Skip to content

Commit dc9c51e

Browse files
André DietrichAndré Dietrich
authored andcommitted
add local file api support for chrome
1 parent 9f01071 commit dc9c51e

9 files changed

Lines changed: 1053 additions & 3 deletions

File tree

src/components/ImportMenu.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ export default defineComponent({
2424
2525
computed: {
2626
extraSources(): ImportSource[] {
27-
return this.sources.filter((s) => s.id !== "new");
27+
return this.sources.filter(
28+
(s) => s.id !== "new" && (s.available ? s.available() : true)
29+
);
2830
},
2931
},
3032

src/i18n/de.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"github": "GitHub-Repository",
101101
"url": "Von URL (Markdown / ZIP)",
102102
"gist": "GitHub-Gist",
103+
"localFolder": "Lokaler Ordner",
103104
"githubPlaceholder": "owner/repo oder https://github.com/owner/repo",
104105
"githubHint": "Im nächsten Schritt wählst du die zu importierenden Dateien aus.",
105106
"urlPlaceholder": "https://.../README.md oder .../project.zip",
@@ -277,6 +278,37 @@
277278
"done": "Repository angelegt und Dateien publiziert."
278279
}
279280
},
281+
"localFolder": {
282+
"close": "Schließen",
283+
"menu": {
284+
"header": "Lokaler Ordner",
285+
"open": "Ordner öffnen…",
286+
"openTooltip": "Einen Ordner auf deinem Computer mit diesem Projekt verbinden",
287+
"sync": "Synchronisieren…",
288+
"syncNamed": "„{name}“ synchronisieren…",
289+
"syncTooltip": "Dieses Projekt mit dem verbundenen Ordner vergleichen und abgleichen"
290+
},
291+
"direction": {
292+
"push": "zum Ordner",
293+
"pull": "zum Editor",
294+
"conflict": "Konflikt"
295+
},
296+
"sync": {
297+
"title": "Mit lokalem Ordner synchronisieren",
298+
"computing": "Dateien werden verglichen…",
299+
"inSync": "Editor und Ordner sind bereits synchron.",
300+
"applying": "Synchronisiere…",
301+
"done": "Synchronisierung abgeschlossen.",
302+
"applyBtn": "{n} Datei(en) synchronisieren",
303+
"keepEditor": "Editor behalten",
304+
"keepDisk": "Ordner übernehmen",
305+
"grant": "Zugriff erlauben"
306+
},
307+
"errors": {
308+
"noFolder": "Mit diesem Projekt ist noch kein Ordner verbunden.",
309+
"permission": "Der Zugriff auf den Ordner wurde verweigert. Bitte erlaube den Zugriff, um fortzufahren."
310+
}
311+
},
280312
"nostr": {
281313
"title": "Via Nostr teilen",
282314
"keysSection": "Nostr-Schlüssel",

src/i18n/en.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"github": "GitHub repository",
101101
"url": "From URL (Markdown / ZIP)",
102102
"gist": "GitHub Gist",
103+
"localFolder": "Local folder",
103104
"githubPlaceholder": "owner/repo or https://github.com/owner/repo",
104105
"githubHint": "Pick the files to import on the next screen.",
105106
"urlPlaceholder": "https://.../README.md or .../project.zip",
@@ -277,6 +278,37 @@
277278
"done": "Repository created and files published."
278279
}
279280
},
281+
"localFolder": {
282+
"close": "Close",
283+
"menu": {
284+
"header": "Local folder",
285+
"open": "Open folder…",
286+
"openTooltip": "Link a folder on your computer to this project",
287+
"sync": "Synchronize…",
288+
"syncNamed": "Synchronize “{name}”…",
289+
"syncTooltip": "Compare and sync this project with the linked folder"
290+
},
291+
"direction": {
292+
"push": "to folder",
293+
"pull": "to editor",
294+
"conflict": "conflict"
295+
},
296+
"sync": {
297+
"title": "Synchronize with local folder",
298+
"computing": "Comparing files…",
299+
"inSync": "Editor and folder are already in sync.",
300+
"applying": "Synchronizing…",
301+
"done": "Synchronization complete.",
302+
"applyBtn": "Synchronize {n} file(s)",
303+
"keepEditor": "Keep editor",
304+
"keepDisk": "Keep folder",
305+
"grant": "Grant access"
306+
},
307+
"errors": {
308+
"noFolder": "No folder is linked to this project yet.",
309+
"permission": "Access to the folder was denied. Please grant permission to continue."
310+
}
311+
},
280312
"nostr": {
281313
"title": "Share via Nostr",
282314
"keysSection": "Nostr Keys",

src/ts/LocalFolder.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Low-level wrapper around the File System Access API plus persistence of the
2+
// chosen directory handle and the "last-synced" manifest. This module knows
3+
// nothing about ProjectDoc — the diff/apply logic lives in localFolderSync.ts.
4+
//
5+
// The File System Access API is only available in Chromium-based browsers, so
6+
// every entry point is gated behind isSupported() by the callers. The DOM lib
7+
// does not reliably ship these types, so handles are typed loosely as `any`.
8+
9+
import { Dexie } from "dexie";
10+
11+
/** A FileSystemDirectoryHandle (typed loosely; see file header). */
12+
export type DirHandle = any;
13+
14+
/** Whether the current browser supports the File System Access API. */
15+
export function isSupported(): boolean {
16+
return typeof (window as any).showDirectoryPicker === "function";
17+
}
18+
19+
/** Prompt the user to pick a folder with read/write access. */
20+
export async function pickFolder(): Promise<DirHandle> {
21+
return await (window as any).showDirectoryPicker({ mode: "readwrite" });
22+
}
23+
24+
/**
25+
* Ensure we have permission on a (possibly persisted) handle. Must be called
26+
* from a user gesture, because requestPermission() may show a prompt.
27+
*/
28+
export async function verifyPermission(
29+
handle: DirHandle,
30+
readWrite: boolean
31+
): Promise<boolean> {
32+
const opts = { mode: readWrite ? "readwrite" : "read" };
33+
if ((await handle.queryPermission(opts)) === "granted") return true;
34+
if ((await handle.requestPermission(opts)) === "granted") return true;
35+
return false;
36+
}
37+
38+
export interface LocalEntry {
39+
path: string;
40+
kind: "file" | "directory";
41+
}
42+
43+
/** Directory/file names that are never mirrored. */
44+
const IGNORED = new Set([".git", "node_modules", ".DS_Store"]);
45+
46+
/** Recursively list every file/folder below `handle` as POSIX-style paths. */
47+
export async function readTree(handle: DirHandle): Promise<LocalEntry[]> {
48+
const out: LocalEntry[] = [];
49+
async function walk(dir: DirHandle, prefix: string) {
50+
for await (const entry of dir.values()) {
51+
if (IGNORED.has(entry.name)) continue;
52+
const path = prefix ? prefix + "/" + entry.name : entry.name;
53+
if (entry.kind === "directory") {
54+
out.push({ path, kind: "directory" });
55+
await walk(entry, path);
56+
} else {
57+
out.push({ path, kind: "file" });
58+
}
59+
}
60+
}
61+
await walk(handle, "");
62+
return out;
63+
}
64+
65+
/** Resolve the directory handle that contains `path`, optionally creating it. */
66+
async function parentDir(
67+
root: DirHandle,
68+
path: string,
69+
create: boolean
70+
): Promise<{ dir: DirHandle; name: string }> {
71+
const parts = path.split("/").filter(Boolean);
72+
const name = parts.pop()!;
73+
let dir = root;
74+
for (const part of parts) {
75+
dir = await dir.getDirectoryHandle(part, { create });
76+
}
77+
return { dir, name };
78+
}
79+
80+
/** Read a file's bytes, or undefined if it does not exist. */
81+
export async function readBytes(
82+
handle: DirHandle,
83+
path: string
84+
): Promise<Uint8Array | undefined> {
85+
try {
86+
const { dir, name } = await parentDir(handle, path, false);
87+
const fileHandle = await dir.getFileHandle(name, { create: false });
88+
const file = await fileHandle.getFile();
89+
return new Uint8Array(await file.arrayBuffer());
90+
} catch {
91+
return undefined;
92+
}
93+
}
94+
95+
/** Write a file, creating any intermediate folders. */
96+
export async function writeBytes(
97+
handle: DirHandle,
98+
path: string,
99+
bytes: Uint8Array
100+
): Promise<void> {
101+
const { dir, name } = await parentDir(handle, path, true);
102+
const fileHandle = await dir.getFileHandle(name, { create: true });
103+
const writable = await fileHandle.createWritable();
104+
await writable.write(bytes);
105+
await writable.close();
106+
}
107+
108+
/** Delete a file (or folder, recursively); silently ignores missing paths. */
109+
export async function removePath(handle: DirHandle, path: string): Promise<void> {
110+
try {
111+
const { dir, name } = await parentDir(handle, path, false);
112+
await dir.removeEntry(name, { recursive: true });
113+
} catch {
114+
/* already gone */
115+
}
116+
}
117+
118+
/** Stable content hash (SHA-256, hex) used for the manifest and the diff. */
119+
export async function hashBytes(bytes: Uint8Array): Promise<string> {
120+
const digest = await crypto.subtle.digest("SHA-256", bytes as BufferSource);
121+
return Array.from(new Uint8Array(digest))
122+
.map((b) => b.toString(16).padStart(2, "0"))
123+
.join("");
124+
}
125+
126+
// -----------------------------------------------------------------------------
127+
// persistence: directory handle + last-synced manifest, keyed by storageId
128+
// (separate Dexie database so the existing "LiveEditor" schema stays untouched)
129+
// -----------------------------------------------------------------------------
130+
131+
interface FolderRecord {
132+
id: string;
133+
handle: DirHandle;
134+
name: string;
135+
/** path -> content hash at the time of the last successful sync */
136+
manifest: Record<string, string>;
137+
}
138+
139+
const db = new Dexie("LiveEditorFS");
140+
db.version(1).stores({ folders: "&id" });
141+
const folders = () => db.table<FolderRecord, string>("folders");
142+
143+
export async function loadFolder(storageId: string): Promise<FolderRecord | undefined> {
144+
return await folders().get(storageId);
145+
}
146+
147+
export async function loadHandle(storageId: string): Promise<DirHandle | undefined> {
148+
return (await loadFolder(storageId))?.handle;
149+
}
150+
151+
/** Persist a freshly picked handle, preserving any existing manifest. */
152+
export async function saveHandle(storageId: string, handle: DirHandle): Promise<void> {
153+
const existing = await loadFolder(storageId);
154+
await folders().put({
155+
id: storageId,
156+
handle,
157+
name: handle.name,
158+
manifest: existing?.manifest || {},
159+
});
160+
}
161+
162+
export async function clearHandle(storageId: string): Promise<void> {
163+
await folders().delete(storageId);
164+
}
165+
166+
export async function loadManifest(storageId: string): Promise<Map<string, string>> {
167+
const record = await loadFolder(storageId);
168+
return new Map(Object.entries(record?.manifest || {}));
169+
}
170+
171+
export async function saveManifest(
172+
storageId: string,
173+
manifest: Map<string, string>
174+
): Promise<void> {
175+
const record = await loadFolder(storageId);
176+
if (!record) return;
177+
record.manifest = Object.fromEntries(manifest);
178+
await folders().put(record);
179+
}

src/ts/createProject.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,22 @@ export interface ProjectMeta {
2020
title?: string;
2121
gist_url?: string;
2222
github?: { owner: string; repo: string; branch: string; commitSha: string };
23+
localFolder?: { name: string };
24+
}
25+
26+
/**
27+
* sessionStorage key carrying the storageId of a project that should pop its
28+
* local-folder sync dialog as soon as the editor opens (set by the index page's
29+
* "local folder" import, consumed once in LiaScript.vue's mounted hook).
30+
*/
31+
export const SYNC_ON_OPEN_KEY = "liaeditor:syncOnOpen";
32+
33+
export interface CreateProjectOptions {
34+
/** Run after the project is set up but before navigating to the editor —
35+
* e.g. to persist a linked folder handle while the storageId is known. */
36+
onReady?: (storageId: string) => void | Promise<void>;
37+
/** Auto-open the local-folder sync dialog when the editor mounts. */
38+
syncOnOpen?: boolean;
2339
}
2440

2541
/**
@@ -32,7 +48,8 @@ export interface ProjectMeta {
3248
*/
3349
export async function createProjectFromFiles(
3450
files: ImportFile[],
35-
meta: ProjectMeta = {}
51+
meta: ProjectMeta = {},
52+
opts: CreateProjectOptions = {}
3653
): Promise<string> {
3754
const storageId = randomString(24);
3855
const doc = getProjectDoc(storageId);
@@ -44,6 +61,9 @@ export async function createProjectFromFiles(
4461
const database = new Dexie();
4562
await database.put(storageId, { title: "", ...meta });
4663

64+
if (opts.onReady) await opts.onReady(storageId);
65+
if (opts.syncOnOpen) sessionStorage.setItem(SYNC_ON_OPEN_KEY, storageId);
66+
4767
navigateTo("?/edit/" + storageId);
4868
return storageId;
4969
}

src/ts/importSources.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
import { navigateTo } from "../index";
77
import { getGithubPat } from "./utils";
88
import { parseRepoUrl } from "./GitHubRepo";
9-
import { createProjectFromFiles, filesFromZip, ImportFile } from "./createProject";
9+
import { createProjectFromFiles, filesFromZip, ImportFile, ProjectMeta } from "./createProject";
10+
import * as LocalFolder from "./LocalFolder";
11+
import { detectGitHubRemote } from "./localFolderSync";
1012

1113
export type SourceKind = "navigate" | "fileInput" | "modal";
1214

@@ -29,6 +31,8 @@ export interface ImportSource {
2931
onSubmit?: (input: string) => Promise<void>;
3032
/** navigate: simple action without further input (e.g. new empty course). */
3133
onSelect?: () => void;
34+
/** optional gate: hide the source when it returns false (e.g. unsupported). */
35+
available?: () => boolean;
3236
}
3337

3438
const MD_EXT = /\.(md|markdown)$/i;
@@ -180,4 +184,37 @@ export const importSources: ImportSource[] = [
180184
});
181185
},
182186
},
187+
188+
{
189+
id: "localFolder",
190+
icon: "bi-folder2-open",
191+
labelKey: "index.import.localFolder",
192+
kind: "navigate",
193+
// File System Access API is Chromium-only.
194+
available: () => LocalFolder.isSupported(),
195+
async onSelect() {
196+
let handle: LocalFolder.DirHandle;
197+
try {
198+
handle = await LocalFolder.pickFolder();
199+
} catch {
200+
return; // user dismissed the picker
201+
}
202+
203+
const meta: ProjectMeta = {
204+
title: handle.name,
205+
localFolder: { name: handle.name },
206+
};
207+
// if the folder is a GitHub clone, link it so the user can push directly
208+
const repo = await detectGitHubRemote(handle);
209+
if (repo) meta.github = repo;
210+
211+
// Create an empty project linked to the folder and open the sync dialog in
212+
// the editor (with an empty manifest, every file on disk shows up as a
213+
// pull) — exactly like opening a folder from inside an existing project.
214+
await createProjectFromFiles([], meta, {
215+
syncOnOpen: true,
216+
onReady: (storageId) => LocalFolder.saveHandle(storageId, handle),
217+
});
218+
},
219+
},
183220
];

0 commit comments

Comments
 (0)