@kubohiroya/turbowarp-plus-editor-git 0.15.0 → 0.16.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/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.0",
4
4
  "description": "Read-only local Git panel for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "MPL-2.0",
@@ -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
+ }