@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/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 readAsset = async (snapshot: Snapshot, change: FileChange, version: ProjectVersion, md5ext: string, signal: AbortSignal) => {
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 sourceOf(snapshot, change, version);
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-connection { margin: 0 16px 8px; max-height: 45%; overflow: auto; }
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; }