@kubohiroya/turbowarp-plus-editor-git 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +373 -0
  2. package/README.ja.md +105 -0
  3. package/README.md +35 -2
  4. package/THIRD_PARTY_NOTICES.md +5 -0
  5. package/dist/THIRD_PARTY_NOTICES.md +1186 -0
  6. package/dist/block-diff-location.d.ts +22 -0
  7. package/dist/block-diff-preview.d.ts +17 -0
  8. package/dist/browser.d.ts +4 -0
  9. package/dist/connection-settings.d.ts +35 -0
  10. package/dist/file-diff-view.d.ts +16 -0
  11. package/dist/filesystem.d.ts +89 -0
  12. package/dist/github-pull-requests.d.ts +38 -0
  13. package/dist/index.d.ts +29 -0
  14. package/dist/index.js +3556 -0
  15. package/dist/libesm-OWRWzmu_.js +739 -0
  16. package/dist/messages.d.ts +112 -0
  17. package/dist/mutation-fs.d.ts +58 -0
  18. package/dist/objectSpread2-Dkd68nJH.js +101 -0
  19. package/dist/project-source-diff.d.ts +22 -0
  20. package/dist/project-source-diff.js +169 -0
  21. package/dist/project-source-patch.d.ts +39 -0
  22. package/dist/project-source-review-model.d.ts +20 -0
  23. package/dist/project-source-review.d.ts +34 -0
  24. package/dist/pull-request-panel.d.ts +8 -0
  25. package/dist/remote-operations.d.ts +5 -0
  26. package/dist/repository-DplMQiuo.js +17212 -0
  27. package/dist/repository.d.ts +61 -0
  28. package/dist/repository.js +1 -0
  29. package/dist/scratch-block-diff-renderer-CLYoOIB2.js +1464 -0
  30. package/dist/scratch-block-diff-renderer.d.ts +65 -0
  31. package/dist/smart-http-BmHEkE7K.js +303 -0
  32. package/dist/smart-http.d.ts +60 -0
  33. package/dist/smart-http.js +1 -0
  34. package/dist/style.css +109 -0
  35. package/package.json +60 -4
  36. package/src/block-diff-location.ts +63 -0
  37. package/src/block-diff-preview.tsx +36 -0
  38. package/src/browser.ts +5 -0
  39. package/src/connection-settings.tsx +103 -0
  40. package/src/file-diff-view.tsx +43 -0
  41. package/src/filesystem.ts +125 -0
  42. package/src/github-pull-requests.ts +162 -0
  43. package/src/index.tsx +349 -0
  44. package/src/messages.ts +64 -0
  45. package/src/mutation-fs.ts +204 -0
  46. package/src/project-source-diff.css +79 -0
  47. package/src/project-source-diff.tsx +132 -0
  48. package/src/project-source-patch.ts +126 -0
  49. package/src/project-source-review-model.ts +113 -0
  50. package/src/project-source-review.tsx +217 -0
  51. package/src/pull-request-panel.tsx +134 -0
  52. package/src/remote-operations.ts +90 -0
  53. package/src/repository.ts +308 -0
  54. package/src/scratch-block-diff-renderer.ts +213 -0
  55. package/src/smart-http.ts +190 -0
  56. package/src/style.css +29 -0
  57. package/src/styles.d.ts +1 -0
  58. package/tsconfig.json +18 -0
  59. package/vite.config.ts +26 -0
package/src/index.tsx ADDED
@@ -0,0 +1,349 @@
1
+ import React, {useEffect, useRef, useState} from 'react';
2
+ import {render, unmountComponentAtNode} from 'react-dom';
3
+ import {FileDiffView} from './file-diff-view.js';
4
+ import {openRepository} from './repository.js';
5
+ import type {Repository, Snapshot, FileChange, FileDiff, ChangeGroup} from './repository.js';
6
+ import {labelsFor} from './messages.js';
7
+ import {GitPanelError} from './filesystem.js';
8
+ import {ConnectionSettings} from './connection-settings.js';
9
+ import type {GitConnectionSettings} from './connection-settings.js';
10
+ import type {RemoteOperation} from './remote-operations.js';
11
+ import {PullRequestReviewPanel} from './pull-request-panel.js';
12
+ import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
13
+
14
+ export {createScratchBlocksDiffRenderer} from './scratch-block-diff-renderer.js';
15
+ export type {ScratchBlockDiffRendererOptions, ScratchBlocksPort} from './scratch-block-diff-renderer.js';
16
+
17
+ export {openRepository, createReadOnlyFs, GitPanelError} from './repository.js';
18
+ export type {Repository, Snapshot, FileChange, FileDiff} from './repository.js';
19
+
20
+ export interface GitHost {
21
+ connectionSettings?: GitConnectionSettings;
22
+ renderReviewBlock?: ProjectBlockRenderer;
23
+ openProject(directory: FileSystemDirectoryHandle, confirmReplace: () => boolean): Promise<'opened' | 'cancelled'>;
24
+ isProjectDirty(): boolean;
25
+ isProjectRunning?(): boolean;
26
+ subscribe?(listener: (event: 'loaded' | 'changed') => void): () => void;
27
+ }
28
+ interface Controller {open(locale: string): void; refresh(automatic?: boolean): Promise<void>}
29
+ interface State {
30
+ repository: Repository | null;
31
+ snapshot: Snapshot | null;
32
+ project: string | null;
33
+ browser: {path: string; children: string[]; isProject: boolean} | null;
34
+ changes: FileChange[];
35
+ selected: FileChange | null;
36
+ diff: FileDiff | null;
37
+ commit: string | null;
38
+ error: string | null;
39
+ notice?: string;
40
+ busy: boolean;
41
+ }
42
+ const initial: State = {repository: null, snapshot: null, project: null, browser: null,
43
+ changes: [], selected: null, diff: null, commit: null, error: null, busy: false};
44
+ const controllers = new WeakMap<object, Controller>();
45
+ type PickerWindow = Window & {showDirectoryPicker?: (options: {id: string; mode: 'readwrite'}) => Promise<FileSystemDirectoryHandle>};
46
+ type ObserverWindow = Window & {FileSystemObserver?: new (callback: () => void) => {
47
+ observe(handle: FileSystemDirectoryHandle, options: {recursive: boolean}): Promise<void>;
48
+ disconnect(): void;
49
+ }};
50
+ const watchInterval = 2000;
51
+
52
+ function GitPanel({document, host, controller}: {document: Document; host: GitHost; controller: Controller}) {
53
+ const [visible, setVisible] = useState(false);
54
+ const [review, setReview] = useState(false);
55
+ const [locale, setLocale] = useState(document.documentElement.lang || 'en');
56
+ const [dirty, setDirty] = useState(host.isProjectDirty());
57
+ const [state, setState] = useState<State>(initial);
58
+ const current = useRef(state);
59
+ current.current = state;
60
+ const epoch = useRef(0);
61
+ const running = useRef(false);
62
+ const refreshPending = useRef(false);
63
+ const remoteRequest = useRef<AbortController>();
64
+ const alive = useRef(true);
65
+ const opening = useRef(false);
66
+ const dialog = useRef<HTMLDivElement>(null);
67
+ const reviewing = useRef(review);
68
+ reviewing.current = review;
69
+ // The last fingerprint acted on, so a failing read or an open diff is not retried on every check.
70
+ const handled = useRef<string | null>(null);
71
+ const labels = labelsFor(locale);
72
+ // A background refresh never drops a user's operation: the operation waits for that refresh instead.
73
+ const background = useRef<Promise<void> | null>(null);
74
+ const run = async (operation: () => Promise<Partial<State>>, automatic = false) => {
75
+ if (running.current && !automatic && background.current) await background.current;
76
+ if (running.current || !alive.current) return;
77
+ let settle = () => {};
78
+ if (automatic) background.current = new Promise(resolve => {settle = resolve;});
79
+ running.current = true;
80
+ const ticket = ++epoch.current;
81
+ // An automatic refresh keeps the current error and notice, such as an uncertain push result.
82
+ setState(previous => automatic ? {...previous, busy: true} : {...previous, busy: true, error: null, notice: undefined});
83
+ try {
84
+ const update = await operation();
85
+ if (epoch.current === ticket) setState(previous => ({...previous, ...update, busy: false}));
86
+ } catch (error) {
87
+ if (epoch.current !== ticket) return;
88
+ const code = error instanceof GitPanelError ? error.code : 'failed';
89
+ const message = labels[code as keyof typeof labels] || labels.failed;
90
+ setState(previous => ({...previous, error: automatic && previous.error ? previous.error : message, busy: false, diff: null}));
91
+ } finally {
92
+ running.current = false;
93
+ if (automatic) {background.current = null; settle();}
94
+ if (refreshPending.current) {
95
+ refreshPending.current = false;
96
+ queueMicrotask(() => void controller.refresh());
97
+ }
98
+ }
99
+ };
100
+ controller.open = language => {setLocale(language); setVisible(true);};
101
+ controller.refresh = async (automatic = false) => {
102
+ if (!alive.current || !current.current.repository) return;
103
+ if (running.current) {
104
+ if (!automatic) refreshPending.current = true;
105
+ return;
106
+ }
107
+ await run(async () => {
108
+ const {repository, project} = current.current;
109
+ const snapshot = await repository!.read(project ?? '');
110
+ return {snapshot, changes: snapshot.changes, selected: null, diff: null, commit: null};
111
+ }, automatic);
112
+ };
113
+ useEffect(() => {
114
+ alive.current = true;
115
+ const unsubscribe = host.subscribe?.(event => {
116
+ setDirty(host.isProjectDirty());
117
+ if (event === 'loaded' && !opening.current) {
118
+ epoch.current++;
119
+ setState(previous => ({...previous, project: null, snapshot: null, changes: [],
120
+ selected: null, diff: null, commit: null, busy: false}));
121
+ queueMicrotask(() => void controller.refresh());
122
+ }
123
+ });
124
+ return () => {alive.current = false; epoch.current++; remoteRequest.current?.abort(); unsubscribe?.();};
125
+ }, [host]);
126
+ // While the panel is open, notice Git commands, agents and editors that change the repository outside the Editor.
127
+ useEffect(() => {
128
+ const repository = state.repository;
129
+ if (!visible || !repository) return;
130
+ const view = document.defaultView as ObserverWindow;
131
+ let stopped = false;
132
+ let checking = false;
133
+ const check = async () => {
134
+ const {snapshot} = current.current;
135
+ if (stopped || checking || running.current || document.visibilityState === 'hidden' ||
136
+ !snapshot || current.current.repository !== repository) return;
137
+ checking = true;
138
+ try {
139
+ const probe = await repository.probe(snapshot.scope);
140
+ if (stopped || probe === 'locked' || probe === snapshot.probe || probe === handled.current ||
141
+ current.current.snapshot !== snapshot) return;
142
+ handled.current = probe;
143
+ const {selected, commit} = current.current;
144
+ // Keep an open diff, commit or review in place and ask for a refresh instead.
145
+ if (selected || commit || reviewing.current) {
146
+ setState(previous => previous.snapshot === snapshot ? {...previous, notice: labelsFor(locale).outdated} : previous);
147
+ } else await controller.refresh(true);
148
+ } catch {
149
+ // Entries can disappear while another tool writes. The next check reads them again.
150
+ } finally {
151
+ checking = false;
152
+ }
153
+ };
154
+ let soon: number | undefined;
155
+ const schedule = () => {view.clearTimeout(soon); soon = view.setTimeout(() => void check(), 300);};
156
+ const interval = view.setInterval(() => void check(), watchInterval);
157
+ const onFocus = () => void check();
158
+ view.addEventListener('focus', onFocus);
159
+ document.addEventListener('visibilitychange', onFocus);
160
+ let observer: InstanceType<NonNullable<ObserverWindow['FileSystemObserver']>> | null = null;
161
+ try {
162
+ if (view.FileSystemObserver) {
163
+ observer = new view.FileSystemObserver(schedule);
164
+ observer.observe(repository.root, {recursive: true}).catch(() => {});
165
+ }
166
+ } catch {
167
+ observer = null;
168
+ }
169
+ void check();
170
+ return () => {
171
+ stopped = true;
172
+ view.clearInterval(interval);
173
+ view.clearTimeout(soon);
174
+ view.removeEventListener('focus', onFocus);
175
+ document.removeEventListener('visibilitychange', onFocus);
176
+ observer?.disconnect();
177
+ };
178
+ }, [visible, state.repository, locale]);
179
+ useEffect(() => {
180
+ if (!visible) return;
181
+ const previous = document.activeElement as HTMLElement | null;
182
+ dialog.current?.focus();
183
+ return () => {if (previous?.isConnected) previous.focus();};
184
+ }, [visible]);
185
+ const chooseRoot = async () => {
186
+ const view = document.defaultView as PickerWindow;
187
+ await run(async () => {
188
+ let root: FileSystemDirectoryHandle;
189
+ try {root = await view.showDirectoryPicker!({id: 'turbowarp-plus-git-root', mode: 'readwrite'});} catch (error) {
190
+ if ((error as DOMException).name === 'AbortError') return {};
191
+ throw error;
192
+ }
193
+ const repository = await openRepository(root);
194
+ const snapshot = await repository.read();
195
+ const browser = await repository.browse();
196
+ await host.connectionSettings?.disconnect();
197
+ setReview(false);
198
+ return {...initial, repository, snapshot, browser, changes: snapshot.changes};
199
+ });
200
+ };
201
+ const browse = (path: string) => run(async () => ({browser: await state.repository!.browse(path)}));
202
+ const openProject = (path: string) => run(async () => {
203
+ const directory = await state.repository!.openProject(path);
204
+ opening.current = true;
205
+ try {
206
+ const result = await host.openProject(directory, () => document.defaultView!.confirm(labels.replace));
207
+ if (result === 'cancelled') return {};
208
+ } finally {opening.current = false;}
209
+ if (!alive.current) return {};
210
+ setDirty(host.isProjectDirty());
211
+ setState(previous => ({...previous, project: path, snapshot: null, changes: [], selected: null, diff: null, commit: null}));
212
+ const snapshot = await state.repository!.read(path);
213
+ return {project: path, snapshot, changes: snapshot.changes, selected: null, diff: null, commit: null};
214
+ });
215
+ const selectFile = (selected: FileChange) => run(async () => ({selected,
216
+ diff: await state.repository!.readDiff(state.snapshot!, selected)}));
217
+ const selectCommit = (commit: string) => run(async () => ({commit,
218
+ changes: await state.repository!.historyChanges(state.snapshot!, commit), selected: null, diff: null}));
219
+ const remoteOperation = async (operation: RemoteOperation) => {
220
+ const repository = current.current.repository;
221
+ const getSession = host.connectionSettings?.getSession;
222
+ if (!repository || !getSession) return;
223
+ if (operation === 'push' && !document.defaultView?.confirm(labels.confirmPush)) return;
224
+ await run(async () => {
225
+ const ticket = epoch.current;
226
+ const request = new AbortController();
227
+ remoteRequest.current = request;
228
+ try {
229
+ await repository.remoteOperation(operation, getSession, () => {
230
+ if (epoch.current !== ticket) throw new GitPanelError('changed');
231
+ if (operation === 'pull' && (host.isProjectDirty() || host.isProjectRunning?.()))
232
+ throw new GitPanelError('pullUnsafe');
233
+ }, request.signal);
234
+ } finally {
235
+ remoteRequest.current = undefined;
236
+ }
237
+ const snapshot = await repository.read(current.current.project ?? '');
238
+ return {snapshot, changes: snapshot.changes, selected: null, diff: null, commit: null,
239
+ notice: operation === 'pull' && current.current.project !== null ? labels.pulledProject : labels.remoteDone};
240
+ });
241
+ };
242
+ const focusTrap = (event: React.KeyboardEvent<HTMLDivElement>) => {
243
+ if (event.key === 'Escape') {event.stopPropagation(); setVisible(false); return;}
244
+ if (event.key !== 'Tab') return;
245
+ const items = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), a[href], summary, [tabindex="0"]')]
246
+ .filter(item => item.getClientRects().length > 0);
247
+ const first = items[0], last = items[items.length - 1];
248
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === event.currentTarget)) {
249
+ event.preventDefault(); last?.focus();
250
+ } else if (!event.shiftKey && document.activeElement === last) {event.preventDefault(); first?.focus();}
251
+ };
252
+ if (!visible) return null;
253
+ const groups: ChangeGroup[] = state.commit ? ['commit'] : ['staged', 'unstaged', 'untracked'];
254
+ return <div className="twgit-backdrop">
255
+ <div className="twgit-panel" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-label={labels.title}
256
+ onKeyDown={focusTrap}>
257
+ <header>
258
+ <h2>{labels.title}</h2>
259
+ <button onClick={chooseRoot} disabled={state.busy}>{labels.choose}</button>
260
+ {!review && <button onClick={() => void controller.refresh()} disabled={!state.repository || state.busy}>{labels.refresh}</button>}
261
+ {!review && state.project !== null && <button disabled={state.busy} onClick={() => void openProject(state.project!)}>{labels.reload}</button>}
262
+ {state.repository && host.renderReviewBlock && <button type="button" disabled={state.busy}
263
+ onClick={() => setReview(!review)}>{review ? labels.localChanges : labels.pullRequests}</button>}
264
+ <button onClick={() => setVisible(false)}>{labels.close}</button>
265
+ </header>
266
+ <div className="twgit-context">
267
+ <span>{labels.repository} · {state.repository?.root.name || '—'}</span>
268
+ <span>{labels.project} · {state.project === null ? labels.none : state.project || labels.root}</span>
269
+ <strong>{state.snapshot?.branch || (state.snapshot?.head ? labels.detached : labels.unborn)}</strong>
270
+ <code>{state.snapshot?.head?.slice(0, 12)}</code>
271
+ <span data-git-dirty="">{dirty ? labels.dirty : labels.saved}</span>
272
+ </div>
273
+ {!review && host.connectionSettings && <ConnectionSettings settings={host.connectionSettings} locale={locale}
274
+ runOperation={host.connectionSettings.getSession ? remoteOperation : undefined}
275
+ readOrigin={state.repository && !state.busy ? async () => {
276
+ const ticket = epoch.current;
277
+ const remote = await state.repository!.readOrigin();
278
+ return epoch.current === ticket ? remote : undefined;
279
+ } : undefined}/>}
280
+ {state.error && <p className="twgit-error" role="alert">{state.error}</p>}
281
+ {state.notice && <p className="twgit-context" role="status">{state.notice}</p>}
282
+ {state.busy && <p role="status">{labels.loading}</p>}
283
+ {!state.repository && <p>{labels.empty}</p>}
284
+ {review && state.repository && host.renderReviewBlock &&
285
+ <PullRequestReviewPanel repository={state.repository} locale={locale} renderBlock={host.renderReviewBlock}/>}
286
+ {!review && state.repository && <div className="twgit-content">
287
+ <aside>
288
+ {state.browser && <details>
289
+ <summary>{labels.browse}</summary>
290
+ <p><code>{state.browser.path || '/'}</code></p>
291
+ <button disabled={state.busy || !state.browser.path} onClick={() => void browse(state.browser!.path.split('/').slice(0, -1).join('/'))}>{labels.up}</button>
292
+ <button data-git-open-project="" disabled={state.busy || !state.browser.isProject} onClick={() => void openProject(state.browser!.path)}>{labels.open}</button>
293
+ <ul>{state.browser.children.map(name => <li key={name}><button disabled={state.busy}
294
+ onClick={() => void browse([state.browser!.path, name].filter(Boolean).join('/'))}>{name}/</button></li>)}</ul>
295
+ </details>}
296
+ <h3><button disabled={state.busy} onClick={() => void controller.refresh()}>{labels.worktree}</button></h3>
297
+ {groups.map(group => <section key={group}>
298
+ <h3>{labels[group]} · {state.changes.filter(change => change.group === group).length}</h3>
299
+ <ul>{state.changes.filter(change => change.group === group).map(change => <li key={change.path}>
300
+ <button className="twgit-file" data-git-group={group} disabled={state.busy}
301
+ aria-pressed={state.selected === change} onClick={() => void selectFile(change)}>
302
+ <span>{!change.after ? 'D' : !change.before ? 'A' : 'M'}</span> {change.path}
303
+ </button>
304
+ </li>)}</ul>
305
+ </section>)}
306
+ {!state.changes.length && <p>{labels.clean}</p>}
307
+ <details><summary>{labels.branches}</summary><ul>{state.snapshot?.branches.map(branch => <li key={branch}>{branch}</li>)}</ul></details>
308
+ <h3>{labels.history}</h3>
309
+ <ol className="twgit-history">{state.snapshot?.commits.map(commit => <li key={commit.oid}>
310
+ <button disabled={state.busy} aria-pressed={state.commit === commit.oid} title={commit.oid}
311
+ onClick={() => void selectCommit(commit.oid)}>{commit.message.split('\n')[0]}</button>
312
+ <small><code>{commit.oid.slice(0, 8)}</code> · {commit.author} · {new Date(commit.timestamp * 1000).toLocaleString(locale)}</small>
313
+ </li>)}</ol>
314
+ </aside>
315
+ <main className="twgit-diff">
316
+ {state.selected ? <FileDiffView change={state.selected} diff={state.diff} labels={labels} locale={locale}
317
+ renderBlock={host.renderReviewBlock}/> : <p>{labels.select}</p>}
318
+ </main>
319
+ </div>}
320
+ <footer>{labels.permissions}</footer>
321
+ </div>
322
+ </div>;
323
+ }
324
+
325
+ export function mountGitPanel({vm, document, host}: {vm: object; document: Document; host: GitHost}) {
326
+ if (!(document.defaultView as PickerWindow)?.showDirectoryPicker) return null;
327
+ const root = document.createElement('div');
328
+ document.body.append(root);
329
+ const controller: Controller = {open() {}, async refresh() {}};
330
+ controllers.set(vm, controller);
331
+ render(<GitPanel document={document} host={host} controller={controller}/>, root);
332
+ return {refresh: () => controller.refresh(), dispose() {
333
+ if (controllers.get(vm) === controller) controllers.delete(vm);
334
+ void host.connectionSettings?.disconnect().catch(() => {});
335
+ unmountComponentAtNode(root);
336
+ root.remove();
337
+ }};
338
+ }
339
+
340
+ export function renderGitMenu(createElement: (component: any, props: any, ...children: any[]) => any,
341
+ {vm, locale = 'en', MenuItem, MenuSection, onRequestClose}: {
342
+ vm: object; locale?: string; MenuItem: any; MenuSection: any; onRequestClose(): void;
343
+ }) {
344
+ const controller = controllers.get(vm);
345
+ if (!controller) return null;
346
+ return createElement(MenuSection, null, createElement(MenuItem, {
347
+ command: 'local-git', onClick() {onRequestClose(); controller.open(locale);}
348
+ }, labelsFor(locale).menu));
349
+ }
@@ -0,0 +1,64 @@
1
+ export const messages = {
2
+ en: {
3
+ pullRequests: 'Pull requests', localChanges: 'Local changes',
4
+ title: 'Local Git', menu: 'Local Git…', close: 'Close', choose: 'Choose Repository…', refresh: 'Refresh',
5
+ empty: 'Choose an existing Git repository to inspect its files and history.',
6
+ repository: 'Repository', project: 'Project folder', none: 'No project selected', root: 'Repository root',
7
+ open: 'Open This Project', reload: 'Reload Project', up: 'Parent folder', browse: 'Choose a project inside this repository',
8
+ replace: 'Replace the open project and discard its unsaved changes?',
9
+ dirty: 'The Editor has unsaved changes.', saved: 'The Editor has no unsaved changes.',
10
+ staged: 'Staged', unstaged: 'Unstaged', untracked: 'Untracked', commit: 'Commit changes',
11
+ history: 'History', worktree: 'Working tree', clean: 'No changes in the selected folder.',
12
+ branches: 'Local branches', unborn: 'No commits yet', detached: 'Detached HEAD', select: 'Select a file to view its diff.',
13
+ binary: 'Binary asset', large: 'Text preview exceeds its display limit.', bytes: 'bytes',
14
+ mode: 'Git file mode', permissions: 'Working-tree executable permission changes are unavailable in the browser.',
15
+ unchanged: 'The text is unchanged.', loading: 'Reading repository…',
16
+ invalid: 'Choose a repository with a .git directory, and a project inside that repository.',
17
+ changed: 'Files or Git state changed while reading. Refresh to read the current state.',
18
+ outdated: 'The repository changed outside the Editor. Refresh to read the current state.',
19
+ unsupported: 'This repository contains an entry the browser cannot represent.',
20
+ pullUnsafe: 'Stop the project and clear unsaved or local Git changes before pulling.',
21
+ diverged: 'Pull requires a fast-forward. Your local branch and files are intact.',
22
+ session: 'Reconnect GitHub for this repository and requested permission.',
23
+ pushUnknown: 'The push result is unknown. Fetch and check the remote branch before retrying.',
24
+ pushRefresh: 'The push succeeded. Fetch to refresh the local tracking branch.',
25
+ rollbackFailed: 'Git could not restore all local changes. Check the working tree and Git status.',
26
+ remoteFailed: 'The Git operation failed. Check the selected connection and retry.',
27
+ remoteDone: 'Git operation completed.',
28
+ pulledProject: 'Pull completed. Use Reload Project to open the updated files.',
29
+ confirmPush: 'Push the current branch to its GitHub origin?',
30
+ failed: 'Could not read the repository. Check folder access and refresh.'
31
+ },
32
+ ja: {
33
+ pullRequests: 'プルリクエスト', localChanges: 'ローカルの変更',
34
+ title: 'ローカル Git', menu: 'ローカル Git…', close: '閉じる', choose: 'リポジトリを選択…', refresh: '再取得',
35
+ empty: '既存の Git リポジトリを選び、ファイルの状態と履歴を確認できます。',
36
+ repository: 'リポジトリ', project: '作品フォルダ', none: '作品は未選択', root: 'リポジトリルート',
37
+ open: 'この作品を開く', reload: '作品を再読込', up: '上のフォルダ', browse: 'リポジトリ内の作品を選択',
38
+ replace: '未保存の変更を破棄して、別の作品を開きますか?',
39
+ dirty: 'Editor に未保存の変更があります。', saved: 'Editor に未保存の変更はありません。',
40
+ staged: 'ステージ済み', unstaged: '未ステージ', untracked: '未追跡', commit: 'コミットの変更',
41
+ history: '履歴', worktree: '作業ツリー', clean: '対象フォルダに変更はありません。',
42
+ branches: 'ローカルブランチ', unborn: 'コミットはまだありません', detached: 'ブランチ未選択の HEAD', select: 'ファイルを選ぶと差分を表示します。',
43
+ binary: 'バイナリアセット', large: 'テキスト差分の表示上限を超えています。', bytes: 'バイト',
44
+ mode: 'Git のファイルモード', permissions: '作業ツリーの実行権限の変更はブラウザーから確認できません。',
45
+ unchanged: 'テキストに変更はありません。', loading: 'リポジトリを読込中…',
46
+ invalid: '.git ディレクトリを持つリポジトリと、その配下の作品を選んでください。',
47
+ changed: '読込中にファイルまたは Git の状態が変わりました。再取得してください。',
48
+ outdated: 'Editor の外でリポジトリが変更されました。再取得すると最新の状態を読み込みます。',
49
+ unsupported: 'ブラウザーで扱えない種類の Git エントリーがあります。',
50
+ pullUnsafe: 'pull の前に実行を止め、未保存編集とローカルの Git 変更を解消してください。',
51
+ diverged: 'fast-forward できません。ローカルブランチとファイルは保持されています。',
52
+ session: 'このリポジトリと操作に必要な権限で GitHub に接続してください。',
53
+ pushUnknown: 'push の結果が不明です。再試行する前に fetch でリモートブランチを確認してください。',
54
+ pushRefresh: 'push は成功しました。fetch でローカルの追跡ブランチを更新してください。',
55
+ rollbackFailed: 'Git がローカルの変更をすべて復元できませんでした。作業ツリーと Git の状態を確認してください。',
56
+ remoteFailed: 'Git 操作に失敗しました。選択中の接続を確認して再試行してください。',
57
+ remoteDone: 'Git 操作が完了しました。',
58
+ pulledProject: 'pull が完了しました。「作品を再読込」で更新済みのファイルを開けます。',
59
+ confirmPush: '現在のブランチを GitHub の origin に push しますか?',
60
+ failed: 'リポジトリを読み込めませんでした。フォルダへのアクセスを確認し、再取得してください。'
61
+ }
62
+ };
63
+ export type Labels = typeof messages.en;
64
+ export const labelsFor = (locale: string): Labels => locale.startsWith('ja') ? messages.ja : messages.en;
@@ -0,0 +1,204 @@
1
+ import {createReadOnlyFs, errorCode, fileError, fileStat, GitPanelError, pathParts, sameBytes} from './filesystem.js';
2
+
3
+ type Entry = {kind: 'directory'} | {kind: 'file'; bytes: Uint8Array<ArrayBuffer>; time: number} | null;
4
+ type Change = {path: string; before: Entry; after: Entry};
5
+ const missing = (error: unknown) => ['ENOENT', 'ENOTDIR'].includes(errorCode(error) ?? '');
6
+
7
+ // Stage Git's writes before touching the granted directory. Pack data precedes refs.
8
+ export function createMutationFs(root: FileSystemDirectoryHandle, worktree: boolean) {
9
+ const base = createReadOnlyFs(root);
10
+ const changes = new Map<string, Change>();
11
+ const normalize = (path: string) => '/' + pathParts(path).join('/');
12
+ const parent = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/';
13
+ const name = (path: string) => path.slice(path.lastIndexOf('/') + 1);
14
+ const fail = (code: string, path: string): never => {throw fileError(code, path);};
15
+ function allow(path: string, directory = false) {
16
+ const parts = pathParts(path);
17
+ const [first, second, ...rest] = parts;
18
+ if (!first || parts.slice(1).some(part => part.toLowerCase() === '.git'))
19
+ return fail('EACCES', path);
20
+ if (first === '.git') {
21
+ const writableFolder = second === 'refs' || second === 'logs' ||
22
+ (second === 'objects' && rest[0]?.toLowerCase() !== 'info');
23
+ const writableFile = !directory && !rest.length &&
24
+ ['HEAD', 'ORIG_HEAD', 'FETCH_HEAD', 'index', 'packed-refs', 'shallow'].includes(second);
25
+ if (writableFolder || writableFile) return;
26
+ } else if (first.toLowerCase() !== '.git' && worktree) {
27
+ return;
28
+ }
29
+ fail('EACCES', path);
30
+ }
31
+
32
+ async function readEntry(fs: typeof base.promises, path: string): Promise<Entry> {
33
+ try {
34
+ const stat = await fs.stat(path);
35
+ if (stat.isDirectory()) return {kind: 'directory'};
36
+ const bytes = await fs.readFile(path);
37
+ if (!(bytes instanceof Uint8Array)) throw new GitPanelError('unsupported');
38
+ return {kind: 'file', bytes: Uint8Array.from(bytes), time: stat.mtimeMs};
39
+ } catch (error) {
40
+ if (missing(error)) return null;
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ async function kind(path: string) {
46
+ const change = changes.get(path);
47
+ if (change) return change.after?.kind;
48
+ try {return (await base.promises.stat(path)).isDirectory() ? 'directory' : 'file';}
49
+ catch (error) {if (!missing(error)) throw error;}
50
+ }
51
+
52
+ async function stage(path: string, after: Entry) {
53
+ const previous = changes.get(path);
54
+ const before = previous ? previous.before : await readEntry(base.promises, path);
55
+ if (before && after && before.kind !== after.kind) throw new GitPanelError('unsupported');
56
+ changes.set(path, {path, before, after});
57
+ }
58
+
59
+ async function readdir(raw: string) {
60
+ const path = normalize(raw);
61
+ const current = await kind(path);
62
+ if (current !== 'directory') return fail(current ? 'ENOTDIR' : 'ENOENT', path);
63
+ const names = new Set<string>();
64
+ try {for (const entry of await base.promises.readdir(path)) names.add(entry);}
65
+ catch (error) {if (!missing(error)) throw error;}
66
+ for (const change of changes.values()) {
67
+ if (parent(change.path) !== path) continue;
68
+ if (change.after) names.add(name(change.path));
69
+ else names.delete(name(change.path));
70
+ }
71
+ return [...names];
72
+ }
73
+
74
+ async function remove(raw: string, directory: boolean) {
75
+ const path = normalize(raw);
76
+ allow(path, directory);
77
+ const current = await kind(path);
78
+ if (!current) return fail('ENOENT', path);
79
+ if (directory !== (current === 'directory')) return fail(directory ? 'ENOTDIR' : 'EISDIR', path);
80
+ if (directory && (await readdir(path)).length) return fail('ENOTEMPTY', path);
81
+ await stage(path, null);
82
+ }
83
+
84
+ async function stat(raw: string) {
85
+ const path = normalize(raw);
86
+ const change = changes.get(path);
87
+ if (!change) return base.promises.stat(path);
88
+ if (!change.after) return fail('ENOENT', path);
89
+ return fileStat(change.after.kind === 'directory' ? null :
90
+ {size: change.after.bytes.length, lastModified: change.after.time});
91
+ }
92
+
93
+ const fs = {promises: {
94
+ async readFile(raw: string, options?: string | {encoding?: string}) {
95
+ const path = normalize(raw);
96
+ const change = changes.get(path);
97
+ if (!change) return base.promises.readFile(path, options);
98
+ if (!change.after) return fail('ENOENT', path);
99
+ if (change.after.kind === 'directory') return fail('EISDIR', path);
100
+ const encoding = typeof options === 'string' ? options : options?.encoding;
101
+ return encoding ? new TextDecoder(encoding).decode(change.after.bytes) : change.after.bytes.slice();
102
+ },
103
+ async writeFile(raw: string, bytes: string | Uint8Array, options?: {mode?: number}) {
104
+ const path = normalize(raw);
105
+ allow(path);
106
+ if (!path.startsWith('/.git/') && options?.mode && (options.mode & 0o111))
107
+ throw new GitPanelError('unsupported');
108
+ if (await kind(parent(path)) !== 'directory') return fail('ENOENT', path);
109
+ if (await kind(path) === 'directory') return fail('EISDIR', path);
110
+ await stage(path, {kind: 'file', bytes: typeof bytes === 'string' ?
111
+ new TextEncoder().encode(bytes) : Uint8Array.from(bytes), time: Date.now()});
112
+ },
113
+ async mkdir(raw: string) {
114
+ const path = normalize(raw);
115
+ if (await kind(path)) return fail('EEXIST', path);
116
+ allow(path, true);
117
+ if (await kind(parent(path)) !== 'directory') return fail('ENOENT', path);
118
+ await stage(path, {kind: 'directory'});
119
+ },
120
+ unlink: (path: string) => remove(path, false),
121
+ rmdir: (path: string) => remove(path, true),
122
+ readdir, stat, lstat: stat,
123
+ readlink: base.promises.readlink,
124
+ symlink: base.promises.symlink,
125
+ chmod: base.promises.chmod
126
+ }};
127
+
128
+ function equal(a: Entry, b: Entry) {
129
+ return a?.kind === b?.kind && (a?.kind !== 'file' || (b?.kind === 'file' && sameBytes(a.bytes, b.bytes)));
130
+ }
131
+
132
+ async function apply(path: string, from: Entry, to: Entry) {
133
+ const current = createReadOnlyFs(root).promises;
134
+ if (path.startsWith('/.git/') && (await readEntry(current, path + '.lock') ||
135
+ await readEntry(current, '/.git/index.lock') || await readEntry(current, '/.git/packed-refs.lock')))
136
+ throw new GitPanelError('changed');
137
+ if (!equal(await readEntry(current, path), from))
138
+ throw new GitPanelError('changed');
139
+ let directory = root;
140
+ for (const part of pathParts(parent(path))) directory = await directory.getDirectoryHandle(part);
141
+ const filename = name(path);
142
+ if (!to) await directory.removeEntry(filename);
143
+ else if (to.kind === 'directory') await directory.getDirectoryHandle(filename, {create: true});
144
+ else {
145
+ const handle = await directory.getFileHandle(filename, {create: !from});
146
+ let stream: FileSystemWritableFileStream | undefined;
147
+ try {
148
+ stream = await handle.createWritable();
149
+ await stream.write(to.bytes);
150
+ await stream.close();
151
+ } catch (error) {
152
+ await stream?.abort().catch(() => {});
153
+ if (!from) {
154
+ const current = await handle.getFile();
155
+ if (current.size === 0) await directory.removeEntry(filename);
156
+ }
157
+ throw error;
158
+ }
159
+ }
160
+ }
161
+
162
+ function order(change: Change) {
163
+ const {path, before, after} = change;
164
+ const depth = pathParts(path).length;
165
+ if (!after) return before?.kind === 'directory' ? 1000 - depth : 0;
166
+ if (after.kind === 'directory') return 1000 + depth;
167
+ if (path.startsWith('/.git/objects/')) return 2000;
168
+ if (!path.startsWith('/.git/')) return 3000;
169
+ return path.startsWith('/.git/refs/heads/') || path === '/.git/HEAD' ? 5000 : 4000;
170
+ }
171
+
172
+ return {fs, async commit(guard: () => void) {
173
+ const pending = [...changes.values()].filter(change => !equal(change.before, change.after))
174
+ .sort((left, right) => order(left) - order(right));
175
+ await base.checkReads();
176
+ for (const change of pending) {
177
+ if (!equal(await readEntry(createReadOnlyFs(root).promises, change.path), change.before))
178
+ throw new GitPanelError('changed');
179
+ }
180
+ const applied: Change[] = [];
181
+ try {
182
+ for (const change of pending) {
183
+ guard();
184
+ try {
185
+ await apply(change.path, change.before, change.after);
186
+ applied.push(change);
187
+ } catch (error) {
188
+ const current = await readEntry(createReadOnlyFs(root).promises, change.path);
189
+ if (equal(current, change.after) && !(error instanceof GitPanelError)) applied.push(change);
190
+ throw error;
191
+ }
192
+ }
193
+ guard();
194
+ } catch (error) {
195
+ let rollbackFailed = false;
196
+ for (const change of applied.reverse()) {
197
+ try {await apply(change.path, change.after, change.before);}
198
+ catch {rollbackFailed = true;}
199
+ }
200
+ if (rollbackFailed) throw new GitPanelError('rollbackFailed');
201
+ throw error;
202
+ }
203
+ }};
204
+ }