@kubohiroya/turbowarp-plus-editor-git 0.9.0 → 0.11.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.
@@ -4,6 +4,7 @@ import type {GitHubPullRequests} from './github-pull-requests.js';
4
4
  import {ProjectSourceReview} from './project-source-review.js';
5
5
  import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
6
6
  import type {Repository} from './repository.js';
7
+ import type {ProjectBlockLocator} from './block-diff-location.js';
7
8
 
8
9
  const en = {
9
10
  title: 'Pull requests', number: 'Pull request number', openNumber: 'Open pull request',
@@ -37,8 +38,9 @@ type Result<T> = {data: T; error?: never} | {data?: never; error: string};
37
38
  type Listing = Awaited<ReturnType<GitHubPullRequests['list']>>;
38
39
  type Pull = Awaited<ReturnType<GitHubPullRequests['load']>>;
39
40
 
40
- function PullRequests({name, labels, locale, renderBlock}: {
41
+ function PullRequests({name, labels, locale, renderBlock, locateFor}: {
41
42
  name: string; labels: typeof en; locale: string; renderBlock: ProjectBlockRenderer;
43
+ locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
42
44
  }) {
43
45
  const client = useMemo(() => createGitHubPullRequests(name), [name]);
44
46
  const [page, setPage] = useState(1);
@@ -108,14 +110,17 @@ function PullRequests({name, labels, locale, renderBlock}: {
108
110
  <code title={pr.head.sha}>{pr.head.ref} → {pr.base.ref}</code>
109
111
  <a href={pr.url} target="_blank" rel="noreferrer">{labels.onGitHub}</a>
110
112
  </p>
111
- <ProjectSourceReview title={pr.title} description={pr.description} files={pr.files} renderBlock={renderBlock} locale={locale}/>
113
+ <ProjectSourceReview title={pr.title} description={pr.description} files={locateFor ? pr.files.map(file => 'patch' in file ? {...file, locateBlock: locateFor(file.path, pr.head.sha)} : file) : pr.files}
114
+ renderBlock={renderBlock} locale={locale}/>
112
115
  </>}
113
116
  </main>
114
117
  </div>;
115
118
  }
116
119
 
117
- export function PullRequestReviewPanel({repository, locale, renderBlock}: {
120
+ export function PullRequestReviewPanel({repository, locale, renderBlock, locateFor}: {
118
121
  repository: Repository; locale: string; renderBlock: ProjectBlockRenderer;
122
+ /** Returns a locator for a project file whose PR head matches the open project, or `undefined`. */
123
+ locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
119
124
  }) {
120
125
  const labels = locale.startsWith('ja') ? ja : en;
121
126
  const [origin, setOrigin] = useState<{repository: Repository; name: string | null; failed?: boolean}>();
@@ -130,5 +135,5 @@ export function PullRequestReviewPanel({repository, locale, renderBlock}: {
130
135
  }, [repository]);
131
136
  if (origin?.repository !== repository) return <p role="status" className="twgit-context">{labels.loading}</p>;
132
137
  if (!origin.name) return <p className="twgit-context" role="status">{origin.failed ? labels.failed : labels.noOrigin}</p>;
133
- return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock}/>;
138
+ return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock} locateFor={locateFor}/>;
134
139
  }
package/src/repository.ts CHANGED
@@ -45,6 +45,21 @@ export interface Snapshot {
45
45
  metadata: Metadata;
46
46
  /** Size and time fingerprint taken before the read. Compare it with `probe(scope)` to see later changes. */
47
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;
48
63
  }
49
64
  export interface FileDiff {
50
65
  binary: boolean;
@@ -63,6 +78,9 @@ const textLimit = 1024 * 1024;
63
78
  const inScope = (path: string, scope: string) => !scope || path === scope || path.startsWith(scope + '/');
64
79
  // Above this many work-tree files, probes only watch Git metadata. Edits then need a manual refresh.
65
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);
66
84
  const sameMetadata = (a: Metadata, b: Metadata) => a.size === b.size && [...a].every(([path, bytes]) => sameBytes(bytes, b.get(path) ?? null));
67
85
 
68
86
  export async function openRepository(root: FileSystemDirectoryHandle) {
@@ -89,7 +107,7 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
89
107
  if (text.startsWith('ref: refs/heads/')) {
90
108
  const ref = text.slice(5);
91
109
  pathParts(ref);
92
- paths.push('/.git/' + ref);
110
+ paths.push('/.git/' + ref, '/.git/' + trackingRef(ref));
93
111
  } else if (!/^[0-9a-f]{40}$/.test(text)) throw new GitPanelError('invalid');
94
112
  const result: Metadata = new Map();
95
113
  for (const path of paths) {
@@ -124,7 +142,7 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
124
142
  const paths = ['.git/HEAD', '.git/index', '.git/packed-refs', '.git/shallow', '.git/config'];
125
143
  if (head.startsWith('ref: refs/heads/')) {
126
144
  try {pathParts(head.slice(5));} catch {return 'locked';}
127
- paths.push('.git/' + head.slice(5));
145
+ paths.push('.git/' + head.slice(5), '.git/' + trackingRef(head.slice(5)));
128
146
  }
129
147
  const lines: string[] = [];
130
148
  for (const path of paths) {
@@ -169,6 +187,60 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
169
187
  }) as {path: string; oid: string; mode: number}[];
170
188
  return new Map(result.map(({path, oid, mode}) => [path, {oid, mode}]));
171
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
+ };
172
244
  const assertSnapshot = async (snapshot: Snapshot) => {
173
245
  if (!sameMetadata(snapshot.metadata, await metadata())) throw new GitPanelError('changed');
174
246
  };
@@ -189,6 +261,7 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
189
261
  const commits = head ? (await git.log({...options, cache, ref: head, depth: 50})).map(({oid, commit}) => ({
190
262
  oid, message: commit.message, author: commit.author.name, timestamp: commit.author.timestamp
191
263
  })) : [];
264
+ const upstream = await upstreamRelation(branch || undefined, head);
192
265
  const matrix = await git.statusMatrix({...options, cache, refresh: false, filepaths: [scope || '.']});
193
266
  const staged = await entries(null, scope);
194
267
  const committed = await entries(head || emptyTree, scope);
@@ -210,7 +283,7 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
210
283
  }
211
284
  if (!sameMetadata(before, await metadata())) throw new GitPanelError('changed');
212
285
  await fs.checkReads();
213
- 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};
214
287
  };
215
288
  const historyChanges = async (snapshot: Snapshot, oid: string): Promise<FileChange[]> => {
216
289
  fs.clearReads();
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; }
@@ -44,3 +54,6 @@
44
54
  .twgit-element-values td { font-family: ui-monospace, monospace; }
45
55
  .twgit-element-files { margin-top: 12px; }
46
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; }
58
+ /* In a layout tab the panel fills the tab and leaves the Editor beside it. */
59
+ .twgit-panel.twgit-panel-tab { width: 100%; height: 100%; border: 0; border-radius: 0; box-shadow: none; }