@kubohiroya/turbowarp-plus-editor-git 0.0.0-stage → 0.8.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.
- package/LICENSE +373 -0
- package/README.ja.md +121 -0
- package/README.md +43 -2
- package/THIRD_PARTY_NOTICES.md +5 -0
- package/dist/THIRD_PARTY_NOTICES.md +1186 -0
- package/dist/block-diff-location.d.ts +22 -0
- package/dist/block-diff-preview.d.ts +33 -0
- package/dist/browser.d.ts +4 -0
- package/dist/connection-settings.d.ts +35 -0
- package/dist/file-diff-view.d.ts +18 -0
- package/dist/filesystem.d.ts +89 -0
- package/dist/github-pull-requests.d.ts +38 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +3563 -0
- package/dist/messages.d.ts +112 -0
- package/dist/mutation-fs.d.ts +58 -0
- package/dist/objectSpread2-Dkd68nJH.js +101 -0
- package/dist/project-asset-paths-KV715791.js +761 -0
- package/dist/project-asset-paths.d.ts +12 -0
- package/dist/project-assets.d.ts +19 -0
- package/dist/project-source-diff.d.ts +26 -0
- package/dist/project-source-diff.js +172 -0
- package/dist/project-source-patch.d.ts +39 -0
- package/dist/project-source-review-model.d.ts +23 -0
- package/dist/project-source-review.d.ts +38 -0
- package/dist/pull-request-panel.d.ts +8 -0
- package/dist/remote-operations.d.ts +5 -0
- package/dist/repository-BQkADJmK.js +17280 -0
- package/dist/repository.d.ts +71 -0
- package/dist/repository.js +1 -0
- package/dist/scratch-block-diff-renderer-xS8OYsRl.js +1830 -0
- package/dist/scratch-block-diff-renderer.d.ts +128 -0
- package/dist/smart-http-BmHEkE7K.js +303 -0
- package/dist/smart-http.d.ts +60 -0
- package/dist/smart-http.js +1 -0
- package/dist/style.css +138 -0
- package/package.json +65 -4
- package/src/block-diff-location.ts +63 -0
- package/src/block-diff-preview.tsx +117 -0
- package/src/browser.ts +5 -0
- package/src/connection-settings.tsx +103 -0
- package/src/file-diff-view.tsx +44 -0
- package/src/filesystem.ts +125 -0
- package/src/github-pull-requests.ts +229 -0
- package/src/index.tsx +352 -0
- package/src/messages.ts +64 -0
- package/src/mutation-fs.ts +204 -0
- package/src/project-asset-paths.ts +33 -0
- package/src/project-assets.ts +66 -0
- package/src/project-source-diff.css +108 -0
- package/src/project-source-diff.tsx +139 -0
- package/src/project-source-patch.ts +126 -0
- package/src/project-source-review-model.ts +116 -0
- package/src/project-source-review.tsx +250 -0
- package/src/pull-request-panel.tsx +134 -0
- package/src/remote-operations.ts +90 -0
- package/src/repository.ts +364 -0
- package/src/scratch-block-diff-renderer.ts +331 -0
- package/src/smart-http.ts +190 -0
- package/src/style.css +29 -0
- package/src/styles.d.ts +1 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +26 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as git from 'isomorphic-git';
|
|
2
|
+
import {GitPanelError} from './filesystem.js';
|
|
3
|
+
import {createMutationFs} from './mutation-fs.js';
|
|
4
|
+
import {createGitSmartHttp, GitSmartHttpError} from './smart-http.js';
|
|
5
|
+
import type {GitSmartHttpOptions} from './smart-http.js';
|
|
6
|
+
|
|
7
|
+
export type RemoteOperation = 'fetch' | 'pull' | 'push';
|
|
8
|
+
export type RemoteSession = NonNullable<Awaited<ReturnType<GitSmartHttpOptions['getSession']>>>;
|
|
9
|
+
export type GetRemoteSession = (remote: string, write: boolean) => Promise<RemoteSession | null>;
|
|
10
|
+
|
|
11
|
+
export async function runRemoteOperation(root: FileSystemDirectoryHandle, operation: RemoteOperation,
|
|
12
|
+
getSession: GetRemoteSession, guard: () => void, signal?: AbortSignal) {
|
|
13
|
+
const execute = async () => {
|
|
14
|
+
guard();
|
|
15
|
+
const transaction = createMutationFs(root, operation === 'pull');
|
|
16
|
+
const options = {fs: transaction.fs, dir: '/', gitdir: '/.git', cache: {}};
|
|
17
|
+
const ref = await git.currentBranch(options);
|
|
18
|
+
if (!ref) throw new GitPanelError('unsupported');
|
|
19
|
+
const config = (path: string) => git.getConfig({...options, path});
|
|
20
|
+
const remote = await config('remote.origin.url');
|
|
21
|
+
if (typeof remote !== 'string') throw new GitPanelError('session');
|
|
22
|
+
const trackingRemote = await config('branch.' + ref + '.remote');
|
|
23
|
+
const trackingRef = await config('branch.' + ref + '.merge');
|
|
24
|
+
if ((trackingRemote && trackingRemote !== 'origin') || (trackingRef && !trackingRef.startsWith('refs/heads/')))
|
|
25
|
+
throw new GitPanelError('unsupported');
|
|
26
|
+
const refspecs = await git.getConfigAll({...options, path: 'remote.origin.fetch'});
|
|
27
|
+
if (refspecs.some(spec => !/^\+?refs\/heads\/[^:\s]+:refs\/remotes\/origin\/[^:\s]+$/u.test(spec)))
|
|
28
|
+
throw new GitPanelError('unsupported');
|
|
29
|
+
if (operation === 'pull') {
|
|
30
|
+
if ((await git.statusMatrix({...options, refresh: false})).some(([, head, worktree, stage]) =>
|
|
31
|
+
head !== worktree || head !== stage)) throw new GitPanelError('pullUnsafe');
|
|
32
|
+
await git.walk({...options, trees: [git.STAGE()], map: async (_path, [entry]) => {
|
|
33
|
+
if (entry && await entry.type() !== 'tree' && await entry.mode() !== 0o100644)
|
|
34
|
+
throw new GitPanelError('unsupported');
|
|
35
|
+
}});
|
|
36
|
+
}
|
|
37
|
+
const write = operation === 'push';
|
|
38
|
+
const session = await getSession(remote, write);
|
|
39
|
+
if (!session) throw new GitPanelError('session');
|
|
40
|
+
guard();
|
|
41
|
+
const transport = createGitSmartHttp({remote, connection: session.connection,
|
|
42
|
+
getSession: () => getSession(remote, write), signal});
|
|
43
|
+
let receivedPushResponse = false;
|
|
44
|
+
let pushResponseFailed = false;
|
|
45
|
+
const http = {async request(request: Parameters<typeof transport.http.request>[0]) {
|
|
46
|
+
const response = await transport.http.request(request);
|
|
47
|
+
if (!write || request.method !== 'POST' || !response.body) return response;
|
|
48
|
+
receivedPushResponse = true;
|
|
49
|
+
const body = response.body;
|
|
50
|
+
// isomorphic-git stops at the report-status and drops later read errors, so record them here.
|
|
51
|
+
return {...response, body: (async function* () {
|
|
52
|
+
try {
|
|
53
|
+
yield* body;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
pushResponseFailed = true;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
})()};
|
|
59
|
+
}};
|
|
60
|
+
const args = {...options, ...transport, http, remote: 'origin', ref,
|
|
61
|
+
remoteRef: trackingRef || `refs/heads/${ref}`};
|
|
62
|
+
try {
|
|
63
|
+
if (operation === 'fetch') await git.fetch({...args, tags: false});
|
|
64
|
+
else if (operation === 'pull') await git.fastForward(args);
|
|
65
|
+
else {
|
|
66
|
+
await git.push({...args, force: false, signal});
|
|
67
|
+
if (pushResponseFailed) throw new GitPanelError('pushUnknown');
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof GitSmartHttpError) {
|
|
71
|
+
if (error.code === 'PUSH_OUTCOME_UNKNOWN') throw new GitPanelError('pushUnknown');
|
|
72
|
+
if (['SESSION_REQUIRED', 'SESSION_UNAVAILABLE', 'SESSION_EXPIRED', 'REAUTH_REQUIRED'].includes(error.code))
|
|
73
|
+
throw new GitPanelError('session');
|
|
74
|
+
}
|
|
75
|
+
const code = (error as {code?: string})?.code;
|
|
76
|
+
if (code === 'FastForwardError') throw new GitPanelError('diverged');
|
|
77
|
+
if (receivedPushResponse && !(error instanceof GitSmartHttpError) && code !== 'GitPushError')
|
|
78
|
+
throw new GitPanelError('pushUnknown');
|
|
79
|
+
if (error instanceof GitPanelError) throw error;
|
|
80
|
+
throw new GitPanelError('remoteFailed');
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await transaction.commit(guard);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (write) throw new GitPanelError('pushRefresh');
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
return globalThis.navigator?.locks ? navigator.locks.request('tw-plus-git-mutation', execute) : execute();
|
|
90
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import * as git from 'isomorphic-git';
|
|
2
|
+
import {createTwoFilesPatch} from 'diff';
|
|
3
|
+
import {createReadOnlyFs, errorCode, GitPanelError, pathParts, readOptional, sameBytes} from './filesystem.js';
|
|
4
|
+
import {runRemoteOperation} from './remote-operations.js';
|
|
5
|
+
import type {GetRemoteSession, RemoteOperation} from './remote-operations.js';
|
|
6
|
+
import {assetLimit, assetsDirectoryParts, folderSettingsLimit, isAssetName} from './project-asset-paths.js';
|
|
7
|
+
import type {ProjectAssetLoader, ProjectVersion} from './project-assets.js';
|
|
8
|
+
|
|
9
|
+
export {createReadOnlyFs, GitPanelError} from './filesystem.js';
|
|
10
|
+
export type ChangeGroup = 'staged' | 'unstaged' | 'untracked' | 'commit';
|
|
11
|
+
export interface FileChange {
|
|
12
|
+
path: string;
|
|
13
|
+
group: ChangeGroup;
|
|
14
|
+
before: string | null;
|
|
15
|
+
after: string | null;
|
|
16
|
+
worktree: boolean;
|
|
17
|
+
oldMode: number | null;
|
|
18
|
+
newMode: number | null;
|
|
19
|
+
/** For `commit` changes, the commit compared with its first parent. */
|
|
20
|
+
commit?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface Commit {
|
|
23
|
+
oid: string;
|
|
24
|
+
message: string;
|
|
25
|
+
author: string;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
}
|
|
28
|
+
interface Entry {oid: string; mode: number}
|
|
29
|
+
/** Where one version of a file is read from: a commit or tree, the index, or the working tree. */
|
|
30
|
+
type Source = {kind: 'tree'; ref: string} | {kind: 'index'} | {kind: 'worktree'};
|
|
31
|
+
type Metadata = Map<string, Uint8Array | null>;
|
|
32
|
+
export interface Snapshot {
|
|
33
|
+
branch: string | undefined;
|
|
34
|
+
head: string | null;
|
|
35
|
+
branches: string[];
|
|
36
|
+
commits: Commit[];
|
|
37
|
+
changes: FileChange[];
|
|
38
|
+
scope: string;
|
|
39
|
+
metadata: Metadata;
|
|
40
|
+
/** Size and time fingerprint taken before the read. Compare it with `probe(scope)` to see later changes. */
|
|
41
|
+
probe: string;
|
|
42
|
+
}
|
|
43
|
+
export interface FileDiff {
|
|
44
|
+
binary: boolean;
|
|
45
|
+
patch: string;
|
|
46
|
+
/**
|
|
47
|
+
* Both versions as text, for views that read the content instead of the patch. A missing version is empty.
|
|
48
|
+
* Absent for binary content and above the text limit, except for project.source.json up to 16 MiB.
|
|
49
|
+
*/
|
|
50
|
+
text?: {before: string; after: string};
|
|
51
|
+
beforeSize: number;
|
|
52
|
+
afterSize: number;
|
|
53
|
+
tooLarge: boolean;
|
|
54
|
+
}
|
|
55
|
+
const emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
56
|
+
const textLimit = 1024 * 1024;
|
|
57
|
+
const inScope = (path: string, scope: string) => !scope || path === scope || path.startsWith(scope + '/');
|
|
58
|
+
// Above this many work-tree files, probes only watch Git metadata. Edits then need a manual refresh.
|
|
59
|
+
const probeLimit = 5000;
|
|
60
|
+
const sameMetadata = (a: Metadata, b: Metadata) => a.size === b.size && [...a].every(([path, bytes]) => sameBytes(bytes, b.get(path) ?? null));
|
|
61
|
+
|
|
62
|
+
export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
63
|
+
// Linked worktrees and submodules may point outside the granted directory.
|
|
64
|
+
try {await root.getDirectoryHandle('.git');} catch (error) {
|
|
65
|
+
if (['NotFoundError', 'TypeMismatchError'].includes((error as DOMException).name)) throw new GitPanelError('invalid');
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
const fs = createReadOnlyFs(root);
|
|
69
|
+
let tail = Promise.resolve();
|
|
70
|
+
const serial = <T>(operation: () => Promise<T>) => {
|
|
71
|
+
const result = tail.then(operation);
|
|
72
|
+
tail = result.then(() => {}, () => {});
|
|
73
|
+
return result;
|
|
74
|
+
};
|
|
75
|
+
const options = {fs, dir: '/', gitdir: '/.git'};
|
|
76
|
+
let cache = {};
|
|
77
|
+
let pushNeedsFetch = false;
|
|
78
|
+
const metadata = async (): Promise<Metadata> => {
|
|
79
|
+
const head = await readOptional(fs, '/.git/HEAD');
|
|
80
|
+
if (!head) throw new GitPanelError('invalid');
|
|
81
|
+
const text = new TextDecoder().decode(head).trim();
|
|
82
|
+
const paths = ['/.git/HEAD', '/.git/index', '/.git/packed-refs', '/.git/shallow', '/.git/config'];
|
|
83
|
+
if (text.startsWith('ref: refs/heads/')) {
|
|
84
|
+
const ref = text.slice(5);
|
|
85
|
+
pathParts(ref);
|
|
86
|
+
paths.push('/.git/' + ref);
|
|
87
|
+
} else if (!/^[0-9a-f]{40}$/.test(text)) throw new GitPanelError('invalid');
|
|
88
|
+
const result: Metadata = new Map();
|
|
89
|
+
for (const path of paths) {
|
|
90
|
+
if (await readOptional(fs, path + '.lock')) throw new GitPanelError('changed');
|
|
91
|
+
result.set(path, await readOptional(fs, path));
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
};
|
|
95
|
+
await metadata();
|
|
96
|
+
|
|
97
|
+
const fileAt = async (parts: string[]) => {
|
|
98
|
+
let directory = root;
|
|
99
|
+
for (const name of parts.slice(0, -1)) directory = await directory.getDirectoryHandle(name);
|
|
100
|
+
return (await directory.getFileHandle(parts[parts.length - 1])).getFile();
|
|
101
|
+
};
|
|
102
|
+
const optionalFile = async (path: string) => {
|
|
103
|
+
try {return await fileAt(pathParts(path));} catch (error) {
|
|
104
|
+
if (['NotFoundError', 'TypeMismatchError'].includes((error as DOMException).name)) return null;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const stamp = (file: File | null) => file ? `${file.size}:${file.lastModified}` : '-';
|
|
109
|
+
/**
|
|
110
|
+
* Fingerprints Git metadata and the work tree in `scope` from sizes and modification times, without
|
|
111
|
+
* reading file contents other than HEAD. Returns 'locked' while Git holds a lock on that metadata.
|
|
112
|
+
*/
|
|
113
|
+
const probe = async (scope = ''): Promise<string> => {
|
|
114
|
+
const parts = pathParts(scope);
|
|
115
|
+
const headFile = await optionalFile('.git/HEAD');
|
|
116
|
+
if (!headFile) return 'locked';
|
|
117
|
+
const head = (await headFile.text()).trim();
|
|
118
|
+
const paths = ['.git/HEAD', '.git/index', '.git/packed-refs', '.git/shallow', '.git/config'];
|
|
119
|
+
if (head.startsWith('ref: refs/heads/')) {
|
|
120
|
+
try {pathParts(head.slice(5));} catch {return 'locked';}
|
|
121
|
+
paths.push('.git/' + head.slice(5));
|
|
122
|
+
}
|
|
123
|
+
const lines: string[] = [];
|
|
124
|
+
for (const path of paths) {
|
|
125
|
+
if (await optionalFile(path + '.lock')) return 'locked';
|
|
126
|
+
lines.push(`${path} ${stamp(await optionalFile(path))}`);
|
|
127
|
+
}
|
|
128
|
+
let start: FileSystemDirectoryHandle | null = root;
|
|
129
|
+
try {for (const name of parts) start = await start.getDirectoryHandle(name);} catch {start = null;}
|
|
130
|
+
const files: string[] = [];
|
|
131
|
+
const visit = async (directory: FileSystemDirectoryHandle, prefix: string): Promise<boolean> => {
|
|
132
|
+
const entries = (directory as FileSystemDirectoryHandle & {values(): AsyncIterable<FileSystemHandle>}).values();
|
|
133
|
+
for await (const entry of entries) {
|
|
134
|
+
const path = prefix + entry.name;
|
|
135
|
+
if (path === '.git') continue;
|
|
136
|
+
if (entry.kind === 'directory') {
|
|
137
|
+
if (!await visit(entry as FileSystemDirectoryHandle, path + '/')) return false;
|
|
138
|
+
} else {
|
|
139
|
+
files.push(`${path} ${stamp(await (entry as FileSystemFileHandle).getFile())}`);
|
|
140
|
+
if (files.length > probeLimit) return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
};
|
|
145
|
+
const prefix = parts.length ? parts.join('/') + '/' : '';
|
|
146
|
+
const complete = start ? await visit(start, prefix) : true;
|
|
147
|
+
if (complete) lines.push(...files.sort());
|
|
148
|
+
else lines.push('work tree not watched');
|
|
149
|
+
return lines.join('\n');
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const entries = async (ref: string | null, scope: string): Promise<Map<string, Entry>> => {
|
|
153
|
+
const tree = ref === null ? git.STAGE() : git.TREE({ref});
|
|
154
|
+
const result = await git.walk({
|
|
155
|
+
...options, cache, trees: [tree],
|
|
156
|
+
map: async (path, [entry]) => {
|
|
157
|
+
if (path !== '.' && !inScope(path, scope) && !scope.startsWith(path + '/')) return null;
|
|
158
|
+
if (!entry || await entry.type() === 'tree') return;
|
|
159
|
+
const mode = await entry.mode();
|
|
160
|
+
if (mode === 0o160000 || mode === 0o120000) throw new GitPanelError('unsupported');
|
|
161
|
+
return {path, oid: await entry.oid(), mode};
|
|
162
|
+
}
|
|
163
|
+
}) as {path: string; oid: string; mode: number}[];
|
|
164
|
+
return new Map(result.map(({path, oid, mode}) => [path, {oid, mode}]));
|
|
165
|
+
};
|
|
166
|
+
const assertSnapshot = async (snapshot: Snapshot) => {
|
|
167
|
+
if (!sameMetadata(snapshot.metadata, await metadata())) throw new GitPanelError('changed');
|
|
168
|
+
};
|
|
169
|
+
const read = async (scope = ''): Promise<Snapshot> => {
|
|
170
|
+
scope = pathParts(scope).join('/');
|
|
171
|
+
fs.clearReads();
|
|
172
|
+
cache = {};
|
|
173
|
+
// Taken first, so a change during the read differs from the stored value and triggers another read.
|
|
174
|
+
const fingerprint = await probe(scope);
|
|
175
|
+
const before = await metadata();
|
|
176
|
+
let head: string | null;
|
|
177
|
+
try {head = await git.resolveRef({...options, ref: 'HEAD'});} catch (error) {
|
|
178
|
+
if (errorCode(error) !== 'NotFoundError') throw error;
|
|
179
|
+
head = null;
|
|
180
|
+
}
|
|
181
|
+
const branch = await git.currentBranch(options);
|
|
182
|
+
const branches = await git.listBranches(options);
|
|
183
|
+
const commits = head ? (await git.log({...options, cache, ref: head, depth: 50})).map(({oid, commit}) => ({
|
|
184
|
+
oid, message: commit.message, author: commit.author.name, timestamp: commit.author.timestamp
|
|
185
|
+
})) : [];
|
|
186
|
+
const matrix = await git.statusMatrix({...options, cache, refresh: false, filepaths: [scope || '.']});
|
|
187
|
+
const staged = await entries(null, scope);
|
|
188
|
+
const committed = await entries(head || emptyTree, scope);
|
|
189
|
+
const changes: FileChange[] = [];
|
|
190
|
+
for (const [path, headState, workState, stageState] of matrix) {
|
|
191
|
+
const previous = committed.get(path);
|
|
192
|
+
const next = staged.get(path);
|
|
193
|
+
if (headState !== stageState || previous?.mode !== next?.mode) {
|
|
194
|
+
changes.push({path, group: 'staged', before: previous?.oid ?? null, after: next?.oid ?? null,
|
|
195
|
+
worktree: false, oldMode: previous?.mode ?? null, newMode: next?.mode ?? null});
|
|
196
|
+
}
|
|
197
|
+
if (workState !== stageState) {
|
|
198
|
+
const bytes = workState ? await fs.promises.readFile('/' + path) as Uint8Array : null;
|
|
199
|
+
const oid = bytes ? (await git.hashBlob({object: bytes})).oid : null;
|
|
200
|
+
changes.push({path, group: !next && bytes ? 'untracked' : 'unstaged',
|
|
201
|
+
before: next?.oid ?? null, after: oid, worktree: true,
|
|
202
|
+
oldMode: next?.mode ?? null, newMode: bytes ? (next?.mode ?? 0o100644) : null});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!sameMetadata(before, await metadata())) throw new GitPanelError('changed');
|
|
206
|
+
await fs.checkReads();
|
|
207
|
+
return {branch: branch || undefined, head, branches, commits, changes, scope, metadata: before, probe: fingerprint};
|
|
208
|
+
};
|
|
209
|
+
const historyChanges = async (snapshot: Snapshot, oid: string): Promise<FileChange[]> => {
|
|
210
|
+
fs.clearReads();
|
|
211
|
+
await assertSnapshot(snapshot);
|
|
212
|
+
const {commit} = await git.readCommit({...options, cache, oid});
|
|
213
|
+
const before = await entries(commit.parent[0] || emptyTree, snapshot.scope);
|
|
214
|
+
const after = await entries(oid, snapshot.scope);
|
|
215
|
+
const result: FileChange[] = [];
|
|
216
|
+
for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
217
|
+
const a = before.get(path), b = after.get(path);
|
|
218
|
+
if (a?.oid !== b?.oid || a?.mode !== b?.mode) result.push({
|
|
219
|
+
path, group: 'commit', before: a?.oid ?? null, after: b?.oid ?? null,
|
|
220
|
+
oldMode: a?.mode ?? null, newMode: b?.mode ?? null, worktree: false, commit: oid
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
await assertSnapshot(snapshot);
|
|
224
|
+
await fs.checkReads();
|
|
225
|
+
return result;
|
|
226
|
+
};
|
|
227
|
+
const readDiff = async (snapshot: Snapshot, change: FileChange): Promise<FileDiff> => {
|
|
228
|
+
fs.clearReads();
|
|
229
|
+
await assertSnapshot(snapshot);
|
|
230
|
+
pathParts(change.path);
|
|
231
|
+
const before = change.before ? (await git.readBlob({...options, cache, oid: change.before})).blob : new Uint8Array();
|
|
232
|
+
let after: Uint8Array = new Uint8Array();
|
|
233
|
+
if (change.after) {
|
|
234
|
+
if (change.worktree) {
|
|
235
|
+
const bytes = await readOptional(fs, '/' + change.path);
|
|
236
|
+
if (!bytes || (await git.hashBlob({object: bytes})).oid !== change.after) throw new GitPanelError('changed');
|
|
237
|
+
after = bytes;
|
|
238
|
+
} else after = (await git.readBlob({...options, cache, oid: change.after})).blob;
|
|
239
|
+
} else if (change.worktree && await readOptional(fs, '/' + change.path)) throw new GitPanelError('changed');
|
|
240
|
+
await assertSnapshot(snapshot);
|
|
241
|
+
await fs.checkReads();
|
|
242
|
+
const size = Math.max(before.byteLength, after.byteLength);
|
|
243
|
+
const tooLarge = size > textLimit;
|
|
244
|
+
const binary = before.includes(0) || after.includes(0);
|
|
245
|
+
const base = {beforeSize: before.byteLength, afterSize: after.byteLength, binary, tooLarge};
|
|
246
|
+
// Project sources are compared as blocks, which stays usable well past the text diff limit.
|
|
247
|
+
const sourceLimit = change.path.split('/').pop() === 'project.source.json' ? 16 * 1024 * 1024 : textLimit;
|
|
248
|
+
if (binary || size > sourceLimit) return {...base, patch: ''};
|
|
249
|
+
const decoder = new TextDecoder('utf-8', {fatal: true});
|
|
250
|
+
let oldText: string, newText: string;
|
|
251
|
+
try {oldText = decoder.decode(before); newText = decoder.decode(after);} catch {
|
|
252
|
+
return {...base, binary: true, patch: ''};
|
|
253
|
+
}
|
|
254
|
+
if (change.worktree && await git.getConfig({...options, path: 'core.autocrlf'}) === 'true') {
|
|
255
|
+
newText = newText.replace(/\r\n/g, '\n');
|
|
256
|
+
}
|
|
257
|
+
const text = {before: oldText, after: newText};
|
|
258
|
+
if (tooLarge) return {...base, patch: '', text};
|
|
259
|
+
// The path is rendered separately, so newline characters cannot alter patch headers.
|
|
260
|
+
const patch = createTwoFilesPatch(change.before ? 'a/file' : '/dev/null', change.after ? 'b/file' : '/dev/null',
|
|
261
|
+
oldText, newText, undefined, undefined, {context: 3, timeout: 1000});
|
|
262
|
+
return patch === undefined ? {...base, tooLarge: true, patch: '', text} :
|
|
263
|
+
{...base, patch: 'diff --git a/file b/file\n' + patch.replace(/^=+\n/, ''), text};
|
|
264
|
+
};
|
|
265
|
+
const sourceOf = async (snapshot: Snapshot, change: FileChange, version: ProjectVersion): Promise<Source | null> => {
|
|
266
|
+
if (change[version] === null) return null;
|
|
267
|
+
switch (change.group) {
|
|
268
|
+
case 'staged': return version === 'after' ? {kind: 'index'} : snapshot.head ? {kind: 'tree', ref: snapshot.head} : null;
|
|
269
|
+
case 'unstaged': return version === 'after' ? {kind: 'worktree'} : {kind: 'index'};
|
|
270
|
+
case 'untracked': return version === 'after' ? {kind: 'worktree'} : null;
|
|
271
|
+
case 'commit': {
|
|
272
|
+
if (!change.commit) return null;
|
|
273
|
+
if (version === 'after') return {kind: 'tree', ref: change.commit};
|
|
274
|
+
const parent = (await git.readCommit({...options, cache, oid: change.commit})).commit.parent[0];
|
|
275
|
+
return parent ? {kind: 'tree', ref: parent} : null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
/** Reads one regular file of one version, or `null` when it is missing or larger than the limit. */
|
|
280
|
+
const readVersion = async (source: Source, path: string, limit: number): Promise<Uint8Array | null> => {
|
|
281
|
+
if (source.kind === 'worktree') {
|
|
282
|
+
const stat = await fs.promises.stat('/' + path).catch(() => null);
|
|
283
|
+
if (!stat?.isFile() || stat.size > limit) return null;
|
|
284
|
+
const bytes = await readOptional(fs, '/' + path);
|
|
285
|
+
return bytes && bytes.byteLength <= limit ? bytes : null;
|
|
286
|
+
}
|
|
287
|
+
const entry = (await entries(source.kind === 'tree' ? source.ref : null, path)).get(path);
|
|
288
|
+
if (!entry || (entry.mode !== 0o100644 && entry.mode !== 0o100755)) return null;
|
|
289
|
+
const {blob} = await git.readBlob({...options, cache, oid: entry.oid});
|
|
290
|
+
return blob.byteLength <= limit ? blob : null;
|
|
291
|
+
};
|
|
292
|
+
const readAsset = async (snapshot: Snapshot, change: FileChange, version: ProjectVersion, md5ext: string, signal: AbortSignal) => {
|
|
293
|
+
if (signal.aborted) return null;
|
|
294
|
+
fs.clearReads();
|
|
295
|
+
await assertSnapshot(snapshot);
|
|
296
|
+
const source = await sourceOf(snapshot, change, version);
|
|
297
|
+
if (!source) return null;
|
|
298
|
+
const folder = pathParts(change.path).slice(0, -1);
|
|
299
|
+
const settings = await readVersion(source, [...folder, 'sb3-source.json'].join('/'), folderSettingsLimit);
|
|
300
|
+
const directory = assetsDirectoryParts(settings && new TextDecoder().decode(settings));
|
|
301
|
+
if (!directory || signal.aborted) return null;
|
|
302
|
+
const bytes = await readVersion(source, [...folder, ...directory, md5ext].join('/'), assetLimit);
|
|
303
|
+
await fs.checkReads();
|
|
304
|
+
return bytes && !signal.aborted ? new Blob([bytes as Uint8Array<ArrayBuffer>]) : null;
|
|
305
|
+
};
|
|
306
|
+
const directoryAt = async (path: string) => {
|
|
307
|
+
let directory = root;
|
|
308
|
+
for (const part of pathParts(path)) directory = await directory.getDirectoryHandle(part);
|
|
309
|
+
return directory;
|
|
310
|
+
};
|
|
311
|
+
const browse = async (path = '') => {
|
|
312
|
+
path = pathParts(path).join('/');
|
|
313
|
+
const directory = await directoryAt(path);
|
|
314
|
+
const children: string[] = [];
|
|
315
|
+
let isProject = false;
|
|
316
|
+
for await (const entry of (directory as FileSystemDirectoryHandle & {values(): AsyncIterable<FileSystemHandle>}).values()) {
|
|
317
|
+
if (entry.kind === 'directory' && entry.name !== '.git') children.push(entry.name);
|
|
318
|
+
if (entry.kind === 'file' && entry.name === 'project.source.json') isProject = true;
|
|
319
|
+
}
|
|
320
|
+
return {path, children: children.sort(), isProject};
|
|
321
|
+
};
|
|
322
|
+
const openProject = async (path: string) => {
|
|
323
|
+
const parts = pathParts(path);
|
|
324
|
+
let directory = root;
|
|
325
|
+
for (const part of parts) {
|
|
326
|
+
directory = await directory.getDirectoryHandle(part);
|
|
327
|
+
for await (const entry of (directory as FileSystemDirectoryHandle & {values(): AsyncIterable<FileSystemHandle>}).values()) {
|
|
328
|
+
if (entry.name === '.git') throw new GitPanelError('invalid');
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const parentParts = await root.resolve(directory);
|
|
332
|
+
if (parentParts === null || parentParts.join('/') !== parts.join('/')) throw new GitPanelError('invalid');
|
|
333
|
+
await directory.getFileHandle('project.source.json');
|
|
334
|
+
return directory;
|
|
335
|
+
};
|
|
336
|
+
return {root, openProject, browse, probe,
|
|
337
|
+
readOrigin: () => serial(() => git.getConfig({...options, path: 'remote.origin.url'})),
|
|
338
|
+
remoteOperation: (operation: RemoteOperation, getSession: GetRemoteSession, guard: () => void, signal?: AbortSignal) =>
|
|
339
|
+
serial(async () => {
|
|
340
|
+
if (operation === 'push' && pushNeedsFetch) throw new GitPanelError('pushUnknown');
|
|
341
|
+
try {
|
|
342
|
+
await runRemoteOperation(root, operation, getSession, guard, signal);
|
|
343
|
+
if (operation === 'fetch') pushNeedsFetch = false;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (operation === 'push' && error instanceof GitPanelError &&
|
|
346
|
+
(error.code === 'pushUnknown' || error.code === 'pushRefresh')) pushNeedsFetch = true;
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
}),
|
|
350
|
+
read: (scope?: string) => serial(() => read(scope)),
|
|
351
|
+
readDiff: (snapshot: Snapshot, change: FileChange) => serial(() => readDiff(snapshot, change)),
|
|
352
|
+
historyChanges: (snapshot: Snapshot, oid: string) => serial(() => historyChanges(snapshot, oid)),
|
|
353
|
+
/**
|
|
354
|
+
* Reads the assets of both versions of a selected project file, for thumbnails. Staged changes compare HEAD with
|
|
355
|
+
* the index, unstaged ones the index with the working tree, untracked ones nothing with the working tree, and
|
|
356
|
+
* commit changes the first parent with the commit. Each version's sb3-source.json names its assets directory.
|
|
357
|
+
* Reads never write, and resolve `null` for anything missing, invalid, larger than 10 MiB or changed since the snapshot.
|
|
358
|
+
*/
|
|
359
|
+
assetLoader: (snapshot: Snapshot, change: FileChange): ProjectAssetLoader => ({version, md5ext, signal}) =>
|
|
360
|
+
!isAssetName(md5ext) || signal.aborted ? Promise.resolve(null) :
|
|
361
|
+
serial(() => readAsset(snapshot, change, version, md5ext, signal)).catch(() => null)
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
export type Repository = Awaited<ReturnType<typeof openRepository>>;
|