Skip to content

Commit fa47ecc

Browse files
authored
chore: persist git-fsa directory handle
2 parents 3775103 + ef1c730 commit fa47ecc

7 files changed

Lines changed: 255 additions & 59 deletions

File tree

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,39 @@
1-
This demo showcase how to run Git in browser but write to a real user file system
2-
folder. It is possible through File System Access API. The API allows to request
3-
a folder from user and then use it as a real file system in browser.
1+
# Git in a real browser folder
42

5-
In this demo we use `memfs` to create a Node `fs`-like file system in browser
6-
out of the folder provided by File System Access API. We then use `isomorphic-git`
7-
to run Git commands on that file system.
3+
This demo shows how to run Git in a browser while writing to a real folder on
4+
the user's device. It uses the File System Access API to select a folder,
5+
`memfs` to expose that folder through a Node.js `fs`-like API, and
6+
`isomorphic-git` to run Git commands.
87

9-
In the demo itself we initiate a Git repo, then we create a `README.md` file, we
10-
stage it, and finally we commit it.
8+
The first time a folder is selected, the demo creates a repository in its
9+
`repo` subdirectory, writes and stages a `README.md`, and creates an initial
10+
commit. The selected directory handle is saved in IndexedDB. On later visits,
11+
the demo reopens the repository or asks the user to restore permission with a
12+
button click.
1113

1214
https://github.com/streamich/memfs/assets/9773803/c15212e8-3ee2-4d2a-b325-9fbdcc377c12
1315

14-
Run:
16+
## Run
17+
18+
From the repository root:
1519

1620
```
17-
yarn demo:git-fsa
21+
yarn build
22+
yarn workspace memfs demo:git-fsa
1823
```
24+
25+
Open `http://localhost:9876` in Chrome or Edge. Browsers treat localhost as a
26+
secure context for the File System Access API.
27+
28+
## Manual test
29+
30+
1. Select a new empty folder and confirm that it receives `repo/README.md` and
31+
a `repo/.git` directory.
32+
2. Reload the page. If the browser asks for permission again, click
33+
**Reconnect saved folder** and grant read/write access.
34+
3. Confirm that the page reports that it reopened the repository and that no
35+
second repository or initial commit is created.
36+
4. Deny a reconnect request and confirm that the page reports the denial
37+
without changing the folder.
38+
5. Click **Forget saved folder**, reload, and confirm that the page asks for a
39+
new folder.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const DATABASE_NAME = 'memfs-git-fsa';
2+
const DATABASE_VERSION = 1;
3+
const STORE_NAME = 'handles';
4+
const ROOT_HANDLE_KEY = 'root';
5+
6+
const openDatabase = (): Promise<IDBDatabase> =>
7+
new Promise((resolve, reject) => {
8+
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
9+
request.onupgradeneeded = () => {
10+
if (!request.result.objectStoreNames.contains(STORE_NAME)) request.result.createObjectStore(STORE_NAME);
11+
};
12+
request.onsuccess = () => resolve(request.result);
13+
request.onerror = () => reject(request.error);
14+
});
15+
16+
const runTransaction = async <T>(
17+
mode: IDBTransactionMode,
18+
createRequest: (store: IDBObjectStore) => IDBRequest<T>,
19+
): Promise<T> => {
20+
const database = await openDatabase();
21+
try {
22+
return await new Promise<T>((resolve, reject) => {
23+
const transaction = database.transaction(STORE_NAME, mode);
24+
const request = createRequest(transaction.objectStore(STORE_NAME));
25+
let result: T;
26+
request.onsuccess = () => {
27+
result = request.result;
28+
};
29+
transaction.oncomplete = () => resolve(result);
30+
transaction.onerror = () => reject(transaction.error);
31+
transaction.onabort = () => reject(transaction.error);
32+
});
33+
} finally {
34+
database.close();
35+
}
36+
};
37+
38+
export const saveDirectoryHandle = async (handle: FileSystemDirectoryHandle): Promise<void> => {
39+
await runTransaction('readwrite', store => store.put(handle, ROOT_HANDLE_KEY));
40+
};
41+
42+
export const loadDirectoryHandle = async (): Promise<FileSystemDirectoryHandle | undefined> =>
43+
await runTransaction('readonly', store => store.get(ROOT_HANDLE_KEY));
44+
45+
export const clearDirectoryHandle = async (): Promise<void> => {
46+
await runTransaction('readwrite', store => store.delete(ROOT_HANDLE_KEY));
47+
};

packages/memfs/demo/git-fsa/main.ts

Lines changed: 167 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,47 +2,185 @@
22
(window as any).Buffer = require('buffer').Buffer;
33

44
import { FsaNodeFs } from '../../src/fsa-to-node';
5-
import type * as fsa from '../../src/fsa/types';
6-
5+
import type { IFileSystemDirectoryHandle } from '../../src/fsa';
76
import git from 'isomorphic-git';
7+
import { clearDirectoryHandle, loadDirectoryHandle, saveDirectoryHandle } from './handle-store';
88

9-
const demo = async (dir: fsa.IFileSystemDirectoryHandle) => {
10-
try {
11-
const fs = ((<any>window).fs = new FsaNodeFs(dir));
9+
const REPO_DIR = '/repo';
10+
const PERMISSION = { mode: 'readwrite' } as const;
1211

13-
console.log('Create "/repo" folder');
14-
await fs.promises.mkdir('/repo');
12+
type BrowserDirectoryHandle = FileSystemDirectoryHandle & {
13+
queryPermission(descriptor: typeof PERMISSION): Promise<PermissionState>;
14+
requestPermission(descriptor: typeof PERMISSION): Promise<PermissionState>;
15+
};
1516

16-
console.log('Init git repo');
17-
await git.init({ fs, dir: 'repo' });
17+
type DemoWindow = Window & {
18+
fs?: FsaNodeFs;
19+
showDirectoryPicker?: (options: { id: string; mode: 'readwrite' }) => Promise<BrowserDirectoryHandle>;
20+
};
1821

19-
console.log('Create README file');
20-
await fs.promises.writeFile('/repo/README.md', 'Hello World\n');
22+
const demoWindow = window as DemoWindow;
2123

22-
console.log('Stage README file');
23-
await git.add({ fs, dir: '/repo', filepath: 'README.md' });
24+
const isMissing = (error: unknown): boolean =>
25+
!!error &&
26+
typeof error === 'object' &&
27+
('code' in error ? error.code === 'ENOENT' : 'name' in error && error.name === 'NotFoundError');
2428

25-
console.log('Commit README file');
26-
await git.commit({
27-
fs,
28-
dir: '/repo',
29-
author: { name: 'Git', email: 'leonid@kingdom.com' },
30-
message: 'fea: initial commit',
31-
});
29+
const hasRepository = async (fs: FsaNodeFs): Promise<boolean> => {
30+
try {
31+
return (await fs.promises.stat(`${REPO_DIR}/.git`)).isDirectory();
3232
} catch (error) {
33-
console.log(error);
34-
console.log((<any>error).name);
33+
if (isMissing(error)) return false;
34+
throw error;
3535
}
3636
};
3737

38-
const main = async () => {
39-
const button = document.createElement('button');
40-
button.textContent = 'Select an empty folder';
41-
document.body.appendChild(button);
42-
button.onclick = async () => {
43-
const dir = await (window as any).showDirectoryPicker({ id: 'demo', mode: 'readwrite' });
44-
await demo(dir);
38+
const createRepository = async (fs: FsaNodeFs): Promise<void> => {
39+
console.log(`Create "${REPO_DIR}" folder`);
40+
await fs.promises.mkdir(REPO_DIR, { recursive: true });
41+
42+
console.log('Init git repo');
43+
await git.init({ fs, dir: REPO_DIR });
44+
45+
console.log('Create README file');
46+
await fs.promises.writeFile(`${REPO_DIR}/README.md`, 'Hello World\n');
47+
48+
console.log('Stage README file');
49+
await git.add({ fs, dir: REPO_DIR, filepath: 'README.md' });
50+
51+
console.log('Commit README file');
52+
await git.commit({
53+
fs,
54+
dir: REPO_DIR,
55+
author: { name: 'Git', email: 'leonid@kingdom.com' },
56+
message: 'fea: initial commit',
57+
});
58+
};
59+
60+
const openDirectory = async (handle: BrowserDirectoryHandle): Promise<'created' | 'reopened'> => {
61+
const root = handle as unknown as IFileSystemDirectoryHandle;
62+
const fs = (demoWindow.fs = new FsaNodeFs(root));
63+
if (!(await hasRepository(fs))) {
64+
await createRepository(fs);
65+
return 'created';
66+
}
67+
const [latestCommit] = await git.log({ fs, dir: REPO_DIR, depth: 1 });
68+
console.log(`Reopened "${handle.name}" at commit ${latestCommit.oid}`);
69+
return 'reopened';
70+
};
71+
72+
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
73+
74+
const isAbort = (error: unknown): boolean =>
75+
!!error && typeof error === 'object' && 'name' in error && error.name === 'AbortError';
76+
77+
const main = async (): Promise<void> => {
78+
const heading = document.createElement('h1');
79+
heading.textContent = 'Git in a real folder';
80+
const status = document.createElement('p');
81+
const selectButton = document.createElement('button');
82+
selectButton.textContent = 'Select an empty folder';
83+
const reconnectButton = document.createElement('button');
84+
reconnectButton.textContent = 'Reconnect saved folder';
85+
reconnectButton.hidden = true;
86+
const forgetButton = document.createElement('button');
87+
forgetButton.textContent = 'Forget saved folder';
88+
forgetButton.hidden = true;
89+
document.body.append(heading, status, selectButton, reconnectButton, forgetButton);
90+
91+
let savedHandle: BrowserDirectoryHandle | undefined;
92+
93+
const setBusy = (busy: boolean): void => {
94+
selectButton.disabled = busy;
95+
reconnectButton.disabled = busy;
96+
forgetButton.disabled = busy;
97+
};
98+
99+
const showOpened = (handle: BrowserDirectoryHandle, result: 'created' | 'reopened'): void => {
100+
status.textContent =
101+
result === 'created'
102+
? `Created the demo repository in "${handle.name}".`
103+
: `Reopened the demo repository in "${handle.name}".`;
104+
reconnectButton.hidden = true;
105+
forgetButton.hidden = false;
106+
};
107+
108+
const activate = async (handle: BrowserDirectoryHandle): Promise<void> => {
109+
showOpened(handle, await openDirectory(handle));
110+
};
111+
112+
selectButton.onclick = async () => {
113+
if (!demoWindow.showDirectoryPicker) return;
114+
setBusy(true);
115+
try {
116+
const handle = await demoWindow.showDirectoryPicker({ id: 'git-fsa-demo', mode: 'readwrite' });
117+
await activate(handle);
118+
await saveDirectoryHandle(handle);
119+
savedHandle = handle;
120+
} catch (error) {
121+
if (!isAbort(error)) status.textContent = `Could not open the folder: ${errorMessage(error)}`;
122+
} finally {
123+
setBusy(false);
124+
}
125+
};
126+
127+
reconnectButton.onclick = async () => {
128+
if (!savedHandle) return;
129+
setBusy(true);
130+
try {
131+
if ((await savedHandle.requestPermission(PERMISSION)) === 'granted') await activate(savedHandle);
132+
else status.textContent = `Permission was not granted for "${savedHandle.name}".`;
133+
} catch (error) {
134+
status.textContent = `Could not reconnect the folder: ${errorMessage(error)}`;
135+
} finally {
136+
setBusy(false);
137+
}
45138
};
139+
140+
forgetButton.onclick = async () => {
141+
setBusy(true);
142+
try {
143+
await clearDirectoryHandle();
144+
savedHandle = undefined;
145+
delete demoWindow.fs;
146+
status.textContent = 'Forgot the saved folder.';
147+
reconnectButton.hidden = true;
148+
forgetButton.hidden = true;
149+
} catch (error) {
150+
status.textContent = `Could not forget the folder: ${errorMessage(error)}`;
151+
} finally {
152+
setBusy(false);
153+
}
154+
};
155+
156+
if (!demoWindow.showDirectoryPicker) {
157+
selectButton.disabled = true;
158+
status.textContent = 'This browser does not support the File System Access API.';
159+
return;
160+
}
161+
162+
try {
163+
savedHandle = (await loadDirectoryHandle()) as BrowserDirectoryHandle | undefined;
164+
if (!savedHandle) {
165+
status.textContent = 'Select an empty folder to create the demo repository.';
166+
return;
167+
}
168+
forgetButton.hidden = false;
169+
if ((await savedHandle.queryPermission(PERMISSION)) === 'granted') {
170+
setBusy(true);
171+
try {
172+
await activate(savedHandle);
173+
} finally {
174+
setBusy(false);
175+
}
176+
} else {
177+
status.textContent = `Reconnect "${savedHandle.name}" to continue.`;
178+
reconnectButton.hidden = false;
179+
}
180+
} catch (error) {
181+
status.textContent = `Could not restore the saved folder: ${errorMessage(error)}`;
182+
reconnectButton.hidden = !savedHandle;
183+
}
46184
};
47185

48186
main();

packages/memfs/demo/git-fsa/webpack.config.js

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
const path = require('path');
22
const HtmlWebpackPlugin = require('html-webpack-plugin');
33
const root = require('app-root-path');
4+
const webpack = require('webpack');
45

56
module.exports = {
67
mode: 'development',
78
devtool: 'inline-source-map',
89
entry: {
910
bundle: __dirname + '/main',
10-
worker: __dirname + '/worker',
1111
},
1212
plugins: [
13+
new webpack.NormalModuleReplacementPlugin(/^node:/, resource => {
14+
resource.request = resource.request.slice(5);
15+
}),
1316
new HtmlWebpackPlugin({
14-
title: 'Development',
17+
title: 'Git in a real folder',
1518
}),
1619
],
1720
module: {
@@ -27,11 +30,12 @@ module.exports = {
2730
extensions: ['.tsx', '.ts', '.js'],
2831
fallback: {
2932
assert: require.resolve('assert'),
30-
buffer: require.resolve('buffer'),
33+
buffer: require.resolve('buffer/'),
34+
events: require.resolve('events/'),
3135
path: require.resolve('path-browserify'),
3236
process: require.resolve('process/browser'),
3337
stream: require.resolve('readable-stream'),
34-
url: require.resolve('url'),
38+
url: require.resolve('url/'),
3539
util: require.resolve('util'),
3640
},
3741
},
@@ -43,13 +47,6 @@ module.exports = {
4347
path: path.resolve(root.path, 'dist'),
4448
},
4549
devServer: {
46-
// HTTPS is required for SharedArrayBuffer to work.
47-
https: true,
48-
headers: {
49-
// These two headers are required for SharedArrayBuffer to work.
50-
'Cross-Origin-Opener-Policy': 'same-origin',
51-
'Cross-Origin-Embedder-Policy': 'require-corp',
52-
},
5350
port: 9876,
5451
hot: false,
5552
},

packages/memfs/demo/git-fsa/worker.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.

packages/memfs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
"app-root-path": "^3.1.0",
103103
"assert": "^2.0.0",
104104
"buffer": "^6.0.3",
105+
"events": "^3.3.0",
105106
"html-webpack-plugin": "^5.5.3",
106107
"husky": "^8.0.1",
107108
"isomorphic-git": "^1.24.2",

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4951,6 +4951,7 @@ __metadata:
49514951
app-root-path: "npm:^3.1.0"
49524952
assert: "npm:^2.0.0"
49534953
buffer: "npm:^6.0.3"
4954+
events: "npm:^3.3.0"
49544955
glob-to-regex.js: "npm:^1.0.1"
49554956
html-webpack-plugin: "npm:^5.5.3"
49564957
husky: "npm:^8.0.1"

0 commit comments

Comments
 (0)