@kubohiroya/turbowarp-plus-editor-git 0.8.0 → 0.10.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/dist/index.js +459 -74
- package/dist/messages.d.ts +34 -0
- package/dist/project-assets.d.ts +4 -0
- package/dist/project-change-list.d.ts +81 -0
- package/dist/project-change-model.d.ts +73 -0
- package/dist/project-source-diff.d.ts +2 -0
- package/dist/project-source-diff.js +2 -2
- package/dist/project-source-review.d.ts +6 -1
- package/dist/{repository-BQkADJmK.js → repository-CtvWYPQ8.js} +187 -8
- package/dist/repository.d.ts +41 -0
- package/dist/repository.js +1 -1
- package/dist/{scratch-block-diff-renderer-xS8OYsRl.js → scratch-block-diff-renderer-DZR8ep-T.js} +264 -12
- package/dist/style.css +30 -2
- package/package.json +1 -1
- package/src/connection-settings.tsx +14 -8
- package/src/file-diff-view.tsx +4 -2
- package/src/index.tsx +68 -23
- package/src/messages.ts +34 -26
- package/src/project-assets.ts +19 -7
- package/src/project-change-list.tsx +210 -0
- package/src/project-change-model.ts +212 -0
- package/src/project-source-diff.tsx +3 -0
- package/src/project-source-review.tsx +12 -5
- package/src/repository.ts +129 -7
- package/src/style.css +30 -2
package/src/repository.ts
CHANGED
|
@@ -28,6 +28,12 @@ export interface Commit {
|
|
|
28
28
|
interface Entry {oid: string; mode: number}
|
|
29
29
|
/** Where one version of a file is read from: a commit or tree, the index, or the working tree. */
|
|
30
30
|
type Source = {kind: 'tree'; ref: string} | {kind: 'index'} | {kind: 'worktree'};
|
|
31
|
+
/**
|
|
32
|
+
* Two versions of a project folder as a person sees them: the last commit and the working tree, so changes split
|
|
33
|
+
* across the index and the working tree read as one; or one commit and its first parent.
|
|
34
|
+
*/
|
|
35
|
+
export type ProjectComparison = {kind: 'worktree'} | {kind: 'commit'; commit: string};
|
|
36
|
+
const projectSourceLimit = 16 * 1024 * 1024;
|
|
31
37
|
type Metadata = Map<string, Uint8Array | null>;
|
|
32
38
|
export interface Snapshot {
|
|
33
39
|
branch: string | undefined;
|
|
@@ -39,6 +45,21 @@ export interface Snapshot {
|
|
|
39
45
|
metadata: Metadata;
|
|
40
46
|
/** Size and time fingerprint taken before the read. Compare it with `probe(scope)` to see later changes. */
|
|
41
47
|
probe: string;
|
|
48
|
+
/**
|
|
49
|
+
* How the current branch stands against `refs/remotes/origin/<branch>` as of the last fetch, from local refs only.
|
|
50
|
+
* `unknown` when there is no current branch or no tracking ref yet.
|
|
51
|
+
*/
|
|
52
|
+
upstream: UpstreamRelation;
|
|
53
|
+
}
|
|
54
|
+
export interface UpstreamRelation {
|
|
55
|
+
state: 'same' | 'ahead' | 'behind' | 'diverged' | 'unknown';
|
|
56
|
+
/**
|
|
57
|
+
* Commits on the local branch that the tracking branch lacks, and the reverse. Zero when unknown. Counting reads at
|
|
58
|
+
* most `upstreamWalkLimit` (1000) commits; past that, a side that has commits of its own reports exactly that limit,
|
|
59
|
+
* meaning "this many or more". The state itself comes from the merge base and stays exact.
|
|
60
|
+
*/
|
|
61
|
+
ahead: number;
|
|
62
|
+
behind: number;
|
|
42
63
|
}
|
|
43
64
|
export interface FileDiff {
|
|
44
65
|
binary: boolean;
|
|
@@ -57,6 +78,9 @@ const textLimit = 1024 * 1024;
|
|
|
57
78
|
const inScope = (path: string, scope: string) => !scope || path === scope || path.startsWith(scope + '/');
|
|
58
79
|
// Above this many work-tree files, probes only watch Git metadata. Edits then need a manual refresh.
|
|
59
80
|
const probeLimit = 5000;
|
|
81
|
+
/** Most commits read to count how far a branch is ahead of or behind its tracking branch. */
|
|
82
|
+
export const upstreamWalkLimit = 1000;
|
|
83
|
+
const trackingRef = (branchRef: string) => 'refs/remotes/origin/' + branchRef.slice('refs/heads/'.length);
|
|
60
84
|
const sameMetadata = (a: Metadata, b: Metadata) => a.size === b.size && [...a].every(([path, bytes]) => sameBytes(bytes, b.get(path) ?? null));
|
|
61
85
|
|
|
62
86
|
export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
@@ -83,7 +107,7 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
83
107
|
if (text.startsWith('ref: refs/heads/')) {
|
|
84
108
|
const ref = text.slice(5);
|
|
85
109
|
pathParts(ref);
|
|
86
|
-
paths.push('/.git/' + ref);
|
|
110
|
+
paths.push('/.git/' + ref, '/.git/' + trackingRef(ref));
|
|
87
111
|
} else if (!/^[0-9a-f]{40}$/.test(text)) throw new GitPanelError('invalid');
|
|
88
112
|
const result: Metadata = new Map();
|
|
89
113
|
for (const path of paths) {
|
|
@@ -118,7 +142,7 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
118
142
|
const paths = ['.git/HEAD', '.git/index', '.git/packed-refs', '.git/shallow', '.git/config'];
|
|
119
143
|
if (head.startsWith('ref: refs/heads/')) {
|
|
120
144
|
try {pathParts(head.slice(5));} catch {return 'locked';}
|
|
121
|
-
paths.push('.git/' + head.slice(5));
|
|
145
|
+
paths.push('.git/' + head.slice(5), '.git/' + trackingRef(head.slice(5)));
|
|
122
146
|
}
|
|
123
147
|
const lines: string[] = [];
|
|
124
148
|
for (const path of paths) {
|
|
@@ -163,6 +187,60 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
163
187
|
}) as {path: string; oid: string; mode: number}[];
|
|
164
188
|
return new Map(result.map(({path, oid, mode}) => [path, {oid, mode}]));
|
|
165
189
|
};
|
|
190
|
+
/**
|
|
191
|
+
* Compares `refs/heads/<branch>` with `refs/remotes/origin/<branch>` from local refs only. Counts by painting
|
|
192
|
+
* commits from both tips in committer-time order until every queued commit is reachable from both, like
|
|
193
|
+
* `git rev-list --left-right --count`, reading at most `upstreamWalkLimit` commits.
|
|
194
|
+
*/
|
|
195
|
+
const upstreamRelation = async (branch: string | undefined, head: string | null): Promise<UpstreamRelation> => {
|
|
196
|
+
const unknown: UpstreamRelation = {state: 'unknown', ahead: 0, behind: 0};
|
|
197
|
+
if (!branch || !head) return unknown;
|
|
198
|
+
pathParts(trackingRef('refs/heads/' + branch));
|
|
199
|
+
try {
|
|
200
|
+
const remote = await git.resolveRef({...options, ref: trackingRef('refs/heads/' + branch)});
|
|
201
|
+
if (remote === head) return {state: 'same', ahead: 0, behind: 0};
|
|
202
|
+
const bases = await git.findMergeBase({...options, cache, oids: [head, remote]}) as string[];
|
|
203
|
+
const hasAhead = !bases.includes(head), hasBehind = !bases.includes(remote);
|
|
204
|
+
const local = 1, tracking = 2, both = 3;
|
|
205
|
+
const flags = new Map<string, number>([[head, local], [remote, tracking]]);
|
|
206
|
+
const time = async (oid: string) => (await git.readCommit({...options, cache, oid})).commit;
|
|
207
|
+
const queue: {oid: string; time: number; parents: string[]}[] = [];
|
|
208
|
+
const enqueue = async (oid: string) => {
|
|
209
|
+
const commit = await time(oid);
|
|
210
|
+
queue.push({oid, time: commit.committer.timestamp, parents: commit.parent});
|
|
211
|
+
};
|
|
212
|
+
await enqueue(head); await enqueue(remote);
|
|
213
|
+
let read = 2;
|
|
214
|
+
let complete = true;
|
|
215
|
+
while (queue.some(item => flags.get(item.oid) !== both)) {
|
|
216
|
+
if (read >= upstreamWalkLimit) {complete = false; break;}
|
|
217
|
+
queue.sort((a, b) => b.time - a.time);
|
|
218
|
+
const item = queue.shift()!;
|
|
219
|
+
const flag = flags.get(item.oid)!;
|
|
220
|
+
for (const parent of item.parents) {
|
|
221
|
+
const previous = flags.get(parent) ?? 0;
|
|
222
|
+
if ((previous | flag) === previous) continue;
|
|
223
|
+
flags.set(parent, previous | flag);
|
|
224
|
+
if (!queue.some(entry => entry.oid === parent)) {await enqueue(parent); read++;}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
let ahead = 0, behind = 0;
|
|
228
|
+
for (const flag of flags.values()) {
|
|
229
|
+
if (flag === local) ahead++;
|
|
230
|
+
else if (flag === tracking) behind++;
|
|
231
|
+
}
|
|
232
|
+
if (!complete) {
|
|
233
|
+
ahead = hasAhead ? upstreamWalkLimit : 0;
|
|
234
|
+
behind = hasBehind ? upstreamWalkLimit : 0;
|
|
235
|
+
}
|
|
236
|
+
return {state: hasAhead && hasBehind ? 'diverged' : hasAhead ? 'ahead' : hasBehind ? 'behind' : 'same',
|
|
237
|
+
ahead: hasAhead ? Math.max(ahead, 1) : 0, behind: hasBehind ? Math.max(behind, 1) : 0};
|
|
238
|
+
} catch (error) {
|
|
239
|
+
// No tracking ref yet, or history missing from a shallow clone.
|
|
240
|
+
if (errorCode(error) === 'NotFoundError') return unknown;
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
166
244
|
const assertSnapshot = async (snapshot: Snapshot) => {
|
|
167
245
|
if (!sameMetadata(snapshot.metadata, await metadata())) throw new GitPanelError('changed');
|
|
168
246
|
};
|
|
@@ -183,6 +261,7 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
183
261
|
const commits = head ? (await git.log({...options, cache, ref: head, depth: 50})).map(({oid, commit}) => ({
|
|
184
262
|
oid, message: commit.message, author: commit.author.name, timestamp: commit.author.timestamp
|
|
185
263
|
})) : [];
|
|
264
|
+
const upstream = await upstreamRelation(branch || undefined, head);
|
|
186
265
|
const matrix = await git.statusMatrix({...options, cache, refresh: false, filepaths: [scope || '.']});
|
|
187
266
|
const staged = await entries(null, scope);
|
|
188
267
|
const committed = await entries(head || emptyTree, scope);
|
|
@@ -204,7 +283,7 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
204
283
|
}
|
|
205
284
|
if (!sameMetadata(before, await metadata())) throw new GitPanelError('changed');
|
|
206
285
|
await fs.checkReads();
|
|
207
|
-
return {branch: branch || undefined, head, branches, commits, changes, scope, metadata: before, probe: fingerprint};
|
|
286
|
+
return {branch: branch || undefined, head, branches, commits, changes, scope, metadata: before, probe: fingerprint, upstream};
|
|
208
287
|
};
|
|
209
288
|
const historyChanges = async (snapshot: Snapshot, oid: string): Promise<FileChange[]> => {
|
|
210
289
|
fs.clearReads();
|
|
@@ -289,13 +368,21 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
289
368
|
const {blob} = await git.readBlob({...options, cache, oid: entry.oid});
|
|
290
369
|
return blob.byteLength <= limit ? blob : null;
|
|
291
370
|
};
|
|
292
|
-
const
|
|
371
|
+
const comparisonSource = async (snapshot: Snapshot, comparison: ProjectComparison, version: ProjectVersion): Promise<Source | null> => {
|
|
372
|
+
if (comparison.kind === 'worktree') return version === 'after' ? {kind: 'worktree'} : snapshot.head ? {kind: 'tree', ref: snapshot.head} : null;
|
|
373
|
+
if (version === 'after') return {kind: 'tree', ref: comparison.commit};
|
|
374
|
+
const parent = (await git.readCommit({...options, cache, oid: comparison.commit})).commit.parent[0];
|
|
375
|
+
return parent ? {kind: 'tree', ref: parent} : null;
|
|
376
|
+
};
|
|
377
|
+
const readAsset = (snapshot: Snapshot, change: FileChange, version: ProjectVersion, md5ext: string, signal: AbortSignal) =>
|
|
378
|
+
readFolderAsset(snapshot, pathParts(change.path).slice(0, -1), () => sourceOf(snapshot, change, version), md5ext, signal);
|
|
379
|
+
const readFolderAsset = async (snapshot: Snapshot, folder: string[], resolve: () => Promise<Source | null>, md5ext: string,
|
|
380
|
+
signal: AbortSignal) => {
|
|
293
381
|
if (signal.aborted) return null;
|
|
294
382
|
fs.clearReads();
|
|
295
383
|
await assertSnapshot(snapshot);
|
|
296
|
-
const source = await
|
|
384
|
+
const source = await resolve();
|
|
297
385
|
if (!source) return null;
|
|
298
|
-
const folder = pathParts(change.path).slice(0, -1);
|
|
299
386
|
const settings = await readVersion(source, [...folder, 'sb3-source.json'].join('/'), folderSettingsLimit);
|
|
300
387
|
const directory = assetsDirectoryParts(settings && new TextDecoder().decode(settings));
|
|
301
388
|
if (!directory || signal.aborted) return null;
|
|
@@ -303,6 +390,30 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
303
390
|
await fs.checkReads();
|
|
304
391
|
return bytes && !signal.aborted ? new Blob([bytes as Uint8Array<ArrayBuffer>]) : null;
|
|
305
392
|
};
|
|
393
|
+
/** Reads the project file and the assets directory of both versions of a project folder. */
|
|
394
|
+
const readProject = async (snapshot: Snapshot, folder: string, comparison: ProjectComparison) => {
|
|
395
|
+
fs.clearReads();
|
|
396
|
+
await assertSnapshot(snapshot);
|
|
397
|
+
const parts = pathParts(folder);
|
|
398
|
+
const read = async (version: ProjectVersion) => {
|
|
399
|
+
const source = await comparisonSource(snapshot, comparison, version);
|
|
400
|
+
if (!source) return {source: null, assetsDirectory: 'assets'};
|
|
401
|
+
const bytes = await readVersion(source, [...parts, 'project.source.json'].join('/'), projectSourceLimit);
|
|
402
|
+
const settings = await readVersion(source, [...parts, 'sb3-source.json'].join('/'), folderSettingsLimit);
|
|
403
|
+
const directory = assetsDirectoryParts(settings && new TextDecoder().decode(settings));
|
|
404
|
+
let text: string | null = null;
|
|
405
|
+
if (bytes) {
|
|
406
|
+
try {text = new TextDecoder('utf-8', {fatal: true}).decode(bytes);} catch {
|
|
407
|
+
throw new GitPanelError('unsupported');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return {source: text, assetsDirectory: directory ? directory.join('/') : 'assets'};
|
|
411
|
+
};
|
|
412
|
+
const before = await read('before'), after = await read('after');
|
|
413
|
+
await assertSnapshot(snapshot);
|
|
414
|
+
await fs.checkReads();
|
|
415
|
+
return {before: before.source, after: after.source, assetsDirectory: {before: before.assetsDirectory, after: after.assetsDirectory}};
|
|
416
|
+
};
|
|
306
417
|
const directoryAt = async (path: string) => {
|
|
307
418
|
let directory = root;
|
|
308
419
|
for (const part of pathParts(path)) directory = await directory.getDirectoryHandle(part);
|
|
@@ -358,7 +469,18 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
358
469
|
*/
|
|
359
470
|
assetLoader: (snapshot: Snapshot, change: FileChange): ProjectAssetLoader => ({version, md5ext, signal}) =>
|
|
360
471
|
!isAssetName(md5ext) || signal.aborted ? Promise.resolve(null) :
|
|
361
|
-
serial(() => readAsset(snapshot, change, version, md5ext, signal)).catch(() => null)
|
|
472
|
+
serial(() => readAsset(snapshot, change, version, md5ext, signal)).catch(() => null),
|
|
473
|
+
/**
|
|
474
|
+
* Reads the project file of a folder in both versions of a comparison, with each version's assets directory.
|
|
475
|
+
* `null` stands for a version without the file. Throws `changed` when the repository changed since the snapshot.
|
|
476
|
+
*/
|
|
477
|
+
readProject: (snapshot: Snapshot, folder: string, comparison: ProjectComparison) =>
|
|
478
|
+
serial(() => readProject(snapshot, folder, comparison)),
|
|
479
|
+
/** Reads the assets of a folder in both versions of a comparison, like `assetLoader`. */
|
|
480
|
+
projectAssetLoader: (snapshot: Snapshot, folder: string, comparison: ProjectComparison): ProjectAssetLoader =>
|
|
481
|
+
({version, md5ext, signal}) => !isAssetName(md5ext) || signal.aborted ? Promise.resolve(null) :
|
|
482
|
+
serial(() => readFolderAsset(snapshot, pathParts(folder), () => comparisonSource(snapshot, comparison, version),
|
|
483
|
+
md5ext, signal)).catch(() => null)
|
|
362
484
|
};
|
|
363
485
|
}
|
|
364
486
|
export type Repository = Awaited<ReturnType<typeof openRepository>>;
|
package/src/style.css
CHANGED
|
@@ -8,14 +8,24 @@
|
|
|
8
8
|
.twgit-panel button:focus-visible, .twgit-panel summary:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
|
|
9
9
|
.twgit-panel button[aria-pressed="true"] { color: HighlightText; background: Highlight; }
|
|
10
10
|
.twgit-context { display: flex; gap: 8px 20px; flex-wrap: wrap; padding: 8px 16px; }
|
|
11
|
-
.twgit-
|
|
11
|
+
.twgit-context { align-items: center; }
|
|
12
|
+
.twgit-connection, .twgit-git-details { position: relative; }
|
|
13
|
+
.twgit-connection > summary, .twgit-git-details > summary { cursor: pointer; }
|
|
14
|
+
/* Settings and Git details open over the list, so the list and the diff stay where they are. */
|
|
15
|
+
.twgit-connection-body { position: absolute; z-index: 5; top: calc(100% + 4px); left: 0; width: min(440px, 80vw); max-height: 60vh;
|
|
16
|
+
overflow: auto; padding: 10px 12px; background: Canvas; color: CanvasText; border: 1px solid GrayText; border-radius: 6px;
|
|
17
|
+
box-shadow: 0 8px 24px #0004; }
|
|
18
|
+
.twgit-connection-body > p { margin: 4px 0; overflow-wrap: anywhere; }
|
|
19
|
+
.twgit-sync { display: inline-flex; flex-wrap: wrap; gap: 4px; }
|
|
20
|
+
.twgit-change-kind { display: inline-block; min-width: 2.6em; font-size: 11px; opacity: .8; }
|
|
21
|
+
.twgit-file-details { font-size: 12px; margin-bottom: 6px; }
|
|
22
|
+
.twgit-file-details > p { margin: 2px 0; }
|
|
12
23
|
.twgit-content { display: grid; grid-template-columns: minmax(230px, 30%) minmax(0, 1fr); flex: 1; min-height: 0; }
|
|
13
24
|
.twgit-panel aside { padding: 0 14px 14px; overflow: auto; border-right: 1px solid GrayText; }
|
|
14
25
|
.twgit-panel aside ul { list-style: none; padding: 0; margin: 4px 0; }
|
|
15
26
|
.twgit-panel aside li { overflow-wrap: anywhere; }
|
|
16
27
|
.twgit-panel summary { cursor: pointer; }
|
|
17
28
|
.twgit-panel .twgit-file { width: 100%; border: 0; margin: 1px 0; }
|
|
18
|
-
.twgit-file span { display: inline-block; width: 1.4em; font-family: monospace; }
|
|
19
29
|
.twgit-diff { padding: 0 16px 16px; min-width: 0; overflow: auto; }
|
|
20
30
|
.twgit-diff .diff { font-size: 12px; }
|
|
21
31
|
.twgit-history { list-style: none; margin: 0; padding: 0; }
|
|
@@ -27,3 +37,21 @@
|
|
|
27
37
|
.twgit-view-switch { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 10px; }
|
|
28
38
|
.twgit-review-unreadable { margin: 6px 0; padding: 8px; background: color-mix(in srgb, #d9822b 16%, Canvas); }
|
|
29
39
|
.twgit-review-comparison .twgit-review-overview dl { margin: 4px 0 8px; }
|
|
40
|
+
.twgit-element-list > section { margin: 6px 0 10px; }
|
|
41
|
+
.twgit-element-target { display: flex; align-items: center; gap: 6px; margin: 10px 0 4px; font-size: 14px; }
|
|
42
|
+
.twgit-element-target img, .twgit-element img, .twgit-element-thumbnail { width: 28px; height: 28px; object-fit: contain; flex: none; }
|
|
43
|
+
.twgit-element-thumbnail { display: inline-block; border-radius: 4px; background: color-mix(in srgb, CanvasText 8%, Canvas); }
|
|
44
|
+
.twgit-panel .twgit-element { display: flex; align-items: center; gap: 6px; padding-inline-start: 10px; }
|
|
45
|
+
.twgit-element-name { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
|
46
|
+
.twgit-file-list { margin-top: 10px; }
|
|
47
|
+
.twgit-element-pictures { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
|
48
|
+
.twgit-element-picture { margin: 0; padding: 10px; border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas); border-radius: 6px; }
|
|
49
|
+
.twgit-element-picture img { display: block; max-width: 100%; max-height: 260px; margin: 8px auto 0; object-fit: contain;
|
|
50
|
+
background: repeating-conic-gradient(color-mix(in srgb, CanvasText 6%, Canvas) 0 25%, Canvas 0 50%) 0 0 / 16px 16px; }
|
|
51
|
+
.twgit-element-values { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
52
|
+
.twgit-element-values th, .twgit-element-values td { text-align: start; vertical-align: top; padding: 6px 8px;
|
|
53
|
+
border-bottom: 1px solid color-mix(in srgb, CanvasText 15%, Canvas); overflow-wrap: anywhere; }
|
|
54
|
+
.twgit-element-values td { font-family: ui-monospace, monospace; }
|
|
55
|
+
.twgit-element-files { margin-top: 12px; }
|
|
56
|
+
.twgit-panel .twgit-element span.twgit-element-name { width: auto; font-family: inherit; }
|
|
57
|
+
.twgit-git-details > .twgit-connection-body { left: auto; right: 0; }
|