@kubohiroya/turbowarp-plus-editor-git 0.17.0 → 0.18.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/index.tsx CHANGED
@@ -5,7 +5,9 @@ import {FileDiffView} from './file-diff-view.js';
5
5
  import {findElement, ProjectChangeListView, ProjectElementView, projectChangeLabels, useProjectChanges} from './project-change-list.js';
6
6
  import type {ChangedFile} from './project-change-model.js';
7
7
  import {openRepository} from './repository.js';
8
- import type {Repository, Snapshot, FileChange, FileDiff, ChangeGroup, HistoryCommit, HistoryCursor} from './repository.js';
8
+ import type {BranchRefs, Repository, Snapshot, FileChange, FileDiff, ChangeGroup, HistoryCommit, HistoryCursor} from './repository.js';
9
+ import {BranchButton} from './branch-button.js';
10
+ import type {BranchButtonState} from './branch-button.js';
9
11
  import {HistoryGraph} from './history-graph-view.js';
10
12
  import type {HistoryActionResult} from './history-graph-view.js';
11
13
  import {labelsFor} from './messages.js';
@@ -34,7 +36,9 @@ export {openProjectComparison} from './project-comparison.js';
34
36
  export {openProjectSourceTabs} from './project-source-tabs.js';
35
37
  export type {ProjectSourceTabsFile, ProjectSourceTabsOptions} from './project-source-tabs.js';
36
38
  export type {ProjectComparisonAction, ProjectComparisonOptions, ProjectComparisonSection} from './project-comparison.js';
37
- export type {PullRequestBase, PullRequestFetch, PullRequestFiles, Repository, Snapshot, FileChange, FileDiff, HistoryCommit, HistoryCursor, HistoryPage, HistoryRef} from './repository.js';
39
+ export type {BranchRefs, PullRequestBase, PullRequestFetch, PullRequestFiles, Repository, Snapshot, FileChange, FileDiff, HistoryCommit, HistoryCursor, HistoryPage, HistoryRef} from './repository.js';
40
+ export {BranchButton} from './branch-button.js';
41
+ export type {BranchButtonActions, BranchButtonProps, BranchButtonState} from './branch-button.js';
38
42
  export {HistoryGraph, laneColors} from './history-graph-view.js';
39
43
  export type {HistoryActionResult, HistoryGraphProps} from './history-graph-view.js';
40
44
  export {layoutHistory} from './history-graph.js';
@@ -77,7 +81,46 @@ export interface GitPanelRequest {
77
81
  inactive?: boolean;
78
82
  mount(element: HTMLElement, close: () => void): {requestClose(): void; dispose(): void};
79
83
  }
80
- interface Controller {open(locale: string): void; refresh(automatic?: boolean): Promise<void>}
84
+ type BranchOperation = Extract<LocalOperation, {kind: 'createBranch' | 'switchBranch'}>;
85
+ /** The panel's side of its entries: set by the panel as it renders, used by the branch buttons and `mountGitPanel`'s result. */
86
+ interface Controller {
87
+ open(locale: string): void;
88
+ refresh(automatic?: boolean): Promise<void>;
89
+ /** Asks for a repository, then opens the panel on it. */
90
+ choose(locale: string): Promise<void>;
91
+ changeBranch(operation: BranchOperation): Promise<HistoryActionResult>;
92
+ /** What the branch buttons show. */
93
+ branch: BranchButtonState;
94
+ publish(state: BranchButtonState): void;
95
+ /** Follows `branch`. While anything follows it, the closed panel keeps watching the branches. */
96
+ follow(listener: () => void): () => void;
97
+ /** How many follow `branch`. */
98
+ followers(): number;
99
+ /** Set by the panel while it is mounted, to hear when `followers()` changes. */
100
+ onFollowers(count: number): void;
101
+ }
102
+ function createController(): Controller {
103
+ const listeners = new Set<() => void>();
104
+ const controller: Controller = {
105
+ open() {}, async refresh() {}, async choose() {}, async changeBranch() {return {done: false};},
106
+ branch: {repository: false, branch: undefined, head: null, branches: [], remoteBranches: [], writable: false, busy: false},
107
+ publish(state) {
108
+ controller.branch = state;
109
+ for (const listener of [...listeners]) listener();
110
+ },
111
+ follow(listener) {
112
+ listeners.add(listener);
113
+ controller.onFollowers(listeners.size);
114
+ return () => {
115
+ listeners.delete(listener);
116
+ controller.onFollowers(listeners.size);
117
+ };
118
+ },
119
+ followers: () => listeners.size,
120
+ onFollowers() {}
121
+ };
122
+ return controller;
123
+ }
81
124
  interface State {
82
125
  repository: Repository | null;
83
126
  snapshot: Snapshot | null;
@@ -94,6 +137,8 @@ interface State {
94
137
  const initial: State = {repository: null, snapshot: null, project: null, browser: null,
95
138
  changes: [], selected: null, diff: null, commit: null, error: null, busy: false};
96
139
  const controllers = new WeakMap<object, Controller>();
140
+ // Branch buttons rendered before the panel is mounted, or after it is disposed, wait for the VM's controller here.
141
+ const registrations = new Set<() => void>();
97
142
  type PickerWindow = Window & {showDirectoryPicker?: (options: {id: string; mode: 'readwrite'}) => Promise<FileSystemDirectoryHandle>};
98
143
  type ObserverWindow = Window & {FileSystemObserver?: new (callback: () => void) => {
99
144
  observe(handle: FileSystemDirectoryHandle, options: {recursive: boolean}): Promise<void>;
@@ -101,6 +146,53 @@ type ObserverWindow = Window & {FileSystemObserver?: new (callback: () => void)
101
146
  }};
102
147
  const watchInterval = 2000;
103
148
 
149
+ /**
150
+ * Calls `check` every `watchInterval`, when the window regains focus and shortly after a File System Observer reports
151
+ * a change under `root`, one call at a time and never while the page is hidden. Returns the function that stops it.
152
+ */
153
+ function watchRepository(document: Document, root: FileSystemDirectoryHandle, check: (stopped: () => boolean) => Promise<void>) {
154
+ const view = document.defaultView as ObserverWindow;
155
+ let stopped = false;
156
+ let checking = false;
157
+ const run = async () => {
158
+ if (stopped || checking || document.visibilityState === 'hidden') return;
159
+ checking = true;
160
+ try {
161
+ await check(() => stopped);
162
+ } catch {
163
+ // Entries can disappear while another tool writes. The next check reads them again.
164
+ } finally {
165
+ checking = false;
166
+ }
167
+ };
168
+ let soon: number | undefined;
169
+ const schedule = () => {view.clearTimeout(soon); soon = view.setTimeout(() => void run(), 300);};
170
+ const interval = view.setInterval(() => void run(), watchInterval);
171
+ const onFocus = () => void run();
172
+ view.addEventListener('focus', onFocus);
173
+ document.addEventListener('visibilitychange', onFocus);
174
+ let observer: InstanceType<NonNullable<ObserverWindow['FileSystemObserver']>> | null = null;
175
+ try {
176
+ if (view.FileSystemObserver) {
177
+ observer = new view.FileSystemObserver(schedule);
178
+ observer.observe(root, {recursive: true}).catch(() => {});
179
+ }
180
+ } catch {
181
+ observer = null;
182
+ }
183
+ void run();
184
+ return () => {
185
+ stopped = true;
186
+ view.clearInterval(interval);
187
+ view.clearTimeout(soon);
188
+ view.removeEventListener('focus', onFocus);
189
+ document.removeEventListener('visibilitychange', onFocus);
190
+ observer?.disconnect();
191
+ };
192
+ }
193
+ const sameRefs = (a: BranchRefs | null, b: BranchRefs) => !!a && a.branch === b.branch && a.head === b.head &&
194
+ a.branches.join('\n') === b.branches.join('\n') && a.remoteBranches.join('\n') === b.remoteBranches.join('\n');
195
+
104
196
  function GitPanel({vm, document, host, controller}: {vm: object; document: Document; host: GitHost; controller: Controller}) {
105
197
  const [visible, setVisible] = useState(false);
106
198
  // A layout tab that holds the panel. The panel component stays mounted, so the repository survives closing the tab.
@@ -233,61 +325,48 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
233
325
  void controller.refresh();
234
326
  }
235
327
  }, [shown]);
328
+ // The branches the branch buttons show: those of the last snapshot, or newer ones read while the panel is not in view.
329
+ const [refs, setRefs] = useState<BranchRefs | null>(null);
330
+ useEffect(() => {
331
+ if (!state.repository) setRefs(null);
332
+ else if (state.snapshot) setRefs(refsOf(state.snapshot));
333
+ }, [state.repository, state.snapshot]);
334
+ const [followers, setFollowers] = useState(controller.followers());
335
+ useEffect(() => {
336
+ controller.onFollowers = setFollowers;
337
+ // A button may have started following before the panel was mounted.
338
+ setFollowers(controller.followers());
339
+ return () => {controller.onFollowers = () => {};};
340
+ }, []);
236
341
  // While the panel is open, notice Git commands, agents and editors that change the repository outside the Editor.
342
+ // While it is out of view and a branch button follows it, read only the refs at the same moments, so the button
343
+ // follows branch changes; the panel reads everything again when it comes into view.
344
+ const following = followers > 0;
237
345
  useEffect(() => {
238
346
  const repository = state.repository;
239
- if (!shown || !repository) return;
240
- const view = document.defaultView as ObserverWindow;
241
- let stopped = false;
242
- let checking = false;
243
- const check = async () => {
347
+ if (!repository || !shown && !following) return;
348
+ return watchRepository(document, repository.root, async stopped => {
244
349
  const {snapshot} = current.current;
350
+ if (running.current || current.current.repository !== repository) return;
245
351
  // A tab behind another one has no boxes; it reads again when it is shown.
246
- const hidden = !dialog.current || !dialog.current.getClientRects().length;
247
- if (stopped || checking || running.current || document.visibilityState === 'hidden' || hidden ||
248
- !snapshot || current.current.repository !== repository) return;
249
- checking = true;
250
- try {
251
- const probe = await repository.probe(snapshot.scope);
252
- if (stopped || probe === 'locked' || probe === snapshot.probe || probe === handled.current ||
253
- current.current.snapshot !== snapshot) return;
254
- handled.current = probe;
255
- const {selected, commit} = current.current;
256
- // Keep an open diff, commit or review in place and ask for a refresh instead.
257
- if (selected || commit || reviewing.current) {
258
- setState(previous => previous.snapshot === snapshot ? {...previous, notice: labelsFor(locale).outdated} : previous);
259
- } else await controller.refresh(true);
260
- } catch {
261
- // Entries can disappear while another tool writes. The next check reads them again.
262
- } finally {
263
- checking = false;
264
- }
265
- };
266
- let soon: number | undefined;
267
- const schedule = () => {view.clearTimeout(soon); soon = view.setTimeout(() => void check(), 300);};
268
- const interval = view.setInterval(() => void check(), watchInterval);
269
- const onFocus = () => void check();
270
- view.addEventListener('focus', onFocus);
271
- document.addEventListener('visibilitychange', onFocus);
272
- let observer: InstanceType<NonNullable<ObserverWindow['FileSystemObserver']>> | null = null;
273
- try {
274
- if (view.FileSystemObserver) {
275
- observer = new view.FileSystemObserver(schedule);
276
- observer.observe(repository.root, {recursive: true}).catch(() => {});
352
+ const inView = shown && !!dialog.current && dialog.current.getClientRects().length > 0;
353
+ if (!inView || !snapshot) {
354
+ if (!following) return;
355
+ const next = await repository.refs();
356
+ if (!stopped() && current.current.repository === repository) setRefs(previous => sameRefs(previous, next) ? previous : next);
357
+ return;
277
358
  }
278
- } catch {
279
- observer = null;
280
- }
281
- void check();
282
- return () => {
283
- stopped = true;
284
- view.clearInterval(interval);
285
- view.clearTimeout(soon);
286
- view.removeEventListener('focus', onFocus);
287
- document.removeEventListener('visibilitychange', onFocus);
288
- observer?.disconnect();
289
- };
290
- }, [shown, state.repository, locale]);
359
+ const probe = await repository.probe(snapshot.scope);
360
+ if (stopped() || probe === 'locked' || probe === snapshot.probe || probe === handled.current ||
361
+ current.current.snapshot !== snapshot) return;
362
+ handled.current = probe;
363
+ const {selected, commit} = current.current;
364
+ // Keep an open diff, commit or review in place and ask for a refresh instead.
365
+ if (selected || commit || reviewing.current) {
366
+ setState(previous => previous.snapshot === snapshot ? {...previous, notice: labelsFor(locale).outdated} : previous);
367
+ } else await controller.refresh(true);
368
+ });
369
+ }, [shown, following, state.repository, locale]);
291
370
  useEffect(() => {
292
371
  if (!visible || slot) return;
293
372
  const previous = document.activeElement as HTMLElement | null;
@@ -296,7 +375,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
296
375
  }, [visible, slot]);
297
376
  const chooseRoot = async () => {
298
377
  const view = document.defaultView as PickerWindow;
299
- await run(async () => {
378
+ return run(async () => {
300
379
  let root: FileSystemDirectoryHandle;
301
380
  try {root = await view.showDirectoryPicker!({id: 'turbowarp-plus-git-root', mode: 'readwrite'});} catch (error) {
302
381
  if ((error as DOMException).name === 'AbortError') return {};
@@ -394,7 +473,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
394
473
  };
395
474
  const [newBranch, setNewBranch] = useState('');
396
475
  const [switchAfterCreate, setSwitchAfterCreate] = useState(true);
397
- const changeBranch = async (operation: Extract<LocalOperation, {kind: 'createBranch' | 'switchBranch'}>): Promise<HistoryActionResult> => {
476
+ const changeBranch = async (operation: BranchOperation): Promise<HistoryActionResult> => {
398
477
  const repository = current.current.repository;
399
478
  const project = current.current.project;
400
479
  const switching = operation.kind === 'switchBranch' || operation.checkout;
@@ -472,6 +551,18 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
472
551
  // One loader per selected change, so thumbnails read each asset once.
473
552
  const loadAsset = useMemo(() => state.repository && state.snapshot && state.selected ?
474
553
  state.repository.assetLoader(state.snapshot, state.selected) : undefined, [state.repository, state.snapshot, state.selected]);
554
+ controller.changeBranch = changeBranch;
555
+ controller.choose = async language => {
556
+ setLocale(language);
557
+ const before = current.current.repository;
558
+ const result = await chooseRoot();
559
+ // The panel shows the chosen repository, or why it could not be read; a cancelled choice opens nothing.
560
+ if (!result.done && result.error || current.current.repository !== before) controller.open(language);
561
+ };
562
+ const branchState: BranchButtonState = {repository: !!state.repository, branch: refs?.branch, head: refs?.head ?? null,
563
+ branches: refs?.branches ?? [], remoteBranches: refs?.remoteBranches ?? [], writable: !!host.localWrite && !!refs, busy: state.busy};
564
+ const published = JSON.stringify(branchState);
565
+ useEffect(() => controller.publish(branchState), [published]);
475
566
  if (!shown) return null;
476
567
  const groups: ChangeGroup[] = state.commit ? ['commit'] : ['staged', 'unstaged', 'untracked'];
477
568
  // Branches can be switched and created whenever writes are allowed and the repository has been read.
@@ -656,28 +747,55 @@ function upstreamText({upstream}: Snapshot, labels: ReturnType<typeof labelsFor>
656
747
  }
657
748
  }
658
749
 
750
+ const refsOf = ({branch, head, branches, remoteBranches}: Snapshot): BranchRefs => ({branch, head, branches, remoteBranches});
751
+ const announce = () => {for (const listener of [...registrations]) listener();};
752
+
659
753
  export function mountGitPanel({vm, document, host}: {vm: object; document: Document; host: GitHost}) {
660
754
  if (!(document.defaultView as PickerWindow)?.showDirectoryPicker) return null;
661
755
  const root = document.createElement('div');
662
756
  document.body.append(root);
663
- const controller: Controller = {open() {}, async refresh() {}};
757
+ const controller = createController();
664
758
  controllers.set(vm, controller);
665
759
  render(<GitPanel vm={vm} document={document} host={host} controller={controller}/>, root);
666
- return {refresh: () => controller.refresh(), dispose() {
760
+ announce();
761
+ return {refresh: () => controller.refresh(), open: (locale: string) => controller.open(locale), dispose() {
667
762
  if (controllers.get(vm) === controller) controllers.delete(vm);
668
763
  void host.connectionSettings?.disconnect().catch(() => {});
669
764
  unmountComponentAtNode(root);
670
765
  root.remove();
766
+ announce();
671
767
  }};
672
768
  }
673
769
 
674
- export function renderGitMenu(createElement: (component: any, props: any, ...children: any[]) => any,
675
- {vm, locale = 'en', MenuItem, MenuSection, onRequestClose}: {
676
- vm: object; locale?: string; MenuItem: any; MenuSection: any; onRequestClose(): void;
677
- }) {
678
- const controller = controllers.get(vm);
679
- if (!controller) return null;
680
- return createElement(MenuSection, null, createElement(MenuItem, {
681
- command: 'local-git', onClick() {onRequestClose(); controller.open(locale);}
682
- }, labelsFor(locale).menu));
770
+ /** The branch button of the panel mounted for `vm`, or nothing while no panel is mounted for it. */
771
+ function BranchEntry({vm, locale}: {vm: object; locale: string}) {
772
+ const [controller, setController] = useState(() => controllers.get(vm));
773
+ useEffect(() => {
774
+ const update = () => setController(controllers.get(vm));
775
+ registrations.add(update);
776
+ update();
777
+ return () => {registrations.delete(update);};
778
+ }, [vm]);
779
+ const [state, setState] = useState(controller?.branch);
780
+ useEffect(() => {
781
+ if (!controller) return;
782
+ const update = () => setState(controller.branch);
783
+ update();
784
+ return controller.follow(update);
785
+ }, [controller]);
786
+ if (!controller || !state) return null;
787
+ return <BranchButton state={state} locale={locale} open={() => controller.open(locale)} choose={() => void controller.choose(locale)}
788
+ switchBranch={name => controller.changeBranch({kind: 'switchBranch', name})}
789
+ createBranch={(name, checkout) => controller.changeBranch({kind: 'createBranch', name, checkout})}
790
+ createFromRemote={name => controller.changeBranch({kind: 'createBranch', name, fromRemote: true, checkout: true})}/>;
791
+ }
792
+
793
+ /**
794
+ * Renders the button for the host's menu bar that shows the current branch of the panel mounted for `vm` and opens the
795
+ * menu of branches, the panel and the repository choice. It renders nothing while no panel is mounted for `vm`, such as
796
+ * when the host does not offer local Git, and shares the panel's repository, operations and checks.
797
+ */
798
+ export function renderBranchButton(createElement: (component: any, props: any, ...children: any[]) => any,
799
+ {vm, locale = 'en'}: {vm: object; locale?: string}) {
800
+ return createElement(BranchEntry, {vm, locale});
683
801
  }
package/src/messages.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export const messages = {
2
2
  en: {
3
3
  pullRequests: 'Pull requests', localChanges: 'Local changes',
4
- title: 'Local Git', menu: 'Local Git…', close: 'Close', choose: 'Choose Repository…', refresh: 'Refresh',
4
+ title: 'Local Git', close: 'Close', choose: 'Choose Repository…', refresh: 'Refresh',
5
5
  empty: 'Choose an existing Git repository to inspect its files and history.',
6
6
  repository: 'Repository', project: 'Project folder', none: 'No project selected', root: 'Repository root',
7
7
  open: 'Open This Project', reload: 'Reload Project', up: 'Parent folder', browse: 'Choose a project inside this repository',
@@ -54,11 +54,12 @@ export const messages = {
54
54
  historyMore: 'Show more', historyLoading: 'Reading records…', historyFailed: 'Could not read the records.',
55
55
  historyActions: 'Actions', historyCreateHere: 'Create branch here…', historySwitch: 'Switch to {name}',
56
56
  historyCreateFromRemote: 'Create and switch to {name}', historyCopy: 'Copy commit ID', historyCopied: 'Copied the commit ID.',
57
- historyCopyFailed: 'Could not copy the commit ID.', historyRefs: 'Branches', cancel: 'Cancel'
57
+ historyCopyFailed: 'Could not copy the commit ID.', historyRefs: 'Branches', cancel: 'Cancel',
58
+ branchMenu: 'Branches', openPanel: 'Open Local Git', newBranchItem: 'New Branch…', newBranchTitle: 'New Branch'
58
59
  },
59
60
  ja: {
60
61
  pullRequests: 'プルリクエスト', localChanges: 'ローカルの変更',
61
- title: 'ローカル Git', menu: 'ローカル Git…', close: '閉じる', choose: 'リポジトリを選択…', refresh: '再取得',
62
+ title: 'ローカル Git', close: '閉じる', choose: 'リポジトリを選択…', refresh: '再取得',
62
63
  empty: '既存の Git リポジトリを選び、ファイルの状態と履歴を確認できます。',
63
64
  repository: 'リポジトリ', project: '作品フォルダ', none: '作品は未選択', root: 'リポジトリルート',
64
65
  open: 'この作品を開く', reload: '作品を再読込', up: '上のフォルダ', browse: 'リポジトリ内の作品を選択',
@@ -111,7 +112,8 @@ export const messages = {
111
112
  historyMore: 'さらに表示', historyLoading: '記録を読込中…', historyFailed: '記録を読み込めませんでした。',
112
113
  historyActions: '操作', historyCreateHere: 'ここからブランチを作る…', historySwitch: '{name} に切り替える',
113
114
  historyCreateFromRemote: '{name} を作って切り替える', historyCopy: 'コミットの ID をコピー', historyCopied: 'コミットの ID をコピーしました。',
114
- historyCopyFailed: 'コミットの ID をコピーできませんでした。', historyRefs: 'ブランチ', cancel: 'キャンセル'
115
+ historyCopyFailed: 'コミットの ID をコピーできませんでした。', historyRefs: 'ブランチ', cancel: 'キャンセル',
116
+ branchMenu: 'ブランチ', openPanel: 'ローカル Git を開く', newBranchItem: '新しいブランチ…', newBranchTitle: '新しいブランチ'
115
117
  }
116
118
  };
117
119
  export type Labels = typeof messages.en;
package/src/repository.ts CHANGED
@@ -84,6 +84,8 @@ export interface HistoryPage {
84
84
  export const historyPageSize = 50;
85
85
  const projectSourceLimit = 16 * 1024 * 1024;
86
86
  type Metadata = Map<string, Uint8Array | null>;
87
+ /** The branch fields of a snapshot. */
88
+ export type BranchRefs = Pick<Snapshot, 'branch' | 'head' | 'branches' | 'remoteBranches'>;
87
89
  export interface Snapshot {
88
90
  branch: string | undefined;
89
91
  head: string | null;
@@ -298,13 +300,7 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
298
300
  const assertSnapshot = async (snapshot: Snapshot) => {
299
301
  if (!sameMetadata(snapshot.metadata, await metadata())) throw new GitPanelError('changed');
300
302
  };
301
- const read = async (scope = ''): Promise<Snapshot> => {
302
- scope = pathParts(scope).join('/');
303
- fs.clearReads();
304
- cache = {};
305
- // Taken first, so a change during the read differs from the stored value and triggers another read.
306
- const fingerprint = await probe(scope);
307
- const before = await metadata();
303
+ const readRefs = async (): Promise<BranchRefs> => {
308
304
  let head: string | null;
309
305
  try {head = await git.resolveRef({...options, ref: 'HEAD'});} catch (error) {
310
306
  if (errorCode(error) !== 'NotFoundError') throw error;
@@ -314,6 +310,16 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
314
310
  const branches = await git.listBranches(options);
315
311
  const remoteBranches = (await git.listBranches({...options, remote: 'origin'}))
316
312
  .filter(name => name !== 'HEAD' && !branches.includes(name)).sort();
313
+ return {branch: branch || undefined, head, branches, remoteBranches};
314
+ };
315
+ const read = async (scope = ''): Promise<Snapshot> => {
316
+ scope = pathParts(scope).join('/');
317
+ fs.clearReads();
318
+ cache = {};
319
+ // Taken first, so a change during the read differs from the stored value and triggers another read.
320
+ const fingerprint = await probe(scope);
321
+ const before = await metadata();
322
+ const {branch, head, branches, remoteBranches} = await readRefs();
317
323
  const commits = head ? (await git.log({...options, cache, ref: head, depth: 50})).map(({oid, commit}) => ({
318
324
  oid, message: commit.message, author: commit.author.name, timestamp: commit.author.timestamp
319
325
  })) : [];
@@ -339,7 +345,7 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
339
345
  }
340
346
  if (!sameMetadata(before, await metadata())) throw new GitPanelError('changed');
341
347
  await fs.checkReads();
342
- return {branch: branch || undefined, head, branches, remoteBranches, commits, changes, scope, metadata: before, probe: fingerprint,
348
+ return {branch, head, branches, remoteBranches, commits, changes, scope, metadata: before, probe: fingerprint,
343
349
  upstream};
344
350
  };
345
351
  interface HistoryNode {commit: HistoryCommit; time: number; children: number; expanded: boolean}
@@ -745,6 +751,14 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
745
751
  }
746
752
  }),
747
753
  read: (scope?: string) => serial(() => read(scope)),
754
+ /**
755
+ * The current branch, HEAD and the branch names, read from refs only. Cheap enough to follow branch changes made
756
+ * by other tools while nothing else is read; `read` returns the same fields.
757
+ */
758
+ refs: () => serial(() => {
759
+ fs.clearReads();
760
+ return readRefs();
761
+ }),
748
762
  readDiff: (snapshot: Snapshot, change: FileChange) => serial(() => readDiff(snapshot, change)),
749
763
  historyChanges: (snapshot: Snapshot, oid: string) => serial(() => historyChanges(snapshot, oid)),
750
764
  /**
package/src/style.css CHANGED
@@ -92,3 +92,31 @@
92
92
  .twgit-graph-status { margin: 4px 0; font-size: 11px; min-height: 1em; }
93
93
  .twgit-panel .twgit-graph-popup [role="menuitem"]:focus-visible, .twgit-graph-popup [role="menuitem"]:focus-visible { outline-offset: -2px; }
94
94
  .twgit-graph-form-error { margin: 0; color: #a11919; overflow-wrap: anywhere; }
95
+ /* The branch button sits in the host's menu bar and takes its text color; its menu uses the host's menu colors. */
96
+ .twgit-branch-entry { position: relative; flex: 0 1 auto; align-self: center; display: flex; min-width: 0; font: inherit; }
97
+ .twgit-branch-button { display: flex; align-items: center; gap: 4px; min-width: 0; max-width: 16rem; min-height: 2rem; padding: 0 8px;
98
+ color: inherit; background: transparent; border: 0; border-radius: 4px; font: inherit; white-space: nowrap; cursor: pointer; }
99
+ .twgit-branch-button:hover, .twgit-branch-button[aria-expanded="true"] { background: color-mix(in srgb, currentColor 15%, transparent); }
100
+ .twgit-branch-button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
101
+ .twgit-branch-icon { flex: none; }
102
+ .twgit-branch-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
103
+ .twgit-branch-unborn { flex: none; font-size: 11px; opacity: .85; }
104
+ .twgit-branch-caret { flex: none; font-size: 10px; }
105
+ .twgit-branch-popup { position: absolute; z-index: 510; top: calc(100% + 4px); right: 0; display: grid; gap: 2px; min-width: 220px;
106
+ max-width: min(360px, 90vw); max-height: 70vh; overflow: auto; box-sizing: border-box; padding: 4px;
107
+ color: var(--text-primary, CanvasText); background: var(--ui-white, Canvas); border: 1px solid var(--ui-black-transparent, GrayText);
108
+ border-radius: 6px; box-shadow: 0 8px 24px #0004; font: 14px/1.5 system-ui, sans-serif; white-space: normal; }
109
+ .twgit-branch-popup [role^="menuitem"] { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; color: inherit;
110
+ background: transparent; border: 0; border-radius: 4px; font: inherit; text-align: start; overflow-wrap: anywhere; cursor: pointer; }
111
+ .twgit-branch-popup [role^="menuitem"]:hover, .twgit-branch-popup [role^="menuitem"]:focus-visible {
112
+ background: color-mix(in srgb, currentColor 12%, transparent); }
113
+ .twgit-branch-popup [role^="menuitem"]:focus-visible { outline: 2px solid var(--looks-secondary, Highlight); outline-offset: -2px; }
114
+ .twgit-branch-popup [aria-disabled="true"]:not([aria-checked="true"]) { opacity: .55; cursor: default; }
115
+ .twgit-branch-popup [aria-checked="true"] { font-weight: 600; }
116
+ .twgit-branch-popup [data-separator-before="true"] { margin-top: 3px; box-shadow: 0 -3px 0 -2px color-mix(in srgb, currentColor 25%, transparent); }
117
+ .twgit-branch-check { flex: none; width: 1em; text-align: center; }
118
+ .twgit-branch-popup form { display: grid; gap: 6px; padding: 6px; font-size: 12px; }
119
+ .twgit-branch-popup form label { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
120
+ .twgit-branch-popup form input:not([type]) { flex: 1; min-width: 10em; font: inherit; }
121
+ .twgit-branch-popup form button { color: inherit; background: transparent; border: 1px solid currentColor; border-radius: 4px; padding: 3px 8px; font: inherit; cursor: pointer; }
122
+ .twgit-branch-popup form button:disabled { opacity: .5; cursor: default; }