@kubohiroya/turbowarp-plus-editor-git 0.14.1 → 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/README.ja.md +7 -3
- package/README.md +7 -3
- package/dist/file-diff-view.d.ts +3 -1
- package/dist/filesystem.d.ts +2 -2
- package/dist/history-graph-view.d.ts +40 -0
- package/dist/history-graph.d.ts +44 -0
- package/dist/index.d.ts +27 -1
- package/dist/index.js +778 -32
- package/dist/local-operations.d.ts +36 -0
- package/dist/messages.d.ts +74 -0
- package/dist/{project-comparison-LAyP_beL.js → project-comparison-MU0qD2wo.js} +213 -16
- package/dist/project-source-diff.d.ts +2 -0
- package/dist/project-source-diff.js +2 -2
- package/dist/project-source-review.d.ts +43 -0
- package/dist/project-source-tabs.d.ts +35 -0
- package/dist/pull-request-panel.d.ts +6 -1
- package/dist/{repository-CtvWYPQ8.js → repository-BYno-5f8.js} +927 -5
- package/dist/repository.d.ts +47 -0
- package/dist/repository.js +1 -1
- package/dist/style.css +30 -3
- package/package.json +1 -1
- package/src/file-diff-view.tsx +7 -3
- package/src/filesystem.ts +3 -1
- package/src/history-graph-view.tsx +233 -0
- package/src/history-graph.ts +90 -0
- package/src/index.tsx +175 -15
- package/src/local-operations.ts +134 -0
- package/src/messages.ts +40 -2
- package/src/project-source-diff.tsx +2 -0
- package/src/project-source-review.tsx +127 -15
- package/src/project-source-tabs.tsx +118 -0
- package/src/pull-request-panel.tsx +26 -5
- package/src/repository.ts +160 -1
- package/src/style.css +30 -3
package/dist/repository.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LocalOperation } from './local-operations.js';
|
|
1
2
|
import type { GetRemoteSession, RemoteOperation } from './remote-operations.js';
|
|
2
3
|
import type { ProjectAssetLoader } from './project-assets.js';
|
|
3
4
|
export { createReadOnlyFs, GitPanelError } from './filesystem.js';
|
|
@@ -29,11 +30,42 @@ export type ProjectComparison = {
|
|
|
29
30
|
kind: 'commit';
|
|
30
31
|
commit: string;
|
|
31
32
|
};
|
|
33
|
+
/** A ref drawn next to a commit in the history: a local branch, `origin/<name>`, or HEAD when it is detached. */
|
|
34
|
+
export interface HistoryRef {
|
|
35
|
+
kind: 'branch' | 'remote' | 'head';
|
|
36
|
+
/** The branch name; `origin/<name>` for remote branches; `HEAD` for a detached HEAD. */
|
|
37
|
+
name: string;
|
|
38
|
+
/** The branch HEAD is on, or the detached HEAD. */
|
|
39
|
+
current: boolean;
|
|
40
|
+
}
|
|
41
|
+
export interface HistoryCommit {
|
|
42
|
+
oid: string;
|
|
43
|
+
parents: string[];
|
|
44
|
+
/** The first line of the message. */
|
|
45
|
+
message: string;
|
|
46
|
+
author: string;
|
|
47
|
+
/** Author time in seconds. */
|
|
48
|
+
timestamp: number;
|
|
49
|
+
refs: HistoryRef[];
|
|
50
|
+
}
|
|
51
|
+
/** Where `history` continues. It only works with the repository that returned it. */
|
|
52
|
+
export type HistoryCursor = {
|
|
53
|
+
readonly continues: 'history';
|
|
54
|
+
};
|
|
55
|
+
export interface HistoryPage {
|
|
56
|
+
commits: HistoryCommit[];
|
|
57
|
+
/** Pass as `after` to read the next page; `null` when every commit has been read. */
|
|
58
|
+
cursor: HistoryCursor | null;
|
|
59
|
+
}
|
|
60
|
+
/** Commits read per history page unless asked otherwise. */
|
|
61
|
+
export declare const historyPageSize = 50;
|
|
32
62
|
type Metadata = Map<string, Uint8Array | null>;
|
|
33
63
|
export interface Snapshot {
|
|
34
64
|
branch: string | undefined;
|
|
35
65
|
head: string | null;
|
|
36
66
|
branches: string[];
|
|
67
|
+
/** Branches fetched from `origin` that have no local branch of the same name yet. */
|
|
68
|
+
remoteBranches: string[];
|
|
37
69
|
commits: Commit[];
|
|
38
70
|
changes: FileChange[];
|
|
39
71
|
scope: string;
|
|
@@ -84,9 +116,24 @@ export declare function openRepository(root: FileSystemDirectoryHandle): Promise
|
|
|
84
116
|
probe: (scope?: string) => Promise<string>;
|
|
85
117
|
readOrigin: () => Promise<any>;
|
|
86
118
|
remoteOperation: (operation: RemoteOperation, getSession: GetRemoteSession, guard: () => void, signal?: AbortSignal) => Promise<void>;
|
|
119
|
+
/** Stages, unstages or commits; see `runLocalOperation`. `guard` throws to stop before each write. */
|
|
120
|
+
localOperation: (operation: LocalOperation, guard: () => void) => Promise<void>;
|
|
121
|
+
/**
|
|
122
|
+
* Whether a local branch, `origin/<name>` with `remote`, or the commit `name` with `remote` set to `'commit'`,
|
|
123
|
+
* has the folder `path` (the root always exists).
|
|
124
|
+
*/
|
|
125
|
+
hasFolder: (name: string, path: string, remote?: boolean | "commit") => Promise<boolean>;
|
|
87
126
|
read: (scope?: string) => Promise<Snapshot>;
|
|
88
127
|
readDiff: (snapshot: Snapshot, change: FileChange) => Promise<FileDiff>;
|
|
89
128
|
historyChanges: (snapshot: Snapshot, oid: string) => Promise<FileChange[]>;
|
|
129
|
+
/**
|
|
130
|
+
* Reads one page of the commits reachable from the local branches, `origin/*` and a detached HEAD, newest first
|
|
131
|
+
* with each child above its parents, and the refs at each. Pass the returned `cursor` as `after` for the next page.
|
|
132
|
+
*/
|
|
133
|
+
history: (options?: {
|
|
134
|
+
limit?: number;
|
|
135
|
+
after?: HistoryCursor | null;
|
|
136
|
+
}) => Promise<HistoryPage>;
|
|
90
137
|
/**
|
|
91
138
|
* Reads the assets of both versions of a selected project file, for thumbnails. Staged changes compare HEAD with
|
|
92
139
|
* the index, unstaged ones the index with the working tree, untracked ones nothing with the working tree, and
|
package/dist/repository.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { i as GitPanelError, a as createReadOnlyFs, t as historyPageSize, n as openRepository, r as upstreamWalkLimit } from "./repository-BYno-5f8.js";
|
package/dist/style.css
CHANGED
|
@@ -148,11 +148,14 @@
|
|
|
148
148
|
.twgit-panel aside li { overflow-wrap: anywhere; }
|
|
149
149
|
.twgit-panel summary { cursor: pointer; }
|
|
150
150
|
.twgit-panel .twgit-file { width: 100%; border: 0; margin: 1px 0; }
|
|
151
|
+
.twgit-panel .twgit-file-row { display: flex; gap: 6px; align-items: center; }
|
|
152
|
+
.twgit-panel .twgit-file-row .twgit-file { flex: 1; min-width: 0; width: auto; text-align: start; }
|
|
153
|
+
.twgit-panel .twgit-stage { flex: none; font-size: 11px; margin: 0; white-space: nowrap; }
|
|
154
|
+
.twgit-commit { display: grid; gap: 6px; margin: 10px 0; padding: 8px; border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas); border-radius: 6px; }
|
|
155
|
+
.twgit-commit label { display: grid; gap: 4px; font-size: 12px; }
|
|
156
|
+
.twgit-commit textarea { font: inherit; resize: vertical; min-height: 3em; }
|
|
151
157
|
.twgit-diff { padding: 0 16px 16px; min-width: 0; overflow: auto; }
|
|
152
158
|
.twgit-diff .diff { font-size: 12px; }
|
|
153
|
-
.twgit-history { list-style: none; margin: 0; padding: 0; }
|
|
154
|
-
.twgit-history li { padding: 5px 0; }
|
|
155
|
-
.twgit-history small { display: block; font-size: 11px; }
|
|
156
159
|
.twgit-panel > footer { font-size: 11px; padding: 8px 16px; border-top: 1px solid GrayText; }
|
|
157
160
|
.twgit-error { margin: 6px 16px; color: #a11919; padding: 8px; background: #fff0f0; }
|
|
158
161
|
@media (max-width: 700px) { .twgit-content { grid-template-columns: minmax(180px, 42%) minmax(0, 1fr); } .twgit-panel > header { flex-wrap: wrap; } }
|
|
@@ -183,4 +186,28 @@
|
|
|
183
186
|
.twgit-comparison-body > p { margin: 0 0 8px; }
|
|
184
187
|
.twgit-comparison-body > section { margin-top: 16px; }
|
|
185
188
|
.twgit-comparison-actions { display: flex; gap: 8px; justify-content: flex-end; padding: 10px 16px; border-top: 1px solid GrayText; }
|
|
189
|
+
.twgit-branches ul { list-style: none; padding: 0; margin: 4px 0; }
|
|
190
|
+
.twgit-branches li { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 2px 0; }
|
|
191
|
+
.twgit-branches form { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 6px 0; }
|
|
192
|
+
.twgit-graph-list { list-style: none; margin: 0; padding: 0; }
|
|
193
|
+
.twgit-graph-row { position: relative; display: flex; align-items: stretch; gap: 4px; height: 44px; }
|
|
194
|
+
.twgit-graph-lanes { flex: none; display: block; }
|
|
195
|
+
.twgit-panel .twgit-graph-commit, .twgit-graph-commit { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: center;
|
|
196
|
+
gap: 1px; margin: 2px 0; padding: 2px 6px; border: 0; text-align: start; }
|
|
197
|
+
.twgit-graph-subject, .twgit-graph-meta { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
198
|
+
.twgit-graph-meta { font-size: 11px; opacity: .8; }
|
|
199
|
+
.twgit-graph-ref { display: inline-block; margin-inline-end: 4px; padding: 0 5px; font-size: 11px; line-height: 16px; border-radius: 8px;
|
|
200
|
+
border: 1px solid color-mix(in srgb, CanvasText 35%, Canvas); }
|
|
201
|
+
.twgit-graph-ref-current { font-weight: 600; border-color: currentColor; }
|
|
202
|
+
.twgit-graph-ref-remote { border-style: dashed; }
|
|
203
|
+
.twgit-panel .twgit-graph-menu-button, .twgit-graph-menu-button { flex: none; align-self: center; padding: 0 6px; line-height: 22px; }
|
|
204
|
+
.twgit-graph-popup { position: absolute; z-index: 6; top: 100%; right: 0; display: grid; gap: 2px; min-width: 200px; padding: 6px;
|
|
205
|
+
background: Canvas; color: CanvasText; border: 1px solid GrayText; border-radius: 6px; box-shadow: 0 8px 24px #0004; }
|
|
206
|
+
.twgit-panel .twgit-graph-popup [role="menuitem"], .twgit-graph-popup [role="menuitem"] { border: 0; text-align: start; }
|
|
207
|
+
.twgit-graph-popup form { display: grid; gap: 6px; font-size: 12px; }
|
|
208
|
+
.twgit-graph-form-actions { display: flex; gap: 6px; justify-content: flex-end; }
|
|
209
|
+
.twgit-graph-more { margin: 6px 0; }
|
|
210
|
+
.twgit-graph-status { margin: 4px 0; font-size: 11px; min-height: 1em; }
|
|
211
|
+
.twgit-panel .twgit-graph-popup [role="menuitem"]:focus-visible, .twgit-graph-popup [role="menuitem"]:focus-visible { outline-offset: -2px; }
|
|
212
|
+
.twgit-graph-form-error { margin: 0; color: #a11919; overflow-wrap: anywhere; }
|
|
186
213
|
/*$vite$:1*/
|
package/package.json
CHANGED
package/src/file-diff-view.tsx
CHANGED
|
@@ -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,6 +1,8 @@
|
|
|
1
1
|
export class GitPanelError extends Error {
|
|
2
2
|
constructor(public code: 'invalid' | 'changed' | 'large' | 'unsupported' | 'pullUnsafe' | 'diverged' |
|
|
3
|
-
'session' | 'pushUnknown' | 'pushRefresh' | 'rollbackFailed' | 'remoteFailed'
|
|
3
|
+
'session' | 'pushUnknown' | 'pushRefresh' | 'rollbackFailed' | 'remoteFailed' |
|
|
4
|
+
'saveFirst' | 'author' | 'nothingStaged' | 'commitMessage' |
|
|
5
|
+
'switchUnsafe' | 'branchExists' | 'branchName' | 'locked', message: string = code) {
|
|
4
6
|
super(message);
|
|
5
7
|
this.name = 'GitPanelError';
|
|
6
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
|
+
}
|