@kubohiroya/turbowarp-plus-editor-git 0.15.0 → 0.16.1

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 (38) hide show
  1. package/README.ja.md +5 -3
  2. package/README.md +5 -3
  3. package/dist/close-button.d.ts +6 -0
  4. package/dist/file-diff-view.d.ts +3 -1
  5. package/dist/filesystem.d.ts +2 -2
  6. package/dist/history-graph-view.d.ts +40 -0
  7. package/dist/history-graph.d.ts +44 -0
  8. package/dist/index.d.ts +18 -1
  9. package/dist/index.js +697 -31
  10. package/dist/local-operations.d.ts +14 -0
  11. package/dist/messages.d.ts +52 -0
  12. package/dist/{project-comparison-LAyP_beL.js → project-comparison-1rggIJse.js} +240 -20
  13. package/dist/project-comparison.d.ts +1 -0
  14. package/dist/project-source-diff.d.ts +2 -0
  15. package/dist/project-source-diff.js +2 -2
  16. package/dist/project-source-review.d.ts +43 -0
  17. package/dist/project-source-tabs.d.ts +35 -0
  18. package/dist/pull-request-panel.d.ts +6 -1
  19. package/dist/{repository-Cx-mxT9l.js → repository-BYno-5f8.js} +491 -4
  20. package/dist/repository.d.ts +44 -0
  21. package/dist/repository.js +1 -1
  22. package/dist/style.css +31 -4
  23. package/package.json +1 -1
  24. package/src/close-button.tsx +10 -0
  25. package/src/file-diff-view.tsx +7 -3
  26. package/src/filesystem.ts +2 -1
  27. package/src/history-graph-view.tsx +233 -0
  28. package/src/history-graph.ts +90 -0
  29. package/src/index.tsx +137 -17
  30. package/src/local-operations.ts +59 -4
  31. package/src/messages.ts +26 -2
  32. package/src/project-comparison.tsx +4 -2
  33. package/src/project-source-diff.tsx +2 -0
  34. package/src/project-source-review.tsx +127 -15
  35. package/src/project-source-tabs.tsx +118 -0
  36. package/src/pull-request-panel.tsx +26 -5
  37. package/src/repository.ts +155 -1
  38. package/src/style.css +31 -4
package/dist/style.css CHANGED
@@ -124,6 +124,10 @@
124
124
  .twgit-panel { width: 94vw; height: 90vh; display: flex; flex-direction: column; background: Canvas; color: CanvasText; border: 1px solid GrayText; border-radius: 8px; box-shadow: 0 12px 48px #0005; font: 14px/1.5 system-ui, sans-serif; overflow: hidden; }
125
125
  .twgit-panel > header { display: flex; gap: 8px; align-items: center; padding: 10px 16px; border-bottom: 1px solid GrayText; }
126
126
  .twgit-panel > header h2 { margin: 0 auto 0 0; font-size: 18px; }
127
+ .twgit-panel .twgit-close, .twgit-close { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0;
128
+ border: 1px solid transparent; border-radius: 4px; background: transparent; color: inherit; cursor: pointer; }
129
+ .twgit-panel .twgit-close:hover, .twgit-close:hover { background: color-mix(in srgb, currentColor 12%, transparent); }
130
+ .twgit-panel .twgit-close:focus-visible, .twgit-close:focus-visible { outline: 2px solid Highlight; outline-offset: 1px; }
127
131
  .twgit-panel h3 { font-size: 14px; margin: 12px 0 5px; }
128
132
  .twgit-panel button { color: inherit; background: Canvas; border: 1px solid GrayText; border-radius: 4px; padding: 4px 8px; cursor: pointer; font: inherit; text-align: start; }
129
133
  .twgit-panel button:disabled { opacity: .5; cursor: default; }
@@ -148,15 +152,14 @@
148
152
  .twgit-panel aside li { overflow-wrap: anywhere; }
149
153
  .twgit-panel summary { cursor: pointer; }
150
154
  .twgit-panel .twgit-file { width: 100%; border: 0; margin: 1px 0; }
151
- .twgit-panel .twgit-stage { font-size: 11px; margin: 0 0 4px; }
155
+ .twgit-panel .twgit-file-row { display: flex; gap: 6px; align-items: center; }
156
+ .twgit-panel .twgit-file-row .twgit-file { flex: 1; min-width: 0; width: auto; text-align: start; }
157
+ .twgit-panel .twgit-stage { flex: none; font-size: 11px; margin: 0; white-space: nowrap; }
152
158
  .twgit-commit { display: grid; gap: 6px; margin: 10px 0; padding: 8px; border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas); border-radius: 6px; }
153
159
  .twgit-commit label { display: grid; gap: 4px; font-size: 12px; }
154
160
  .twgit-commit textarea { font: inherit; resize: vertical; min-height: 3em; }
155
161
  .twgit-diff { padding: 0 16px 16px; min-width: 0; overflow: auto; }
156
162
  .twgit-diff .diff { font-size: 12px; }
157
- .twgit-history { list-style: none; margin: 0; padding: 0; }
158
- .twgit-history li { padding: 5px 0; }
159
- .twgit-history small { display: block; font-size: 11px; }
160
163
  .twgit-panel > footer { font-size: 11px; padding: 8px 16px; border-top: 1px solid GrayText; }
161
164
  .twgit-error { margin: 6px 16px; color: #a11919; padding: 8px; background: #fff0f0; }
162
165
  @media (max-width: 700px) { .twgit-content { grid-template-columns: minmax(180px, 42%) minmax(0, 1fr); } .twgit-panel > header { flex-wrap: wrap; } }
@@ -187,4 +190,28 @@
187
190
  .twgit-comparison-body > p { margin: 0 0 8px; }
188
191
  .twgit-comparison-body > section { margin-top: 16px; }
189
192
  .twgit-comparison-actions { display: flex; gap: 8px; justify-content: flex-end; padding: 10px 16px; border-top: 1px solid GrayText; }
193
+ .twgit-branches ul { list-style: none; padding: 0; margin: 4px 0; }
194
+ .twgit-branches li { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 2px 0; }
195
+ .twgit-branches form { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 6px 0; }
196
+ .twgit-graph-list { list-style: none; margin: 0; padding: 0; }
197
+ .twgit-graph-row { position: relative; display: flex; align-items: stretch; gap: 4px; height: 44px; }
198
+ .twgit-graph-lanes { flex: none; display: block; }
199
+ .twgit-panel .twgit-graph-commit, .twgit-graph-commit { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: center;
200
+ gap: 1px; margin: 2px 0; padding: 2px 6px; border: 0; text-align: start; }
201
+ .twgit-graph-subject, .twgit-graph-meta { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
202
+ .twgit-graph-meta { font-size: 11px; opacity: .8; }
203
+ .twgit-graph-ref { display: inline-block; margin-inline-end: 4px; padding: 0 5px; font-size: 11px; line-height: 16px; border-radius: 8px;
204
+ border: 1px solid color-mix(in srgb, CanvasText 35%, Canvas); }
205
+ .twgit-graph-ref-current { font-weight: 600; border-color: currentColor; }
206
+ .twgit-graph-ref-remote { border-style: dashed; }
207
+ .twgit-panel .twgit-graph-menu-button, .twgit-graph-menu-button { flex: none; align-self: center; padding: 0 6px; line-height: 22px; }
208
+ .twgit-graph-popup { position: absolute; z-index: 6; top: 100%; right: 0; display: grid; gap: 2px; min-width: 200px; padding: 6px;
209
+ background: Canvas; color: CanvasText; border: 1px solid GrayText; border-radius: 6px; box-shadow: 0 8px 24px #0004; }
210
+ .twgit-panel .twgit-graph-popup [role="menuitem"], .twgit-graph-popup [role="menuitem"] { border: 0; text-align: start; }
211
+ .twgit-graph-popup form { display: grid; gap: 6px; font-size: 12px; }
212
+ .twgit-graph-form-actions { display: flex; gap: 6px; justify-content: flex-end; }
213
+ .twgit-graph-more { margin: 6px 0; }
214
+ .twgit-graph-status { margin: 4px 0; font-size: 11px; min-height: 1em; }
215
+ .twgit-panel .twgit-graph-popup [role="menuitem"]:focus-visible, .twgit-graph-popup [role="menuitem"]:focus-visible { outline-offset: -2px; }
216
+ .twgit-graph-form-error { margin: 0; color: #a11919; overflow-wrap: anywhere; }
190
217
  /*$vite$:1*/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-plus-editor-git",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Read-only local Git panel for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "MPL-2.0",
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+
3
+ /** A button drawn as an X, named by `label` for screen readers and in its tooltip. */
4
+ export function CloseButton({label, onClick}: {label: string; onClick(): void}) {
5
+ return <button type="button" className="twgit-close" aria-label={label} title={label} onClick={onClick}>
6
+ <svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" focusable="false">
7
+ <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" fill="none"/>
8
+ </svg>
9
+ </button>;
10
+ }
@@ -7,8 +7,8 @@ import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
7
7
  import type {ProjectAssetLoader} from './project-assets.js';
8
8
  import type {ProjectBlockLocator} from './block-diff-location.js';
9
9
 
10
- const en = {view: 'Show changes as', blocks: 'Blocks', text: 'Text'};
11
- const ja: typeof en = {view: '変更の表示', blocks: 'ブロック', text: 'テキスト'};
10
+ const en = {view: 'Show changes as', blocks: 'Blocks', text: 'Text', openTabs: 'Open as tabs'};
11
+ const ja: typeof en = {view: '変更の表示', blocks: 'ブロック', text: 'テキスト', openTabs: 'タブで開く'};
12
12
 
13
13
  /** Project sources are the files the panel opens as projects. */
14
14
  export const isProjectSource = (path: string) => path.split('/').pop() === 'project.source.json';
@@ -16,9 +16,11 @@ export const isProjectSource = (path: string) => path.split('/').pop() === 'proj
16
16
  /**
17
17
  * Shows one changed file. Project sources are compared as blocks by default, with their text diff one switch away.
18
18
  */
19
- export function FileDiffView({change, diff, labels, locale, renderBlock, loadAsset, locateBlock}: {
19
+ export function FileDiffView({change, diff, labels, locale, renderBlock, loadAsset, locateBlock, onOpenTabs}: {
20
20
  change: FileChange; diff: FileDiff | null; labels: ReturnType<typeof labelsFor>; locale: string;
21
21
  renderBlock?: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader; locateBlock?: ProjectBlockLocator;
22
+ /** Opens the comparison as tabs of the host's layout, a tab group for each stage or sprite. */
23
+ onOpenTabs?(before: string | null, after: string | null): void;
22
24
  }) {
23
25
  const [view, setView] = useState<'blocks' | 'text'>('blocks');
24
26
  const names = locale.startsWith('ja') ? ja : en;
@@ -40,6 +42,8 @@ export function FileDiffView({change, diff, labels, locale, renderBlock, loadAss
40
42
  {sources && <div className="twgit-view-switch" role="group" aria-label={names.view}>
41
43
  {(['blocks', 'text'] as const).map(mode => <button key={mode} type="button" aria-pressed={view === mode}
42
44
  data-git-view={mode} onClick={() => setView(mode)}>{names[mode]}</button>)}
45
+ {onOpenTabs && <button type="button" data-git-open-tabs="" onClick={() => onOpenTabs(
46
+ change.before === null ? null : sources.before, change.after === null ? null : sources.after)}>{names.openTabs}</button>}
43
47
  </div>}
44
48
  {sources && view === 'blocks' ? <ProjectSourceComparison renderBlock={renderBlock!} locale={locale} fallback={text} loadAsset={loadAsset} locateBlock={locateBlock}
45
49
  before={change.before === null ? null : sources.before} after={change.after === null ? null : sources.after}/> : text}
package/src/filesystem.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  export class GitPanelError extends Error {
2
2
  constructor(public code: 'invalid' | 'changed' | 'large' | 'unsupported' | 'pullUnsafe' | 'diverged' |
3
3
  'session' | 'pushUnknown' | 'pushRefresh' | 'rollbackFailed' | 'remoteFailed' |
4
- 'saveFirst' | 'author' | 'nothingStaged' | 'commitMessage', message: string = code) {
4
+ 'saveFirst' | 'author' | 'nothingStaged' | 'commitMessage' |
5
+ 'switchUnsafe' | 'branchExists' | 'branchName' | 'locked', message: string = code) {
5
6
  super(message);
6
7
  this.name = 'GitPanelError';
7
8
  }
@@ -0,0 +1,233 @@
1
+ import React, {useEffect, useMemo, useRef, useState} from 'react';
2
+ import {layoutHistory} from './history-graph.js';
3
+ import type {GraphRow} from './history-graph.js';
4
+ import {labelsFor} from './messages.js';
5
+ import type {HistoryCommit} from './repository.js';
6
+
7
+ /** How a write action ended: `error` is the message to show when it did not run to the end. */
8
+ export type HistoryActionResult = {done: true} | {done: false; error?: string};
9
+ export interface HistoryGraphProps {
10
+ /** Commits newest first, each child above its parents, as `Repository.history` returns them page by page. */
11
+ commits: readonly HistoryCommit[];
12
+ /** The commit shown as selected. */
13
+ selected?: string | null;
14
+ onSelect(oid: string): void;
15
+ locale?: string;
16
+ /** While true, selecting and the write actions wait; focus and copying still work. */
17
+ busy?: boolean;
18
+ /** Offers **Show more** when there are more commits to read. */
19
+ hasMore?: boolean;
20
+ loadingMore?: boolean;
21
+ onShowMore?(): void;
22
+ /** Local branch names, so `origin/<name>` is offered as a new branch only when there is none yet. */
23
+ localBranches?: readonly string[];
24
+ /**
25
+ * Write actions. Each is offered only when given. The branch form stays open with the name until `onCreateBranch`
26
+ * resolves `{done: true}` (or nothing); `{done: false, error}` or a rejection shows the reason in the form.
27
+ */
28
+ onCreateBranch?(oid: string, name: string, checkout: boolean): void | Promise<void | HistoryActionResult>;
29
+ onSwitchBranch?(name: string): void | Promise<unknown>;
30
+ onCreateFromRemote?(name: string): void | Promise<unknown>;
31
+ }
32
+
33
+ /** Lane colors that read on light and dark backgrounds. */
34
+ export const laneColors = ['#2f7ed8', '#d9480f', '#2b9348', '#9c36b5', '#c2255c', '#0c8599', '#b7791f', '#5c7cfa'];
35
+ const column = 14, rowHeight = 44, middle = rowHeight / 2;
36
+ const x = (index: number) => column / 2 + index * column;
37
+
38
+ function Lanes({row, width}: {row: GraphRow; width: number}) {
39
+ return <svg className="twgit-graph-lanes" aria-hidden="true" focusable="false" width={width * column} height={rowHeight}
40
+ viewBox={`0 0 ${width * column} ${rowHeight}`}>
41
+ {row.segments.map((segment, index) => {
42
+ const [top, bottom] = segment.part === 'top' ? [0, middle] : [middle, rowHeight];
43
+ const from = x(segment.from), to = x(segment.to), bend = (top + bottom) / 2;
44
+ const d = from === to ? `M${from} ${top}V${bottom}` : `M${from} ${top}C${from} ${bend} ${to} ${bend} ${to} ${bottom}`;
45
+ return <path key={index} d={d} fill="none" stroke={laneColors[segment.color % laneColors.length]} strokeWidth={2}/>;
46
+ })}
47
+ <circle cx={x(row.column)} cy={middle} r={4.5} fill={laneColors[row.color % laneColors.length]}
48
+ stroke="Canvas" strokeWidth={1.5}/>
49
+ </svg>;
50
+ }
51
+
52
+ interface Action {id: string; label: string; run(): void}
53
+ type Menu = {oid: string; mode: 'menu' | 'create'};
54
+
55
+ /**
56
+ * The history as a graph: one row per commit with its lanes drawn in SVG, a button that selects the commit and a
57
+ * button that opens its actions. ArrowUp and ArrowDown move between commits, Home and End to the ends; Enter or
58
+ * Space selects; the context menu key or Shift+F10 opens the actions.
59
+ */
60
+ export function HistoryGraph(props: HistoryGraphProps) {
61
+ const {commits, selected = null, onSelect, locale = 'en', busy = false} = props;
62
+ const labels = labelsFor(locale);
63
+ const layout = useMemo(() => layoutHistory(commits), [commits]);
64
+ const rows = useRef<(HTMLButtonElement | null)[]>([]);
65
+ const menuButtons = useRef<(HTMLButtonElement | null)[]>([]);
66
+ const popup = useRef<HTMLDivElement>(null);
67
+ const [active, setActive] = useState<string | null>(null);
68
+ const [menu, setMenu] = useState<Menu | null>(null);
69
+ const [branchName, setBranchName] = useState('');
70
+ const [checkout, setCheckout] = useState(true);
71
+ const [status, setStatus] = useState('');
72
+ const [creating, setCreating] = useState(false);
73
+ const [createError, setCreateError] = useState('');
74
+ const mounted = useRef(true);
75
+ useEffect(() => () => {mounted.current = false;}, []);
76
+ const focusIndex = Math.max(0, commits.findIndex(commit => commit.oid === (active ?? selected)));
77
+ const locals = useMemo(() => new Set(props.localBranches ??
78
+ commits.flatMap(commit => commit.refs.filter(ref => ref.kind === 'branch').map(ref => ref.name))), [commits, props.localBranches]);
79
+ useEffect(() => {
80
+ const element = popup.current;
81
+ if (!element) return;
82
+ element.querySelector<HTMLElement>(menu?.mode === 'create' ? 'input' : '[role="menuitem"]')?.focus();
83
+ }, [menu?.oid, menu?.mode]);
84
+ // After a refused create, the name is selected for correcting.
85
+ useEffect(() => {
86
+ if (!createError || creating) return;
87
+ const input = popup.current?.querySelector('input:not([type])') as HTMLInputElement | null;
88
+ input?.focus();
89
+ input?.select();
90
+ }, [createError, creating]);
91
+ const date = (timestamp: number) => new Date(timestamp * 1000).toLocaleString(locale, {dateStyle: 'medium', timeStyle: 'short'});
92
+ const refName = (ref: HistoryCommit['refs'][number]) => ref.current && ref.kind === 'branch' ? `${ref.name} (${labels.currentBranch})` : ref.name;
93
+ const closeMenu = (focus: 'menu' | 'row' | false) => {
94
+ const index = commits.findIndex(commit => commit.oid === menu?.oid);
95
+ setMenu(null);
96
+ if (focus && index !== -1) (focus === 'menu' ? menuButtons : rows).current[index]?.focus();
97
+ };
98
+ const actionsFor = (commit: HistoryCommit): Action[] => {
99
+ const actions: Action[] = [];
100
+ if (props.onCreateBranch) actions.push({id: 'create', label: labels.historyCreateHere, run: () => {
101
+ setBranchName('');
102
+ setCheckout(true);
103
+ setCreateError('');
104
+ setMenu({oid: commit.oid, mode: 'create'});
105
+ }});
106
+ for (const ref of commit.refs) {
107
+ if (ref.kind === 'branch' && !ref.current && props.onSwitchBranch) {
108
+ const run = props.onSwitchBranch;
109
+ actions.push({id: 'switch:' + ref.name, label: labels.historySwitch.replace('{name}', ref.name),
110
+ run: () => {closeMenu('row'); void run(ref.name);}});
111
+ }
112
+ const name = ref.name.slice('origin/'.length);
113
+ if (ref.kind === 'remote' && !locals.has(name) && props.onCreateFromRemote) {
114
+ const run = props.onCreateFromRemote;
115
+ actions.push({id: 'remote:' + name, label: labels.historyCreateFromRemote.replace('{name}', name),
116
+ run: () => {closeMenu('row'); void run(name);}});
117
+ }
118
+ }
119
+ actions.push({id: 'copy', label: labels.historyCopy, run: () => {
120
+ closeMenu('menu');
121
+ const clipboard = globalThis.navigator?.clipboard;
122
+ Promise.resolve().then(() => clipboard!.writeText(commit.oid))
123
+ .then(() => setStatus(labels.historyCopied), () => setStatus(labels.historyCopyFailed));
124
+ }});
125
+ return actions;
126
+ };
127
+ const onRowKey = (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
128
+ const target = {ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: commits.length - 1}[event.key];
129
+ if (target !== undefined) {
130
+ event.preventDefault();
131
+ rows.current[Math.min(commits.length - 1, Math.max(0, target))]?.focus();
132
+ } else if (event.key === 'ContextMenu' || (event.key === 'F10' && event.shiftKey)) {
133
+ event.preventDefault();
134
+ setMenu({oid: commits[index].oid, mode: 'menu'});
135
+ }
136
+ };
137
+ const onMenuKey = (event: React.KeyboardEvent<HTMLElement>) => {
138
+ if (event.key === 'Escape') {
139
+ event.preventDefault();
140
+ event.stopPropagation();
141
+ if (!creating) closeMenu('menu');
142
+ return;
143
+ }
144
+ if (menu?.mode !== 'menu') return;
145
+ if (event.key === 'Tab') {setMenu(null); return;}
146
+ const items = [...event.currentTarget.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)')];
147
+ const index = items.indexOf(event.target as HTMLElement);
148
+ const target = {ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: items.length - 1}[event.key];
149
+ if (target === undefined || !items.length) return;
150
+ event.preventDefault();
151
+ items[(target + items.length) % items.length].focus();
152
+ };
153
+ // Closes the menu when focus leaves it and its button, such as on a click elsewhere.
154
+ const onMenuBlur = (event: React.FocusEvent<HTMLElement>) => {
155
+ const next = event.relatedTarget as Node | null;
156
+ if (!next || !event.currentTarget.parentElement?.contains(next)) setMenu(null);
157
+ };
158
+ const submitBranch = async (event: React.FormEvent, oid: string) => {
159
+ event.preventDefault();
160
+ const name = branchName.trim();
161
+ if (!name || !props.onCreateBranch || creating) return;
162
+ setCreating(true);
163
+ setCreateError('');
164
+ let result: void | HistoryActionResult;
165
+ try {
166
+ result = await props.onCreateBranch(oid, name, checkout);
167
+ } catch (error) {
168
+ result = {done: false, error: error instanceof Error && error.message ? error.message : labels.failed};
169
+ }
170
+ if (!mounted.current) return;
171
+ setCreating(false);
172
+ if (!result || result.done) closeMenu('menu');
173
+ else setCreateError(result.error ?? '');
174
+ };
175
+ rows.current.length = commits.length;
176
+ menuButtons.current.length = commits.length;
177
+ return <div className="twgit-graph">
178
+ <ol className="twgit-graph-list">{commits.map((commit, index) => {
179
+ const row = layout.rows[index];
180
+ const refs = commit.refs.map(refName);
181
+ const name = [commit.message, commit.author, date(commit.timestamp),
182
+ ...(refs.length ? [`${labels.historyRefs}: ${refs.join(', ')}`] : [])].join(', ');
183
+ const open = menu?.oid === commit.oid ? menu : null;
184
+ const actions = open?.mode === 'menu' ? actionsFor(commit) : [];
185
+ return <li key={commit.oid} className="twgit-graph-row">
186
+ <Lanes row={row} width={layout.width}/>
187
+ <button type="button" className="twgit-graph-commit" ref={element => {rows.current[index] = element;}}
188
+ tabIndex={index === focusIndex ? 0 : -1} aria-pressed={selected === commit.oid} aria-disabled={busy || undefined}
189
+ aria-label={name} title={commit.oid} data-oid={commit.oid}
190
+ onFocus={() => setActive(commit.oid)} onKeyDown={event => onRowKey(event, index)}
191
+ onClick={() => {if (!busy) onSelect(commit.oid);}}>
192
+ <span className="twgit-graph-subject">{commit.refs.map(ref => <span key={ref.kind + ref.name}
193
+ className={`twgit-graph-ref twgit-graph-ref-${ref.kind}${ref.current ? ' twgit-graph-ref-current' : ''}`}>
194
+ {refName(ref)}</span>)}{commit.message}</span>
195
+ <span className="twgit-graph-meta">{commit.author} · {date(commit.timestamp)}</span>
196
+ </button>
197
+ <button type="button" className="twgit-graph-menu-button" ref={element => {menuButtons.current[index] = element;}}
198
+ tabIndex={index === focusIndex || open ? 0 : -1} aria-haspopup="menu" aria-expanded={open?.mode === 'menu'}
199
+ aria-label={`${labels.historyActions}: ${commit.message}`}
200
+ onClick={() => setMenu(open ? null : {oid: commit.oid, mode: 'menu'})}
201
+ onKeyDown={event => {
202
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
203
+ event.preventDefault();
204
+ setMenu({oid: commit.oid, mode: 'menu'});
205
+ }
206
+ }}>⋯</button>
207
+ {open?.mode === 'menu' && <div ref={popup} className="twgit-graph-popup" role="menu" aria-label={labels.historyActions}
208
+ onKeyDown={onMenuKey} onBlur={onMenuBlur}>
209
+ {actions.map(action => <button key={action.id} type="button" role="menuitem" tabIndex={-1} data-action={action.id}
210
+ disabled={busy && action.id !== 'copy'} onClick={action.run}>{action.label}</button>)}
211
+ </div>}
212
+ {open?.mode === 'create' && <div ref={popup} className="twgit-graph-popup" role="dialog"
213
+ aria-label={labels.historyCreateHere} onKeyDown={onMenuKey}>
214
+ <form onSubmit={event => void submitBranch(event, commit.oid)}>
215
+ <label>{labels.newBranch} <input value={branchName} required disabled={creating}
216
+ aria-invalid={createError ? true : undefined}
217
+ onChange={event => setBranchName(event.currentTarget.value)}/></label>
218
+ <label><input type="checkbox" checked={checkout} disabled={creating}
219
+ onChange={event => setCheckout(event.currentTarget.checked)}/> {labels.switchAfterCreate}</label>
220
+ {createError && <p className="twgit-graph-form-error" role="alert">{createError}</p>}
221
+ <span className="twgit-graph-form-actions">
222
+ <button type="submit" disabled={creating || busy || !branchName.trim()}>{labels.createBranch}</button>
223
+ <button type="button" disabled={creating} onClick={() => closeMenu('menu')}>{labels.cancel}</button>
224
+ </span>
225
+ </form>
226
+ </div>}
227
+ </li>;
228
+ })}</ol>
229
+ {props.hasMore && props.onShowMore && <button type="button" className="twgit-graph-more"
230
+ disabled={props.loadingMore} onClick={props.onShowMore}>{props.loadingMore ? labels.historyLoading : labels.historyMore}</button>}
231
+ <p className="twgit-graph-status" role="status">{status}</p>
232
+ </div>;
233
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Lays out a commit graph in lanes, top to bottom, after the approach DoltHub describes in "Drawing a commit graph"
3
+ * (https://www.dolthub.com/blog/2024-08-07-drawing-a-commit-graph/): each lane waits for one commit; a commit takes
4
+ * the leftmost lane that waits for it, or the first free lane; its first parent continues that lane and each further
5
+ * parent gets the lane that already waits for it, or a free one. Lanes whose commit arrived elsewhere end there and
6
+ * are reused by later commits.
7
+ */
8
+
9
+ /** The parts of a commit the layout needs. Commits come newest first, each child above its parents. */
10
+ export interface GraphCommit {
11
+ oid: string;
12
+ parents: readonly string[];
13
+ }
14
+ /**
15
+ * A line in one row's band, between columns. `top` runs from the row's upper edge to the dot's height; `bottom`
16
+ * from the dot's height to the lower edge. `from` is the upper end's column and `to` the lower end's.
17
+ */
18
+ export interface GraphSegment {
19
+ part: 'top' | 'bottom';
20
+ from: number;
21
+ to: number;
22
+ color: number;
23
+ }
24
+ export interface GraphRow {
25
+ oid: string;
26
+ /** The commit's column. */
27
+ column: number;
28
+ /** The color of the commit's lane, an index into a palette (see `laneColors`). */
29
+ color: number;
30
+ segments: GraphSegment[];
31
+ /** Columns this row's band uses: one more than the rightmost lane drawn in it. */
32
+ width: number;
33
+ }
34
+ export interface GraphLayout {
35
+ rows: GraphRow[];
36
+ /** The widest row, for drawing every row with the same width. */
37
+ width: number;
38
+ }
39
+
40
+ /** How many lane colors there are. Lanes take them in turn. */
41
+ export const laneColorCount = 8;
42
+
43
+ interface Lane {oid: string; color: number}
44
+
45
+ /**
46
+ * Lays out commits ordered newest first with every child above its parents. Parents missing from `commits`, such as
47
+ * those on a page not loaded yet or cut off by a shallow clone, keep their lane running to the bottom.
48
+ */
49
+ export function layoutHistory(commits: readonly GraphCommit[]): GraphLayout {
50
+ const lanes: (Lane | null)[] = [];
51
+ let nextColor = 0;
52
+ const free = () => {
53
+ const index = lanes.indexOf(null);
54
+ return index === -1 ? lanes.length : index;
55
+ };
56
+ const rows: GraphRow[] = [];
57
+ for (const commit of commits) {
58
+ const segments: GraphSegment[] = [];
59
+ const before = lanes.slice();
60
+ const waiting = before.flatMap((lane, index) => lane?.oid === commit.oid ? [index] : []);
61
+ const column = waiting.length ? waiting[0] : free();
62
+ const color = waiting.length ? before[column]!.color : nextColor++ % laneColorCount;
63
+ for (const [index, lane] of before.entries()) {
64
+ if (!lane) continue;
65
+ // Lanes that waited for this commit end in its dot; the others pass by.
66
+ segments.push({part: 'top', from: index, to: lane.oid === commit.oid ? column : index, color: lane.color});
67
+ }
68
+ for (const index of waiting) lanes[index] = null;
69
+ const parents = [...new Set(commit.parents)];
70
+ lanes[column] = parents.length ? {oid: parents[0], color} : null;
71
+ const joins: {index: number; color: number}[] = parents.length ? [{index: column, color}] : [];
72
+ for (const parent of parents.slice(1)) {
73
+ let index = lanes.findIndex(lane => lane?.oid === parent);
74
+ if (index === -1) {
75
+ index = free();
76
+ lanes[index] = {oid: parent, color: nextColor++ % laneColorCount};
77
+ }
78
+ joins.push({index, color: lanes[index]!.color});
79
+ }
80
+ while (lanes.length && lanes[lanes.length - 1] === null) lanes.pop();
81
+ for (const [index, lane] of before.entries()) {
82
+ // Lanes waiting for other commits are never reassigned here, so they pass straight by.
83
+ if (lane && lane.oid !== commit.oid) segments.push({part: 'bottom', from: index, to: index, color: lane.color});
84
+ }
85
+ for (const join of joins) segments.push({part: 'bottom', from: column, to: join.index, color: join.color});
86
+ const width = Math.max(column + 1, ...segments.map(segment => Math.max(segment.from, segment.to) + 1));
87
+ rows.push({oid: commit.oid, column, color, segments, width});
88
+ }
89
+ return {rows, width: Math.max(0, ...rows.map(row => row.width))};
90
+ }