Skip to content

Commit

Permalink
Request permission to local FS when needed (#456)
Browse files Browse the repository at this point in the history
* Detect hdl project folders instead of hardcoding them
* Move dialog.tsx to components
* Prompt user to grant permission when needed
* Fix initialization for local storage fs
  • Loading branch information
netalondon authored Sep 16, 2024
1 parent 65e751e commit 8d4ead9
Show file tree
Hide file tree
Showing 11 changed files with 159 additions and 41 deletions.
File renamed without changes.
18 changes: 17 additions & 1 deletion components/src/file_utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs";
import { FileSystem, Stats } from "@davidsouther/jiffies/lib/esm/fs";
import { Err, Ok, Result } from "@davidsouther/jiffies/lib/esm/result.js";

interface TestFiles {
Expand Down Expand Up @@ -26,3 +26,19 @@ export async function loadTestFiles(
return Err(e as Error);
}
}

export function sortFiles(files: Stats[]) {
return files.sort((a, b) => {
const aIsNum = /^\d+/.test(a.name);
const bIsNum = /^\d+/.test(b.name);
if (aIsNum && !bIsNum) {
return -1;
} else if (!aIsNum && bIsNum) {
return 1;
} else if (aIsNum && bIsNum) {
return parseInt(a.name, 10) - parseInt(b.name, 10);
} else {
return a.name.localeCompare(b.name);
}
});
}
51 changes: 48 additions & 3 deletions components/src/stores/base.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import {
FileSystem,
LocalStorageFileSystemAdapter,
} from "@davidsouther/jiffies/lib/esm/fs.js";
import { Action } from "@nand2tetris/simulator/types.js";
import { Action, AsyncAction } from "@nand2tetris/simulator/types.js";
import {
createContext,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useDialog } from "../dialog.js";
import {
FileSystemAccessFileSystemAdapter,
openNand2TetrisDirectory,
Expand All @@ -29,13 +30,18 @@ export interface BaseContext {
status: string;
setStatus: Action<string>;
storage: Record<string, string>;
permissionPrompt: ReturnType<typeof useDialog>;
requestPermission: AsyncAction<void>;
loadFs: Action<void>;
}

export function useBaseContext(): BaseContext {
const localAdapter = useMemo(() => new LocalStorageFileSystemAdapter(), []);
const [fs, setFs] = useState(new FileSystem(localAdapter));
const [root, setRoot] = useState<string>();

const permissionPrompt = useDialog();

const setLocalFs = useCallback(
async (handle: FileSystemDirectoryHandle, createFiles = false) => {
// We will not mirror the changes in localStorage, since they will be saved in the user's file system
Expand All @@ -52,13 +58,44 @@ export function useBaseContext(): BaseContext {
[setRoot, setFs],
);

const requestPermission = async () => {
attemptLoadAdapterFromIndexedDb().then(async (adapter) => {
if (!adapter) return;
await adapter.requestPermission({ mode: "readwrite" });
});
};

const loadFs = () => {
attemptLoadAdapterFromIndexedDb().then(async (adapter) => {
if (!adapter) return;
setLocalFs(adapter);
});
};

useEffect(() => {
if (root) return;

if ("showDirectoryPicker" in window) {
attemptLoadAdapterFromIndexedDb().then((adapter) => {
attemptLoadAdapterFromIndexedDb().then(async (adapter) => {
if (!adapter) return;
setLocalFs(adapter);

const permissions = await adapter.queryPermission({
mode: "readwrite",
});

switch (permissions) {
case "granted":
setLocalFs(adapter);
break;
case "prompt":
permissionPrompt.open();
break;
case "denied":
setStatus(
"Permission denied. Please allow access to your file system.",
);
break;
}
});
}
}, [root, setLocalFs]);
Expand Down Expand Up @@ -91,8 +128,11 @@ export function useBaseContext(): BaseContext {
setStatus,
storage: localStorage,
canUpgradeFs,
permissionPrompt,
upgradeFs,
requestPermission,
closeFs,
loadFs,
};
}

Expand All @@ -107,4 +147,9 @@ export const BaseContext = createContext<BaseContext>({
// eslint-disable-next-line @typescript-eslint/no-empty-function
setStatus() {},
storage: {},
permissionPrompt: {} as ReturnType<typeof useDialog>,
// eslint-disable-next-line @typescript-eslint/no-empty-function
async requestPermission() {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
loadFs() {},
});
39 changes: 25 additions & 14 deletions components/src/stores/chip.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { getBuiltinChip } from "@nand2tetris/simulator/chip/builtins/index.js";
import { TST } from "@nand2tetris/simulator/languages/tst.js";
import { Action } from "@nand2tetris/simulator/types.js";
import { compare } from "../compare.js";
import { sortFiles } from "../file_utils.js";
import { RunSpeed } from "../runbar.js";
import { BaseContext } from "./base.context.js";

Expand Down Expand Up @@ -285,18 +286,29 @@ export function makeChipStore(

const actions = {
async initialize() {
if (upgraded) {
dispatch.current({
action: "setProjects",
payload: ["1", "2", "3", "5"],
});
await actions.setProject("1");
} else {
dispatch.current({
action: "setProjects",
payload: ["01", "02", "03", "05"],
});
await actions.setProject("01");
const projectsFolder = upgraded ? "/" : "/projects";

const entries = await fs.scandir(projectsFolder);
const hdlProjects = [];

for (const project of entries.filter((project) =>
project.isDirectory(),
)) {
const items = await fs.scandir(`${projectsFolder}/${project.name}`);
if (items.some((item) => item.isFile() && item.name.endsWith(".hdl"))) {
hdlProjects.push(project);
}
}

const sortedNames = sortFiles(hdlProjects).map((project) => project.name);

dispatch.current({
action: "setProjects",
payload: sortedNames,
});

if (hdlProjects.length > 0) {
await actions.setProject(sortedNames[0]);
}

dispatch.current({ action: "clearChip" });
Expand All @@ -311,7 +323,7 @@ export function makeChipStore(
},

async setProject(project: string) {
project = storage["/chip/project"] = project;
storage["/chip/project"] = project;
dispatch.current({ action: "setProject", payload: project });

const chips = (
Expand Down Expand Up @@ -451,7 +463,6 @@ export function makeChipStore(
}) {
invalid = false;
dispatch.current({ action: "setFiles", payload: { hdl, tst, cmp } });
console.log("calling update files");
try {
if (hdl) {
await this.compileChip(hdl, _dir, _chipName);
Expand Down
6 changes: 6 additions & 0 deletions extension/views/hdl/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
FileSystem,
ObjectFileSystemAdapter,
} from "@davidsouther/jiffies/lib/esm/fs.js";
import { useDialog } from "@nand2tetris/components/dialog";
import { BaseContext } from "@nand2tetris/components/stores/base.context.js";
import * as Not from "@nand2tetris/projects/project_01/01_not.js";
import React from "react";
Expand All @@ -21,6 +22,11 @@ const baseContext: BaseContext = {
// api.postMessage({ nand2tetris: true, showMessage: status });
console.log(status);
},
permissionPrompt: {} as ReturnType<typeof useDialog>,
// eslint-disable-next-line @typescript-eslint/no-empty-function
async requestPermission() {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
loadFs() {},
};

const root = ReactDOM.createRoot(
Expand Down
2 changes: 1 addition & 1 deletion web/src/App.context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
import { useDialog } from "@nand2tetris/components/dialog.js";
import { createContext, useCallback, useState } from "react";
import { useDialog } from "./shell/dialog";
import { useFilePicker } from "./shell/file_select";
import { useTracking } from "./tracking";

Expand Down
2 changes: 1 addition & 1 deletion web/src/pages/compiler.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FileSystem } from "@davidsouther/jiffies/lib/esm/fs.js";
import { Trans, t } from "@lingui/macro";
import { useDialog } from "@nand2tetris/components/dialog";
import { BaseContext } from "@nand2tetris/components/stores/base.context";
import {
FileSystemAccessFileSystemAdapter,
Expand All @@ -8,7 +9,6 @@ import {
import { VmFile } from "@nand2tetris/simulator/test/vmtst";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { useDialog } from "src/shell/dialog";
import { Editor } from "src/shell/editor";
import { Tab, TabList } from "src/shell/tabs";
import { AppContext } from "../App.context";
Expand Down
19 changes: 2 additions & 17 deletions web/src/shell/file_select.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { FileSystem, Stats } from "@davidsouther/jiffies/lib/esm/fs";
import { t, Trans } from "@lingui/macro";
import { useDialog } from "@nand2tetris/components/dialog";
import { sortFiles } from "@nand2tetris/components/file_utils";
import { BaseContext } from "@nand2tetris/components/stores/base.context.js";
import type JSZip from "jszip";
import {
Expand All @@ -12,7 +14,6 @@ import {
} from "react";
import { AppContext } from "../App.context";
import { Icon } from "../pico/icon";
import { useDialog } from "./dialog";
import "./file_select.scss";
import { newZip } from "./zip";

Expand Down Expand Up @@ -145,22 +146,6 @@ function isFileValid(filename: string, validSuffixes: string[]) {
.reduce((p1, p2) => p1 || p2, false);
}

function sortFiles(files: Stats[]) {
return files.sort((a, b) => {
const aIsNum = /^\d+/.test(a.name);
const bIsNum = /^\d+/.test(b.name);
if (aIsNum && !bIsNum) {
return -1;
} else if (!aIsNum && bIsNum) {
return 1;
} else if (aIsNum && bIsNum) {
return parseInt(a.name, 10) - parseInt(b.name, 10);
} else {
return a.name.localeCompare(b.name);
}
});
}

export const FilePicker = () => {
const { fs, setStatus, localFsRoot } = useContext(BaseContext);
const { filePicker } = useContext(AppContext);
Expand Down
55 changes: 52 additions & 3 deletions web/src/shell/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,29 @@ import { BaseContext } from "@nand2tetris/components/stores/base.context.js";
import { useContext, useEffect, useMemo, useState } from "react";
import { AppContext } from "../App.context";

import { useDialog } from "@nand2tetris/components/dialog";
import { PageContext } from "src/Page.context";
import "../pico/button-group.scss";
import "../pico/property.scss";
import { TrackingDisclosure } from "../tracking";
import { getVersion, setVersion } from "../versions";
import { useDialog } from "./dialog";
// import { useDialog } from "./dialog";

const showUpgradeFs = true;

export const Settings = () => {
const { stores } = useContext(PageContext);
const { fs, setStatus, canUpgradeFs, upgradeFs, closeFs, localFsRoot } =
useContext(BaseContext);
const {
fs,
setStatus,
canUpgradeFs,
upgradeFs,
closeFs,
localFsRoot,
permissionPrompt,
requestPermission,
loadFs,
} = useContext(BaseContext);
const { settings, monaco, theme, setTheme, tracking } =
useContext(AppContext);

Expand Down Expand Up @@ -67,6 +77,44 @@ export const Settings = () => {
stores.compiler.actions.reset();
};

const permissionPromptDialog = (
<dialog open={permissionPrompt.isOpen}>
<article>
<main>
<div style={{ margin: "10px" }}>
{"Please grant permissions to use your local projects folder"}
</div>
<div
style={{
display: "flex",
justifyContent: "space-around",
marginTop: "30px",
}}
>
<button
style={{ width: "100px" }}
onClick={async () => {
await requestPermission();
loadFs();
permissionPrompt.close();
}}
>
Ok
</button>
<button
style={{ width: "100px" }}
onClick={() => {
permissionPrompt.close();
}}
>
Cancel
</button>
</div>
</main>
</article>
</dialog>
);

const resetWarningDialog = (
<dialog open={resetWarning.isOpen}>
<article>
Expand Down Expand Up @@ -311,6 +359,7 @@ export const Settings = () => {
</main>
</article>
</dialog>
{permissionPromptDialog}
{resetWarningDialog}
{resetConfirmDialog}
</>
Expand Down
2 changes: 1 addition & 1 deletion web/src/shell/test_panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DiffDisplay,
generateDiffs,
} from "@nand2tetris/components/compare.js";
import { useDialog } from "@nand2tetris/components/dialog";
import { loadTestFiles } from "@nand2tetris/components/file_utils";
import { useStateInitializer } from "@nand2tetris/components/react";
import { RunSpeed, Runbar } from "@nand2tetris/components/runbar.js";
Expand All @@ -24,7 +25,6 @@ import {
useState,
} from "react";
import { AppContext } from "../App.context";
import { useDialog } from "./dialog";
import { Editor } from "./editor";
import { Panel } from "./panel";
import { Tab, TabList } from "./tabs";
Expand Down
Loading

0 comments on commit 8d4ead9

Please sign in to comment.