@kubohiroya/turbowarp-plus-editor-git 0.17.0 → 0.19.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.
Files changed (44) hide show
  1. package/README.ja.md +13 -3
  2. package/README.md +12 -2
  3. package/dist/branch-button.d.ts +47 -0
  4. package/dist/change-marks.d.ts +8 -0
  5. package/dist/connection-settings.d.ts +3 -1
  6. package/dist/github-pull-requests.d.ts +2 -2
  7. package/dist/github-review-api.d.ts +112 -0
  8. package/dist/index.d.ts +29 -5
  9. package/dist/index.js +830 -303
  10. package/dist/messages.d.ts +109 -2
  11. package/dist/{project-asset-paths-nL5sV35A.js → project-asset-paths-DNaoWs3E.js} +2 -2
  12. package/dist/{project-comparison-DjMlwyp8.js → project-comparison-B_3QZ6M-.js} +1414 -33
  13. package/dist/project-source-diff.d.ts +5 -0
  14. package/dist/project-source-diff.js +4 -4
  15. package/dist/project-source-review.d.ts +6 -1
  16. package/dist/pull-request-panel.d.ts +2 -0
  17. package/dist/remote-operations.d.ts +1 -1
  18. package/dist/{repository-xIv0iZkj.js → repository-DXRZEtYk.js} +26 -10
  19. package/dist/repository.d.ts +7 -0
  20. package/dist/repository.js +1 -1
  21. package/dist/review-composer.d.ts +23 -0
  22. package/dist/review-submit.d.ts +144 -0
  23. package/dist/{smart-http-BmHEkE7K.js → smart-http-eARzlXHO.js} +101 -2
  24. package/dist/smart-http.d.ts +2 -0
  25. package/dist/smart-http.js +1 -1
  26. package/dist/style.css +51 -0
  27. package/package.json +1 -1
  28. package/src/branch-button.tsx +289 -0
  29. package/src/change-marks.ts +22 -0
  30. package/src/connection-settings.tsx +9 -2
  31. package/src/github-review-api.ts +298 -0
  32. package/src/index.tsx +322 -66
  33. package/src/messages.ts +80 -4
  34. package/src/project-source-diff.css +10 -0
  35. package/src/project-source-diff.tsx +6 -0
  36. package/src/project-source-review.tsx +33 -5
  37. package/src/pull-request-panel.tsx +27 -6
  38. package/src/remote-operations.ts +1 -1
  39. package/src/repository.ts +22 -8
  40. package/src/review-composer.tsx +166 -0
  41. package/src/review-submit.ts +519 -0
  42. package/src/smart-http.ts +2 -2
  43. package/src/style.css +41 -0
  44. package/dist/objectSpread2-Dkd68nJH.js +0 -101
package/src/index.tsx CHANGED
@@ -5,7 +5,11 @@ 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, useBranchSnackbar} from './branch-button.js';
10
+ import {changeMarks} from './change-marks.js';
11
+ import type {ChangeMark} from './change-marks.js';
12
+ import type {BranchButtonState} from './branch-button.js';
9
13
  import {HistoryGraph} from './history-graph-view.js';
10
14
  import type {HistoryActionResult} from './history-graph-view.js';
11
15
  import {labelsFor} from './messages.js';
@@ -34,11 +38,15 @@ export {openProjectComparison} from './project-comparison.js';
34
38
  export {openProjectSourceTabs} from './project-source-tabs.js';
35
39
  export type {ProjectSourceTabsFile, ProjectSourceTabsOptions} from './project-source-tabs.js';
36
40
  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';
41
+ export type {BranchRefs, PullRequestBase, PullRequestFetch, PullRequestFiles, Repository, Snapshot, FileChange, FileDiff, HistoryCommit, HistoryCursor, HistoryPage, HistoryRef} from './repository.js';
42
+ export {BranchButton} from './branch-button.js';
43
+ export type {BranchButtonActions, BranchButtonProps, BranchButtonState} from './branch-button.js';
38
44
  export {HistoryGraph, laneColors} from './history-graph-view.js';
39
45
  export type {HistoryActionResult, HistoryGraphProps} from './history-graph-view.js';
40
46
  export {layoutHistory} from './history-graph.js';
41
47
  export type {GraphCommit, GraphLayout, GraphRow, GraphSegment} from './history-graph.js';
48
+ export {changeMarks} from './change-marks.js';
49
+ export type {ChangeMark} from './change-marks.js';
42
50
  export type {GitAuthor, LocalOperation} from './local-operations.js';
43
51
  export {pullRequestBranch} from './local-operations.js';
44
52
 
@@ -77,7 +85,56 @@ export interface GitPanelRequest {
77
85
  inactive?: boolean;
78
86
  mount(element: HTMLElement, close: () => void): {requestClose(): void; dispose(): void};
79
87
  }
80
- interface Controller {open(locale: string): void; refresh(automatic?: boolean): Promise<void>}
88
+ type BranchOperation = Extract<LocalOperation, {kind: 'createBranch' | 'switchBranch'}>;
89
+ /** The panel's side of its entries: set by the panel as it renders, used by the branch buttons and `mountGitPanel`'s result. */
90
+ interface Controller {
91
+ open(locale: string): void;
92
+ refresh(automatic?: boolean): Promise<void>;
93
+ /** Asks for a repository, then opens the panel on it. */
94
+ choose(locale: string): Promise<void>;
95
+ changeBranch(operation: BranchOperation): Promise<HistoryActionResult>;
96
+ /** Reloads the project folder selected in the panel, as Reload Project does. */
97
+ reload(): void;
98
+ /** What the branch buttons show. */
99
+ branch: BranchButtonState;
100
+ /** The chosen repository and its last snapshot, published with `branch`. */
101
+ repository: Repository | null;
102
+ snapshot: Snapshot | null;
103
+ publish(state: BranchButtonState, repository: Repository | null, snapshot: Snapshot | null): void;
104
+ /** Follows `branch`. While anything follows it, the closed panel keeps watching the branches. */
105
+ follow(listener: () => void): () => void;
106
+ /** How many follow `branch`. */
107
+ followers(): number;
108
+ /** Set by the panel while it is mounted, to hear when `followers()` changes. */
109
+ onFollowers(count: number): void;
110
+ }
111
+ function createController(): Controller {
112
+ const listeners = new Set<() => void>();
113
+ const controller: Controller = {
114
+ open() {}, async refresh() {}, async choose() {}, async changeBranch() {return {done: false};}, reload() {},
115
+ branch: {repository: false, branch: undefined, head: null, branches: [], remoteBranches: [], writable: false, busy: false,
116
+ project: false},
117
+ repository: null,
118
+ snapshot: null,
119
+ publish(state, repository, snapshot) {
120
+ controller.branch = state;
121
+ controller.repository = repository;
122
+ controller.snapshot = snapshot;
123
+ for (const listener of [...listeners]) listener();
124
+ },
125
+ follow(listener) {
126
+ listeners.add(listener);
127
+ controller.onFollowers(listeners.size);
128
+ return () => {
129
+ listeners.delete(listener);
130
+ controller.onFollowers(listeners.size);
131
+ };
132
+ },
133
+ followers: () => listeners.size,
134
+ onFollowers() {}
135
+ };
136
+ return controller;
137
+ }
81
138
  interface State {
82
139
  repository: Repository | null;
83
140
  snapshot: Snapshot | null;
@@ -94,6 +151,8 @@ interface State {
94
151
  const initial: State = {repository: null, snapshot: null, project: null, browser: null,
95
152
  changes: [], selected: null, diff: null, commit: null, error: null, busy: false};
96
153
  const controllers = new WeakMap<object, Controller>();
154
+ // Branch buttons rendered before the panel is mounted, or after it is disposed, wait for the VM's controller here.
155
+ const registrations = new Set<() => void>();
97
156
  type PickerWindow = Window & {showDirectoryPicker?: (options: {id: string; mode: 'readwrite'}) => Promise<FileSystemDirectoryHandle>};
98
157
  type ObserverWindow = Window & {FileSystemObserver?: new (callback: () => void) => {
99
158
  observe(handle: FileSystemDirectoryHandle, options: {recursive: boolean}): Promise<void>;
@@ -101,6 +160,53 @@ type ObserverWindow = Window & {FileSystemObserver?: new (callback: () => void)
101
160
  }};
102
161
  const watchInterval = 2000;
103
162
 
163
+ /**
164
+ * Calls `check` every `watchInterval`, when the window regains focus and shortly after a File System Observer reports
165
+ * a change under `root`, one call at a time and never while the page is hidden. Returns the function that stops it.
166
+ */
167
+ function watchRepository(document: Document, root: FileSystemDirectoryHandle, check: (stopped: () => boolean) => Promise<void>) {
168
+ const view = document.defaultView as ObserverWindow;
169
+ let stopped = false;
170
+ let checking = false;
171
+ const run = async () => {
172
+ if (stopped || checking || document.visibilityState === 'hidden') return;
173
+ checking = true;
174
+ try {
175
+ await check(() => stopped);
176
+ } catch {
177
+ // Entries can disappear while another tool writes. The next check reads them again.
178
+ } finally {
179
+ checking = false;
180
+ }
181
+ };
182
+ let soon: number | undefined;
183
+ const schedule = () => {view.clearTimeout(soon); soon = view.setTimeout(() => void run(), 300);};
184
+ const interval = view.setInterval(() => void run(), watchInterval);
185
+ const onFocus = () => void run();
186
+ view.addEventListener('focus', onFocus);
187
+ document.addEventListener('visibilitychange', onFocus);
188
+ let observer: InstanceType<NonNullable<ObserverWindow['FileSystemObserver']>> | null = null;
189
+ try {
190
+ if (view.FileSystemObserver) {
191
+ observer = new view.FileSystemObserver(schedule);
192
+ observer.observe(root, {recursive: true}).catch(() => {});
193
+ }
194
+ } catch {
195
+ observer = null;
196
+ }
197
+ void run();
198
+ return () => {
199
+ stopped = true;
200
+ view.clearInterval(interval);
201
+ view.clearTimeout(soon);
202
+ view.removeEventListener('focus', onFocus);
203
+ document.removeEventListener('visibilitychange', onFocus);
204
+ observer?.disconnect();
205
+ };
206
+ }
207
+ const sameRefs = (a: BranchRefs | null, b: BranchRefs) => !!a && a.branch === b.branch && a.head === b.head &&
208
+ a.branches.join('\n') === b.branches.join('\n') && a.remoteBranches.join('\n') === b.remoteBranches.join('\n');
209
+
104
210
  function GitPanel({vm, document, host, controller}: {vm: object; document: Document; host: GitHost; controller: Controller}) {
105
211
  const [visible, setVisible] = useState(false);
106
212
  // A layout tab that holds the panel. The panel component stays mounted, so the repository survives closing the tab.
@@ -233,61 +339,48 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
233
339
  void controller.refresh();
234
340
  }
235
341
  }, [shown]);
342
+ // The branches the branch buttons show: those of the last snapshot, or newer ones read while the panel is not in view.
343
+ const [refs, setRefs] = useState<BranchRefs | null>(null);
344
+ useEffect(() => {
345
+ if (!state.repository) setRefs(null);
346
+ else if (state.snapshot) setRefs(refsOf(state.snapshot));
347
+ }, [state.repository, state.snapshot]);
348
+ const [followers, setFollowers] = useState(controller.followers());
349
+ useEffect(() => {
350
+ controller.onFollowers = setFollowers;
351
+ // A button may have started following before the panel was mounted.
352
+ setFollowers(controller.followers());
353
+ return () => {controller.onFollowers = () => {};};
354
+ }, []);
236
355
  // While the panel is open, notice Git commands, agents and editors that change the repository outside the Editor.
356
+ // While it is out of view and a branch button follows it, read only the refs at the same moments, so the button
357
+ // follows branch changes; the panel reads everything again when it comes into view.
358
+ const following = followers > 0;
237
359
  useEffect(() => {
238
360
  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 () => {
361
+ if (!repository || !shown && !following) return;
362
+ return watchRepository(document, repository.root, async stopped => {
244
363
  const {snapshot} = current.current;
364
+ if (running.current || current.current.repository !== repository) return;
245
365
  // 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;
366
+ const inView = shown && !!dialog.current && dialog.current.getClientRects().length > 0;
367
+ if (!inView || !snapshot) {
368
+ if (!following) return;
369
+ const next = await repository.refs();
370
+ if (!stopped() && current.current.repository === repository) setRefs(previous => sameRefs(previous, next) ? previous : next);
371
+ return;
264
372
  }
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(() => {});
277
- }
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]);
373
+ const probe = await repository.probe(snapshot.scope);
374
+ if (stopped() || probe === 'locked' || probe === snapshot.probe || probe === handled.current ||
375
+ current.current.snapshot !== snapshot) return;
376
+ handled.current = probe;
377
+ const {selected, commit} = current.current;
378
+ // Keep an open diff, commit or review in place and ask for a refresh instead.
379
+ if (selected || commit || reviewing.current) {
380
+ setState(previous => previous.snapshot === snapshot ? {...previous, notice: labelsFor(locale).outdated} : previous);
381
+ } else await controller.refresh(true);
382
+ });
383
+ }, [shown, following, state.repository, locale]);
291
384
  useEffect(() => {
292
385
  if (!visible || slot) return;
293
386
  const previous = document.activeElement as HTMLElement | null;
@@ -296,7 +389,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
296
389
  }, [visible, slot]);
297
390
  const chooseRoot = async () => {
298
391
  const view = document.defaultView as PickerWindow;
299
- await run(async () => {
392
+ return run(async () => {
300
393
  let root: FileSystemDirectoryHandle;
301
394
  try {root = await view.showDirectoryPicker!({id: 'turbowarp-plus-git-root', mode: 'readwrite'});} catch (error) {
302
395
  if ((error as DOMException).name === 'AbortError') return {};
@@ -371,7 +464,8 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
371
464
  const pullRequestConnection = useMemo(() => {
372
465
  const settings = host.connectionSettings;
373
466
  const getSession = settings?.getSession;
374
- return getSession ? {getSession, connected: () => settings.getView().state.status === 'connected'} : undefined;
467
+ return getSession ? {getSession, connected: () => settings.getView().state.status === 'connected',
468
+ reviewGranted: () => settings.getView().review === true} : undefined;
375
469
  }, [host]);
376
470
  const [message, setMessage] = useState('');
377
471
  const localOperation = (operation: LocalOperation, notice?: string) => {
@@ -394,7 +488,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
394
488
  };
395
489
  const [newBranch, setNewBranch] = useState('');
396
490
  const [switchAfterCreate, setSwitchAfterCreate] = useState(true);
397
- const changeBranch = async (operation: Extract<LocalOperation, {kind: 'createBranch' | 'switchBranch'}>): Promise<HistoryActionResult> => {
491
+ const changeBranch = async (operation: BranchOperation): Promise<HistoryActionResult> => {
398
492
  const repository = current.current.repository;
399
493
  const project = current.current.project;
400
494
  const switching = operation.kind === 'switchBranch' || operation.checkout;
@@ -472,6 +566,22 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
472
566
  // One loader per selected change, so thumbnails read each asset once.
473
567
  const loadAsset = useMemo(() => state.repository && state.snapshot && state.selected ?
474
568
  state.repository.assetLoader(state.snapshot, state.selected) : undefined, [state.repository, state.snapshot, state.selected]);
569
+ controller.changeBranch = changeBranch;
570
+ controller.reload = () => {
571
+ if (current.current.project !== null) void openProject(current.current.project);
572
+ };
573
+ controller.choose = async language => {
574
+ setLocale(language);
575
+ const before = current.current.repository;
576
+ const result = await chooseRoot();
577
+ // The panel shows the chosen repository, or why it could not be read; a cancelled choice opens nothing.
578
+ if (!result.done && result.error || current.current.repository !== before) controller.open(language);
579
+ };
580
+ const branchState: BranchButtonState = {repository: !!state.repository, branch: refs?.branch, head: refs?.head ?? null,
581
+ branches: refs?.branches ?? [], remoteBranches: refs?.remoteBranches ?? [], writable: !!host.localWrite && !!refs, busy: state.busy,
582
+ project: state.project !== null};
583
+ const published = JSON.stringify(branchState);
584
+ useEffect(() => controller.publish(branchState, state.repository, state.snapshot), [published, state.repository, state.snapshot]);
475
585
  if (!shown) return null;
476
586
  const groups: ChangeGroup[] = state.commit ? ['commit'] : ['staged', 'unstaged', 'untracked'];
477
587
  // Branches can be switched and created whenever writes are allowed and the repository has been read.
@@ -656,28 +766,174 @@ function upstreamText({upstream}: Snapshot, labels: ReturnType<typeof labelsFor>
656
766
  }
657
767
  }
658
768
 
769
+ const refsOf = ({branch, head, branches, remoteBranches}: Snapshot): BranchRefs => ({branch, head, branches, remoteBranches});
770
+ const announce = () => {for (const listener of [...registrations]) listener();};
771
+
659
772
  export function mountGitPanel({vm, document, host}: {vm: object; document: Document; host: GitHost}) {
660
773
  if (!(document.defaultView as PickerWindow)?.showDirectoryPicker) return null;
661
774
  const root = document.createElement('div');
662
775
  document.body.append(root);
663
- const controller: Controller = {open() {}, async refresh() {}};
776
+ const controller = createController();
664
777
  controllers.set(vm, controller);
665
778
  render(<GitPanel vm={vm} document={document} host={host} controller={controller}/>, root);
666
- return {refresh: () => controller.refresh(), dispose() {
779
+ announce();
780
+ return {refresh: () => controller.refresh(), open: (locale: string) => controller.open(locale), dispose() {
667
781
  if (controllers.get(vm) === controller) controllers.delete(vm);
668
782
  void host.connectionSettings?.disconnect().catch(() => {});
669
783
  unmountComponentAtNode(root);
670
784
  root.remove();
785
+ announce();
671
786
  }};
672
787
  }
673
788
 
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));
789
+ /** The branch button of the panel mounted for `vm`, or nothing while no panel is mounted for it. */
790
+ /** Calls `listener` with the controller of the panel mounted for `vm`, now and whenever it is mounted or disposed. */
791
+ function findController(vm: object, listener: (controller: Controller | undefined) => void) {
792
+ const update = () => listener(controllers.get(vm));
793
+ registrations.add(update);
794
+ update();
795
+ return () => {registrations.delete(update);};
796
+ }
797
+
798
+ /** The panel mounted for `vm` and what it last published. Following it keeps the closed panel watching the branches. */
799
+ function useController(vm: object) {
800
+ const [controller, setController] = useState(() => controllers.get(vm));
801
+ useEffect(() => findController(vm, setController), [vm]);
802
+ const [state, setState] = useState(controller?.branch);
803
+ useEffect(() => {
804
+ if (!controller) return;
805
+ const update = () => setState(controller.branch);
806
+ update();
807
+ return controller.follow(update);
808
+ }, [controller]);
809
+ return {controller, state};
810
+ }
811
+
812
+ function BranchEntry({vm, locale}: {vm: object; locale: string}) {
813
+ const {controller, state} = useController(vm);
814
+ if (!controller || !state) return null;
815
+ return <BranchButton state={state} locale={locale} open={() => controller.open(locale)} choose={() => void controller.choose(locale)}
816
+ reload={() => controller.reload()}
817
+ switchBranch={name => controller.changeBranch({kind: 'switchBranch', name})}
818
+ createBranch={(name, checkout) => controller.changeBranch({kind: 'createBranch', name, checkout})}
819
+ createFromRemote={name => controller.changeBranch({kind: 'createBranch', name, fromRemote: true, checkout: true})}/>;
820
+ }
821
+
822
+ /**
823
+ * Renders the button for the host's menu bar that shows the current branch of the panel mounted for `vm` and opens the
824
+ * menu of branches, the panel and the repository choice. It renders nothing while no panel is mounted for `vm`, such as
825
+ * when the host does not offer local Git, and shares the panel's repository, operations and checks.
826
+ */
827
+ export function renderBranchButton(createElement: (component: any, props: any, ...children: any[]) => any,
828
+ {vm, locale = 'en'}: {vm: object; locale?: string}) {
829
+ return createElement(BranchEntry, {vm, locale});
830
+ }
831
+
832
+ type History = {commits: HistoryCommit[]; cursor: HistoryCursor | null; loading: boolean; failed: boolean};
833
+
834
+ /** The history of the panel's repository, read again whenever its branches move. */
835
+ function HistoryEntry({vm, locale}: {vm: object; locale: string}) {
836
+ const {controller, state} = useController(vm);
837
+ const labels = labelsFor(locale);
838
+ const repository = state && controller?.repository || null;
839
+ const [history, setHistory] = useState<History>({commits: [], cursor: null, loading: false, failed: false});
840
+ const [selected, setSelected] = useState<string | null>(null);
841
+ const ticket = useRef(0);
842
+ const root = useRef<HTMLDivElement>(null);
843
+ const refs = state ? [state.head, ...state.branches, '', ...state.remoteBranches].join('\n') : '';
844
+ useEffect(() => {
845
+ const current = ++ticket.current;
846
+ if (!repository) {
847
+ setHistory({commits: [], cursor: null, loading: false, failed: false});
848
+ return;
849
+ }
850
+ // The rows read before stay until the new page arrives, so focus in the list is kept.
851
+ setHistory(previous => ({...previous, cursor: null, loading: true, failed: false}));
852
+ repository.history().then(page => {
853
+ if (ticket.current === current) setHistory({commits: page.commits, cursor: page.cursor, loading: false, failed: false});
854
+ }, () => {
855
+ if (ticket.current === current) setHistory({commits: [], cursor: null, loading: false, failed: true});
856
+ });
857
+ }, [repository, refs]);
858
+ const showMore = () => {
859
+ if (!repository || !history.cursor || history.loading) return;
860
+ const current = ticket.current;
861
+ setHistory(previous => ({...previous, loading: true}));
862
+ repository.history({after: history.cursor}).then(page => {
863
+ if (ticket.current === current) setHistory(previous => ({commits: [...previous.commits, ...page.commits],
864
+ cursor: page.cursor, loading: false, failed: false}));
865
+ }, () => {
866
+ if (ticket.current === current) setHistory(previous => ({...previous, cursor: null, loading: false, failed: true}));
867
+ });
868
+ };
869
+ const {report, element: snackbar} = useBranchSnackbar(locale, !!state?.project, {
870
+ open: () => controller?.open(locale), reload: () => controller?.reload()
871
+ }, () => root.current?.querySelector<HTMLElement>('[tabindex="0"]')?.focus({preventScroll: true}));
872
+ if (!controller || !state) return null;
873
+ if (!repository) return <div className="twgit-history-view" ref={root}>
874
+ <p>{labels.historyViewEmpty}</p>
875
+ <button type="button" onClick={() => void controller.choose(locale)}>{labels.choose}</button>
876
+ </div>;
877
+ const change = async (operation: BranchOperation) => {
878
+ const result = await controller.changeBranch(operation);
879
+ report(result, operation.kind === 'switchBranch' || !!operation.checkout);
880
+ return result;
881
+ };
882
+ return <div className="twgit-history-view" ref={root}>
883
+ <HistoryGraph commits={history.commits} selected={selected} locale={locale} busy={state.busy} onSelect={setSelected}
884
+ hasMore={!!history.cursor} loadingMore={history.loading} onShowMore={showMore} localBranches={state.branches}
885
+ {...state.writable ? {
886
+ // A refused create stays in the graph's form with its reason; only a done one is told in the snackbar.
887
+ onCreateBranch: async (oid: string, name: string, checkout: boolean) => {
888
+ const result = await controller.changeBranch({kind: 'createBranch', name, at: oid, checkout});
889
+ if (result.done) report(result, checkout);
890
+ return result;
891
+ },
892
+ onSwitchBranch: (name: string) => change({kind: 'switchBranch', name}),
893
+ onCreateFromRemote: (name: string) => change({kind: 'createBranch', name, fromRemote: true, checkout: true})
894
+ } : {}}/>
895
+ {history.loading && !history.commits.length && <p role="status">{labels.historyLoading}</p>}
896
+ {history.failed && <p>{labels.historyFailed}</p>}
897
+ {snackbar}
898
+ </div>;
899
+ }
900
+
901
+ /**
902
+ * Renders the history graph of the repository chosen in the panel mounted for `vm`, for a side panel of the host. It
903
+ * follows branch changes made anywhere, offers the graph's branch actions when the panel allows writes and tells their
904
+ * results in the branch button's snackbar. Before a repository is chosen it offers to choose one; it renders nothing
905
+ * while no panel is mounted for `vm`.
906
+ */
907
+ export function renderHistoryView(createElement: (component: any, props: any, ...children: any[]) => any,
908
+ {vm, locale = 'en'}: {vm: object; locale?: string}) {
909
+ return createElement(HistoryEntry, {vm, locale});
910
+ }
911
+
912
+ /**
913
+ * Follows the Git status of the files under `directory`, as the panel mounted for `vm` last read it, and calls
914
+ * `listener` with their marks by path relative to `directory`. The marks are empty while no repository is chosen or
915
+ * `directory` is outside it. Returns the function that stops following.
916
+ */
917
+ export function followChangeMarks(vm: object, directory: FileSystemDirectoryHandle,
918
+ listener: (marks: ReadonlyMap<string, ChangeMark>) => void) {
919
+ let stopped = false;
920
+ let read: Snapshot | null | undefined;
921
+ let unfollow = () => {};
922
+ const update = async (controller: Controller | undefined) => {
923
+ const {repository, snapshot} = controller ?? {repository: null, snapshot: null};
924
+ if (snapshot === read) return;
925
+ read = snapshot;
926
+ const path = repository && snapshot ? await repository.root.resolve(directory).catch(() => null) : null;
927
+ if (!stopped && read === snapshot) listener(path ? changeMarks(snapshot!.changes, path.join('/')) : new Map());
928
+ };
929
+ const unregister = findController(vm, controller => {
930
+ unfollow();
931
+ unfollow = controller ? controller.follow(() => void update(controller)) : () => {};
932
+ void update(controller);
933
+ });
934
+ return () => {
935
+ stopped = true;
936
+ unregister();
937
+ unfollow();
938
+ };
683
939
  }
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,13 @@ 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',
59
+ switchedShort: 'Switched branches.', historyViewEmpty: 'Choose a Git repository to show its history here.'
58
60
  },
59
61
  ja: {
60
62
  pullRequests: 'プルリクエスト', localChanges: 'ローカルの変更',
61
- title: 'ローカル Git', menu: 'ローカル Git…', close: '閉じる', choose: 'リポジトリを選択…', refresh: '再取得',
63
+ title: 'ローカル Git', close: '閉じる', choose: 'リポジトリを選択…', refresh: '再取得',
62
64
  empty: '既存の Git リポジトリを選び、ファイルの状態と履歴を確認できます。',
63
65
  repository: 'リポジトリ', project: '作品フォルダ', none: '作品は未選択', root: 'リポジトリルート',
64
66
  open: 'この作品を開く', reload: '作品を再読込', up: '上のフォルダ', browse: 'リポジトリ内の作品を選択',
@@ -111,8 +113,82 @@ export const messages = {
111
113
  historyMore: 'さらに表示', historyLoading: '記録を読込中…', historyFailed: '記録を読み込めませんでした。',
112
114
  historyActions: '操作', historyCreateHere: 'ここからブランチを作る…', historySwitch: '{name} に切り替える',
113
115
  historyCreateFromRemote: '{name} を作って切り替える', historyCopy: 'コミットの ID をコピー', historyCopied: 'コミットの ID をコピーしました。',
114
- historyCopyFailed: 'コミットの ID をコピーできませんでした。', historyRefs: 'ブランチ', cancel: 'キャンセル'
116
+ historyCopyFailed: 'コミットの ID をコピーできませんでした。', historyRefs: 'ブランチ', cancel: 'キャンセル',
117
+ branchMenu: 'ブランチ', openPanel: 'ローカル Git を開く', newBranchItem: '新しいブランチ…', newBranchTitle: '新しいブランチ',
118
+ switchedShort: 'ブランチを切り替えました。', historyViewEmpty: 'Git リポジトリを選ぶと、ここに履歴を表示します。'
115
119
  }
116
120
  };
117
121
  export type Labels = typeof messages.en;
118
122
  export const labelsFor = (locale: string): Labels => locale.startsWith('ja') ? messages.ja : messages.en;
123
+
124
+ /** The words of sending a pull request review through the GitHub connection. */
125
+ export const reviewMessages = {
126
+ en: {
127
+ composer: 'Send a review', overall: 'Overall comment', event: 'Result',
128
+ COMMENT: 'Comment', APPROVE: 'Approve', REQUEST_CHANGES: 'Request changes',
129
+ scriptComment: 'Comment on this code', commented: 'Commented',
130
+ summaryThreads: 'Code comments sent as threads on their project.source.json: {threads}. Code comments in the review body: {body}.',
131
+ summaryBody: 'This connection sends only the review body: {body} code comments go in it.',
132
+ summaryNone: 'No code comments yet. Write them under each code change.',
133
+ send: 'Send Review', sending: 'Sending the review…', checking: 'Checking GitHub…',
134
+ sendAgain: 'Send Again', check: 'Check Whether It Was Sent', discard: 'Discard the Pending Review',
135
+ forget: 'Stop Tracking This Submission', useVersion: 'Review This Version',
136
+ sent: 'Sent the review.', openReview: 'Open the review on GitHub', openPullRequest: 'Open the pull request on GitHub',
137
+ dismiss: 'Dismiss', discarded: 'Discarded the pending review. Your words are kept.',
138
+ notStored: 'Browser storage is unavailable, so this draft is kept only while the panel is open.',
139
+ headChanged: 'The pull request changed after you started this review. The review is sent for the version shown.',
140
+ unsupported: 'This GitHub connection cannot send reviews.',
141
+ needsGrant: 'To send a review, disconnect and connect again with “Allow sending reviews”.',
142
+ allowReview: 'Allow sending reviews',
143
+ PENDING_REVIEW_EXISTS: 'You already have a pending review on this pull request on GitHub. Submit or discard it on GitHub, then send again.',
144
+ PENDING_REVIEW_LEFT: 'A pending review remains on GitHub. Only you can see it. Discard it here, or on GitHub with “Finish your review”.',
145
+ REVIEW_OUTCOME_UNKNOWN: 'It is unknown whether GitHub received the review. Check before sending again.',
146
+ pending: 'The review waits on GitHub with its code comments but is not published. Change it and send again, or discard it.',
147
+ REVIEW_REJECTED: 'GitHub refused the review, for example an approval of your own pull request.',
148
+ REVIEW_THREAD_REJECTED: 'GitHub refused a comment on a project file, so nothing was published. Comments on that file now go in the review body. Send again.',
149
+ RATE_LIMITED: 'The GitHub rate limit was reached. Try again in {seconds} seconds.',
150
+ EMPTY_REVIEW: 'Write an overall comment (needed to request changes) or a comment on the code.',
151
+ TOO_LONG: 'The review is longer than GitHub accepts (65,536 characters).',
152
+ closed: 'GitHub has no such review: it was not published, or it was discarded. You can send it again.',
153
+ lost: 'The connection no longer knows this submission. Check on GitHub whether the review was published ({count} of your reviews on this version found).',
154
+ session: 'Connect to GitHub again to send the review.',
155
+ FORBIDDEN: 'The GitHub connection is not allowed to send reviews to this repository.',
156
+ GITHUB_API_UNAVAILABLE: 'The connection cannot add code comments as threads. They now go in the review body: check the summary and send again.',
157
+ failed: 'Could not send the review. Nothing was published. Try again.'
158
+ },
159
+ ja: {
160
+ composer: 'レビューを送る', overall: '全体のコメント', event: '結果',
161
+ COMMENT: 'コメント', APPROVE: '承認', REQUEST_CHANGES: '修正を依頼',
162
+ scriptComment: 'このコードへのコメント', commented: 'コメントあり',
163
+ summaryThreads: 'コードへのコメントのうち {threads} 件をその project.source.json へのスレッドとして、{body} 件をレビュー本文に入れて送ります。',
164
+ summaryBody: 'この接続ではレビュー本文だけを送れます。コードへのコメント {body} 件を本文に入れます。',
165
+ summaryNone: 'コードへのコメントはまだありません。各コードの変更の下に書けます。',
166
+ send: 'レビューを送信', sending: 'レビューを送信中…', checking: 'GitHub で確かめています…',
167
+ sendAgain: 'もう一度送る', check: '送信されたか確かめる', discard: '保留中のレビューを破棄',
168
+ forget: 'この送信の記録を消す', useVersion: 'この版をレビューする',
169
+ sent: 'レビューを送りました。', openReview: 'GitHub でレビューを開く', openPullRequest: 'GitHub で PR を開く',
170
+ dismiss: '閉じる', discarded: '保留中のレビューを破棄しました。書いた内容は残っています。',
171
+ notStored: 'ブラウザーのストレージを使えないため、この下書きはパネルを開いている間だけ残ります。',
172
+ headChanged: 'レビューを書き始めた後に PR が更新されました。レビューは表示中の版に対して送ります。',
173
+ unsupported: 'この GitHub 接続ではレビューを送れません。',
174
+ needsGrant: 'レビューを送るには、切断して「レビューの送信を許可する」を付けて接続し直してください。',
175
+ allowReview: 'レビューの送信を許可する',
176
+ PENDING_REVIEW_EXISTS: 'この PR に、GitHub で書きかけの保留中のレビューがあります。GitHub で送るか破棄してから、もう一度送ってください。',
177
+ PENDING_REVIEW_LEFT: 'GitHub に保留中のレビューが残っています。あなたにしか見えません。ここで破棄するか、GitHub の「Finish your review」から破棄してください。',
178
+ REVIEW_OUTCOME_UNKNOWN: 'GitHub がレビューを受け取ったかわかりません。もう一度送る前に確かめてください。',
179
+ pending: 'レビューはコードへのコメント付きで GitHub に保留中ですが、公開されていません。直してもう一度送るか、破棄してください。',
180
+ REVIEW_REJECTED: 'GitHub がレビューを拒みました(自分の PR の承認など)。',
181
+ REVIEW_THREAD_REJECTED: 'GitHub が作品ファイルへのコメントを拒んだため、何も公開していません。そのファイルへのコメントはレビュー本文に入れます。もう一度送ってください。',
182
+ RATE_LIMITED: 'GitHub の利用上限に達しました。{seconds} 秒後に再試行してください。',
183
+ EMPTY_REVIEW: '全体のコメント(修正の依頼では必須)か、コードへのコメントを書いてください。',
184
+ TOO_LONG: 'レビューが GitHub の上限(65,536 文字)を超えています。',
185
+ closed: 'GitHub にそのレビューはありません。公開されなかったか、破棄されています。もう一度送れます。',
186
+ lost: '接続がこの送信の記録を持っていません。レビューが公開されたかを GitHub で確かめてください(この版へのあなたのレビュー {count} 件)。',
187
+ session: 'レビューを送るには、GitHub にもう一度接続してください。',
188
+ FORBIDDEN: 'GitHub 接続に、このリポジトリへレビューを送る権限がありません。',
189
+ GITHUB_API_UNAVAILABLE: 'この接続ではコードへのコメントをスレッドにできません。コメントはレビュー本文に入れます。件数を確かめて、もう一度送ってください。',
190
+ failed: 'レビューを送れませんでした。何も公開していません。再試行してください。'
191
+ }
192
+ };
193
+ export type ReviewLabels = typeof reviewMessages.en;
194
+ export const reviewLabelsFor = (locale: string): ReviewLabels => locale.startsWith('ja') ? reviewMessages.ja : reviewMessages.en;
@@ -120,3 +120,13 @@
120
120
  .twgit-review-target-status { font-weight: 600; }
121
121
  .twgit-review-asset { display: flex; align-items: center; gap: 8px; }
122
122
  .twgit-review-asset img { width: 32px; height: 32px; object-fit: contain; }
123
+ .twgit-review-field { display: grid; gap: 4px; }
124
+ .twgit-review-field textarea { font: inherit; resize: vertical; min-height: 3em; }
125
+ .twgit-review-comment { padding: 0 12px 10px; }
126
+ .twgit-review-composer { display: grid; gap: 10px; margin-top: 14px; padding: 12px 14px; border: 1px solid var(--twgit-review-line, color-mix(in srgb, CanvasText 18%, Canvas)); border-radius: 8px; }
127
+ .twgit-review-composer h3 { margin: 0; }
128
+ .twgit-review-events { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0; padding: 6px 10px; border: 1px solid var(--twgit-review-line, color-mix(in srgb, CanvasText 18%, Canvas)); border-radius: 6px; }
129
+ .twgit-review-summary, .twgit-review-note, .twgit-review-state { margin: 0; }
130
+ .twgit-review-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
131
+ .twgit-review-result { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin: 0; }
132
+ .twgit-review-result p { margin: 0; }
@@ -8,6 +8,12 @@ import type {LocationStatus} from './block-diff-preview.js';
8
8
  import './project-source-diff.css';
9
9
 
10
10
  export {createGitHubPullRequests, gitHubRepositoryFromOrigin} from './github-pull-requests.js';
11
+ export {createGitHubReviewApi, GitHubReviewError, isReviewThreadPath} from './github-review-api.js';
12
+ export type {GitHubReviewApi, GitHubReviewApiOptions, GitHubReviewErrorCode, ReviewContext, ReviewEvent} from './github-review-api.js';
13
+ export {checkReview, discardReview, emptyReviewDraft, forgetReviewSubmission, planReview, probeReviewRoutes, readReviewDraft,
14
+ reviewBody, reviewProblem, reviewRoute, sendReview, subscribeReviewDraft, writeReviewDraft} from './review-submit.js';
15
+ export {PullRequestReviewComposer} from './review-composer.js';
16
+ export type {ReviewComment, ReviewDraft, ReviewPlan, ReviewProblem, ReviewResult, ReviewSubmission, ReviewTarget} from './review-submit.js';
11
17
  export {ProjectSourceComparison, ProjectSourceReview} from './project-source-review.js';
12
18
  export {ProjectComparisonDialog, openProjectComparison} from './project-comparison.js';
13
19
  export {openProjectSourceTabs} from './project-source-tabs.js';