@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/README.ja.md +5 -3
- package/README.md +5 -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 +18 -1
- package/dist/index.js +693 -30
- package/dist/local-operations.d.ts +14 -0
- package/dist/messages.d.ts +52 -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-Cx-mxT9l.js → repository-BYno-5f8.js} +491 -4
- package/dist/repository.d.ts +44 -0
- package/dist/repository.js +1 -1
- package/dist/style.css +27 -4
- package/package.json +1 -1
- package/src/file-diff-view.tsx +7 -3
- package/src/filesystem.ts +2 -1
- package/src/history-graph-view.tsx +233 -0
- package/src/history-graph.ts +90 -0
- package/src/index.tsx +135 -16
- package/src/local-operations.ts +59 -4
- package/src/messages.ts +26 -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 +155 -1
- package/src/style.css +27 -4
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import React, {useEffect, useRef, useState} from 'react';
|
|
2
|
+
import {render, unmountComponentAtNode} from 'react-dom';
|
|
3
|
+
import {compareProjectForReview} from './project-source-review-model.js';
|
|
4
|
+
import type {TargetReview} from './project-source-review-model.js';
|
|
5
|
+
import {reviewTabWords, ScriptReviewTab, TargetReviewTab} from './project-source-review.js';
|
|
6
|
+
import type {ProjectSource} from './project-source-patch.js';
|
|
7
|
+
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
8
|
+
import type {ProjectAssetLoader} from './project-assets.js';
|
|
9
|
+
import type {ProjectBlockLocator} from './block-diff-location.js';
|
|
10
|
+
import type {GitPanelRequest} from './index.js';
|
|
11
|
+
|
|
12
|
+
/** One project.source.json of a comparison, read into both versions. */
|
|
13
|
+
export interface ProjectSourceTabsFile {
|
|
14
|
+
path: string;
|
|
15
|
+
before: ProjectSource;
|
|
16
|
+
after: ProjectSource;
|
|
17
|
+
loadAsset?: ProjectAssetLoader;
|
|
18
|
+
/** Offers "Highlight in Editor" on the after version, which the caller has matched with the open project. */
|
|
19
|
+
locateBlock?: ProjectBlockLocator;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ProjectSourceTabsOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Names this comparison, such as `owner/repo#42@<head>` or `<commit>:project.source.json`. Its tabs share one
|
|
25
|
+
* split, and opening the same comparison again brings its tabs forward instead of adding them twice.
|
|
26
|
+
*/
|
|
27
|
+
key: string;
|
|
28
|
+
files: readonly ProjectSourceTabsFile[];
|
|
29
|
+
renderBlock: ProjectBlockRenderer;
|
|
30
|
+
locale?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Shares the reviewed marks of the script tabs with the review of this name, such as `owner/repo#42`, and keeps
|
|
33
|
+
* them in the browser. Without it, the marks are kept for this comparison while the page is open.
|
|
34
|
+
*/
|
|
35
|
+
rememberAs?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const providerId = 'turbowarp-plus-git';
|
|
39
|
+
// The chip colour says what happened to the stage or sprite, as the review's status words do.
|
|
40
|
+
const statusColor = {added: 'green', removed: 'red', updated: 'blue'} as const;
|
|
41
|
+
|
|
42
|
+
/** Renders its content once the tab is first shown, so a comparison with many scripts draws only what is looked at. */
|
|
43
|
+
function WhenShown({children}: {children: React.ReactNode}) {
|
|
44
|
+
const element = useRef<HTMLDivElement>(null);
|
|
45
|
+
const [shown, setShown] = useState(false);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const view = element.current?.ownerDocument.defaultView as (Window & typeof globalThis) | null;
|
|
48
|
+
if (shown || !element.current) return;
|
|
49
|
+
if (!view?.IntersectionObserver) {
|
|
50
|
+
setShown(true);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const observer = new view.IntersectionObserver(entries => {
|
|
54
|
+
if (entries.some(entry => entry.isIntersecting)) setShown(true);
|
|
55
|
+
});
|
|
56
|
+
observer.observe(element.current);
|
|
57
|
+
return () => observer.disconnect();
|
|
58
|
+
}, [shown]);
|
|
59
|
+
return <div ref={element} className="twgit-review-tab-body">{shown && children}</div>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Opens a comparison of project.source.json files as tabs of the host's workspace: a tab group for each changed
|
|
64
|
+
* stage or sprite, holding a tab for each changed script and one for its changes outside the code.
|
|
65
|
+
* Returns how many tabs were asked for, or `0` when the host has no workspace to take them.
|
|
66
|
+
*/
|
|
67
|
+
export function openProjectSourceTabs(openPanel: ((request: GitPanelRequest) => boolean) | undefined,
|
|
68
|
+
{key, files, renderBlock, locale = 'en', rememberAs}: ProjectSourceTabsOptions): number {
|
|
69
|
+
if (!openPanel) return 0;
|
|
70
|
+
const words = reviewTabWords(locale);
|
|
71
|
+
const area = `${providerId}:diff:${key}`;
|
|
72
|
+
const requests: GitPanelRequest[] = [];
|
|
73
|
+
const tab = (documentId: string, title: string, content: () => React.ReactElement,
|
|
74
|
+
tabGroup?: GitPanelRequest['tabGroup']): GitPanelRequest => ({
|
|
75
|
+
reference: {providerId, documentId: `diff:${key}:${documentId}`},
|
|
76
|
+
filename: title,
|
|
77
|
+
title,
|
|
78
|
+
transient: true,
|
|
79
|
+
area,
|
|
80
|
+
tabGroup,
|
|
81
|
+
inactive: requests.length > 0,
|
|
82
|
+
mount(element) {
|
|
83
|
+
element.classList.add('twgit-panel-tab', 'twgit-review-tab-slot');
|
|
84
|
+
render(<WhenShown>{content()}</WhenShown>, element);
|
|
85
|
+
return {requestClose: () => {}, dispose: () => { unmountComponentAtNode(element); }};
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
const comparison = compareProjectForReview(file.before, file.after);
|
|
90
|
+
const prefix = files.length > 1 ? file.path.split('/').slice(0, -1).join('/') : '';
|
|
91
|
+
const labelOf = (target: TargetReview) => prefix ? `${prefix}: ${words.target(target)}` : words.target(target);
|
|
92
|
+
if (comparison.properties.length || !comparison.targets.length) {
|
|
93
|
+
const title = prefix ? `${prefix}: ${words.project}` : words.project;
|
|
94
|
+
requests.push(tab(`${file.path}:project`, title, () => <TargetReviewTab comparison={comparison} locale={locale}/>));
|
|
95
|
+
}
|
|
96
|
+
for (const target of comparison.targets) {
|
|
97
|
+
const targetKey = JSON.stringify([file.path, target.isStage, target.name]);
|
|
98
|
+
const tabGroup = {id: `${area}:${targetKey}`, label: labelOf(target), color: statusColor[target.status]};
|
|
99
|
+
if (target.status !== 'updated' || target.properties.length || !target.scripts.length) {
|
|
100
|
+
// The chip names the stage or sprite, so its tabs name only what they show.
|
|
101
|
+
requests.push(tab(`${targetKey}:details`, words.targetDetails,
|
|
102
|
+
() => <TargetReviewTab comparison={comparison} target={target} locale={locale} loadAsset={file.loadAsset}/>, tabGroup));
|
|
103
|
+
}
|
|
104
|
+
for (const script of target.scripts) {
|
|
105
|
+
requests.push(tab(`${targetKey}:${script.id}`, words.script(script),
|
|
106
|
+
() => <ScriptReviewTab comparison={comparison} script={script} renderBlock={renderBlock} locale={locale}
|
|
107
|
+
loadAsset={file.loadAsset} locateBlock={file.locateBlock}
|
|
108
|
+
mark={{name: rememberAs ?? area, remember: !!rememberAs, file: file.path, target}}/>, tabGroup));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
let opened = 0;
|
|
113
|
+
for (const request of requests) {
|
|
114
|
+
if (!openPanel(request)) break;
|
|
115
|
+
opened++;
|
|
116
|
+
}
|
|
117
|
+
return opened;
|
|
118
|
+
}
|
|
@@ -5,6 +5,10 @@ import {ProjectSourceReview} from './project-source-review.js';
|
|
|
5
5
|
import type {ProjectBlockRenderer} from './scratch-block-diff-renderer.js';
|
|
6
6
|
import type {Repository} from './repository.js';
|
|
7
7
|
import type {ProjectBlockLocator} from './block-diff-location.js';
|
|
8
|
+
import {readProjectSourceDiff} from './project-source-patch.js';
|
|
9
|
+
import type {ProjectSourceTabsFile} from './project-source-tabs.js';
|
|
10
|
+
|
|
11
|
+
type OpenTabs = (key: string, files: readonly ProjectSourceTabsFile[], rememberAs?: string) => number;
|
|
8
12
|
|
|
9
13
|
const en = {
|
|
10
14
|
title: 'Pull requests', number: 'Pull request number', openNumber: 'Open pull request',
|
|
@@ -15,7 +19,8 @@ const en = {
|
|
|
15
19
|
unavailable: 'GitHub could not read this repository or pull request. The panel supports public repositories.',
|
|
16
20
|
rateLimit: 'The public GitHub API rate limit was reached. Try again after the limit resets.',
|
|
17
21
|
failed: 'Could not read GitHub. Try again.', changed: 'The pull request changed while it was being read. Open it again.',
|
|
18
|
-
tooManyFiles: 'GitHub only returns the first 3,000 changed files. This pull request exceeds that limit.'
|
|
22
|
+
tooManyFiles: 'GitHub only returns the first 3,000 changed files. This pull request exceeds that limit.',
|
|
23
|
+
openTabs: 'Open as tabs'
|
|
19
24
|
};
|
|
20
25
|
const ja: typeof en = {
|
|
21
26
|
title: 'プルリクエスト', number: 'プルリクエスト番号', openNumber: 'PR を開く',
|
|
@@ -27,7 +32,8 @@ const ja: typeof en = {
|
|
|
27
32
|
rateLimit: '公開 GitHub API の利用上限に達しました。上限の解除後に再試行してください。',
|
|
28
33
|
failed: 'GitHub から取得できませんでした。再試行してください。',
|
|
29
34
|
changed: '取得中にプルリクエストが更新されました。もう一度開いてください。',
|
|
30
|
-
tooManyFiles: '変更ファイルが GitHub API の取得上限である 3,000 件を超えています。'
|
|
35
|
+
tooManyFiles: '変更ファイルが GitHub API の取得上限である 3,000 件を超えています。',
|
|
36
|
+
openTabs: 'タブで開く'
|
|
31
37
|
};
|
|
32
38
|
|
|
33
39
|
function errorMessage(error: unknown, labels: typeof en) {
|
|
@@ -38,9 +44,10 @@ type Result<T> = {data: T; error?: never} | {data?: never; error: string};
|
|
|
38
44
|
type Listing = Awaited<ReturnType<GitHubPullRequests['list']>>;
|
|
39
45
|
type Pull = Awaited<ReturnType<GitHubPullRequests['load']>>;
|
|
40
46
|
|
|
41
|
-
function PullRequests({name, labels, locale, renderBlock, locateFor}: {
|
|
47
|
+
function PullRequests({name, labels, locale, renderBlock, locateFor, openTabs}: {
|
|
42
48
|
name: string; labels: typeof en; locale: string; renderBlock: ProjectBlockRenderer;
|
|
43
49
|
locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
|
|
50
|
+
openTabs?: OpenTabs;
|
|
44
51
|
}) {
|
|
45
52
|
const client = useMemo(() => createGitHubPullRequests(name), [name]);
|
|
46
53
|
const [page, setPage] = useState(1);
|
|
@@ -109,6 +116,18 @@ function PullRequests({name, labels, locale, renderBlock, locateFor}: {
|
|
|
109
116
|
{pr.author && <span>{pr.author}</span>}
|
|
110
117
|
<code title={pr.head.sha}>{pr.head.ref} → {pr.base.ref}</code>
|
|
111
118
|
<a href={pr.url} target="_blank" rel="noreferrer">{labels.onGitHub}</a>
|
|
119
|
+
{openTabs && <button type="button" data-git-open-tabs="" onClick={() => openTabs(`${name}#${pr.number}@${pr.head.sha}`,
|
|
120
|
+
pr.files.flatMap(file => {
|
|
121
|
+
if (!('patch' in file)) return [];
|
|
122
|
+
let projects;
|
|
123
|
+
try {
|
|
124
|
+
projects = readProjectSourceDiff(file.patch, file.beforeSource);
|
|
125
|
+
} catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
return projects ? [{path: file.path, before: projects[0], after: projects[1], loadAsset: file.loadAsset,
|
|
129
|
+
locateBlock: locateFor?.(file.path, pr.head.sha)}] : [];
|
|
130
|
+
}), `${name}#${pr.number}`)}>{labels.openTabs}</button>}
|
|
112
131
|
</p>
|
|
113
132
|
<ProjectSourceReview title={pr.title} description={pr.description} files={locateFor ? pr.files.map(file => 'patch' in file ? {...file, locateBlock: locateFor(file.path, pr.head.sha)} : file) : pr.files}
|
|
114
133
|
renderBlock={renderBlock} locale={locale} rememberAs={`${name}#${pr.number}`}/>
|
|
@@ -117,8 +136,10 @@ function PullRequests({name, labels, locale, renderBlock, locateFor}: {
|
|
|
117
136
|
</div>;
|
|
118
137
|
}
|
|
119
138
|
|
|
120
|
-
export function PullRequestReviewPanel({repository, locale, renderBlock, locateFor}: {
|
|
139
|
+
export function PullRequestReviewPanel({repository, locale, renderBlock, locateFor, openTabs}: {
|
|
121
140
|
repository: Repository; locale: string; renderBlock: ProjectBlockRenderer;
|
|
141
|
+
/** Opens a pull request's project changes as tabs of the host's layout. */
|
|
142
|
+
openTabs?: OpenTabs;
|
|
122
143
|
/** Returns a locator for a project file whose PR head matches the open project, or `undefined`. */
|
|
123
144
|
locateFor?(path: string, headSha: string): ProjectBlockLocator | undefined;
|
|
124
145
|
}) {
|
|
@@ -135,5 +156,5 @@ export function PullRequestReviewPanel({repository, locale, renderBlock, locateF
|
|
|
135
156
|
}, [repository]);
|
|
136
157
|
if (origin?.repository !== repository) return <p role="status" className="twgit-context">{labels.loading}</p>;
|
|
137
158
|
if (!origin.name) return <p className="twgit-context" role="status">{origin.failed ? labels.failed : labels.noOrigin}</p>;
|
|
138
|
-
return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock} locateFor={locateFor}/>;
|
|
159
|
+
return <PullRequests key={origin.name} name={origin.name} labels={labels} locale={locale} renderBlock={renderBlock} locateFor={locateFor} openTabs={openTabs}/>;
|
|
139
160
|
}
|
package/src/repository.ts
CHANGED
|
@@ -35,12 +35,41 @@ type Source = {kind: 'tree'; ref: string} | {kind: 'index'} | {kind: 'worktree'}
|
|
|
35
35
|
* across the index and the working tree read as one; or one commit and its first parent.
|
|
36
36
|
*/
|
|
37
37
|
export type ProjectComparison = {kind: 'worktree'} | {kind: 'commit'; commit: string};
|
|
38
|
+
/** A ref drawn next to a commit in the history: a local branch, `origin/<name>`, or HEAD when it is detached. */
|
|
39
|
+
export interface HistoryRef {
|
|
40
|
+
kind: 'branch' | 'remote' | 'head';
|
|
41
|
+
/** The branch name; `origin/<name>` for remote branches; `HEAD` for a detached HEAD. */
|
|
42
|
+
name: string;
|
|
43
|
+
/** The branch HEAD is on, or the detached HEAD. */
|
|
44
|
+
current: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface HistoryCommit {
|
|
47
|
+
oid: string;
|
|
48
|
+
parents: string[];
|
|
49
|
+
/** The first line of the message. */
|
|
50
|
+
message: string;
|
|
51
|
+
author: string;
|
|
52
|
+
/** Author time in seconds. */
|
|
53
|
+
timestamp: number;
|
|
54
|
+
refs: HistoryRef[];
|
|
55
|
+
}
|
|
56
|
+
/** Where `history` continues. It only works with the repository that returned it. */
|
|
57
|
+
export type HistoryCursor = {readonly continues: 'history'};
|
|
58
|
+
export interface HistoryPage {
|
|
59
|
+
commits: HistoryCommit[];
|
|
60
|
+
/** Pass as `after` to read the next page; `null` when every commit has been read. */
|
|
61
|
+
cursor: HistoryCursor | null;
|
|
62
|
+
}
|
|
63
|
+
/** Commits read per history page unless asked otherwise. */
|
|
64
|
+
export const historyPageSize = 50;
|
|
38
65
|
const projectSourceLimit = 16 * 1024 * 1024;
|
|
39
66
|
type Metadata = Map<string, Uint8Array | null>;
|
|
40
67
|
export interface Snapshot {
|
|
41
68
|
branch: string | undefined;
|
|
42
69
|
head: string | null;
|
|
43
70
|
branches: string[];
|
|
71
|
+
/** Branches fetched from `origin` that have no local branch of the same name yet. */
|
|
72
|
+
remoteBranches: string[];
|
|
44
73
|
commits: Commit[];
|
|
45
74
|
changes: FileChange[];
|
|
46
75
|
scope: string;
|
|
@@ -260,6 +289,8 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
260
289
|
}
|
|
261
290
|
const branch = await git.currentBranch(options);
|
|
262
291
|
const branches = await git.listBranches(options);
|
|
292
|
+
const remoteBranches = (await git.listBranches({...options, remote: 'origin'}))
|
|
293
|
+
.filter(name => name !== 'HEAD' && !branches.includes(name)).sort();
|
|
263
294
|
const commits = head ? (await git.log({...options, cache, ref: head, depth: 50})).map(({oid, commit}) => ({
|
|
264
295
|
oid, message: commit.message, author: commit.author.name, timestamp: commit.author.timestamp
|
|
265
296
|
})) : [];
|
|
@@ -285,7 +316,111 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
285
316
|
}
|
|
286
317
|
if (!sameMetadata(before, await metadata())) throw new GitPanelError('changed');
|
|
287
318
|
await fs.checkReads();
|
|
288
|
-
return {branch: branch || undefined, head, branches, commits, changes, scope, metadata: before, probe: fingerprint,
|
|
319
|
+
return {branch: branch || undefined, head, branches, remoteBranches, commits, changes, scope, metadata: before, probe: fingerprint,
|
|
320
|
+
upstream};
|
|
321
|
+
};
|
|
322
|
+
interface HistoryNode {commit: HistoryCommit; time: number; children: number; expanded: boolean}
|
|
323
|
+
interface HistoryWalk {nodes: Map<string, HistoryNode>; pending: Set<HistoryNode>; frontier: Set<HistoryNode>}
|
|
324
|
+
const walks = new WeakMap<HistoryCursor, HistoryWalk>();
|
|
325
|
+
/**
|
|
326
|
+
* Reads commits reachable from the local branches, `origin/*` and HEAD, newest committer time first, like
|
|
327
|
+
* `git log --all --date-order` limited to those refs. A commit is listed only after every child read so far, and
|
|
328
|
+
* only once no commit still to be read is newer, keeping a page's worth read ahead, so a child stays above its
|
|
329
|
+
* parents unless committer times run backwards by more than that.
|
|
330
|
+
*/
|
|
331
|
+
const history = async ({limit = historyPageSize, after}: {limit?: number; after?: HistoryCursor | null} = {}): Promise<HistoryPage> => {
|
|
332
|
+
fs.clearReads();
|
|
333
|
+
if (!(limit >= 1)) limit = historyPageSize;
|
|
334
|
+
let walk = after ? walks.get(after) : undefined;
|
|
335
|
+
// Each cursor continues once; the walk it names moves on.
|
|
336
|
+
if (after && !walk) throw new GitPanelError('changed');
|
|
337
|
+
if (after) walks.delete(after);
|
|
338
|
+
const readNode = async (oid: string): Promise<HistoryNode | null> => {
|
|
339
|
+
try {
|
|
340
|
+
const {commit} = await git.readCommit({...options, cache, oid});
|
|
341
|
+
return {commit: {oid, parents: commit.parent, message: commit.message.split('\n')[0], author: commit.author.name,
|
|
342
|
+
timestamp: commit.author.timestamp, refs: []}, time: commit.committer.timestamp, children: 0, expanded: false};
|
|
343
|
+
} catch (error) {
|
|
344
|
+
// A shallow clone stops before the parents it does not have.
|
|
345
|
+
if (errorCode(error) === 'NotFoundError') return null;
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
if (!walk) {
|
|
350
|
+
walk = {nodes: new Map(), pending: new Set(), frontier: new Set()};
|
|
351
|
+
const tips: {ref: string; label: HistoryRef}[] = [];
|
|
352
|
+
const branch = await git.currentBranch(options);
|
|
353
|
+
for (const name of (await git.listBranches(options)).sort()) {
|
|
354
|
+
tips.push({ref: `refs/heads/${name}`, label: {kind: 'branch', name, current: name === branch}});
|
|
355
|
+
}
|
|
356
|
+
for (const name of (await git.listBranches({...options, remote: 'origin'})).sort()) {
|
|
357
|
+
if (name !== 'HEAD') tips.push({ref: `refs/remotes/origin/${name}`, label: {kind: 'remote', name: `origin/${name}`, current: false}});
|
|
358
|
+
}
|
|
359
|
+
if (!branch) tips.push({ref: 'HEAD', label: {kind: 'head', name: 'HEAD', current: true}});
|
|
360
|
+
for (const {ref, label} of tips) {
|
|
361
|
+
let oid: string;
|
|
362
|
+
try {oid = await git.resolveRef({...options, ref});} catch (error) {
|
|
363
|
+
if (errorCode(error) === 'NotFoundError') continue;
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
let node = walk.nodes.get(oid);
|
|
367
|
+
if (!node) {
|
|
368
|
+
const read = await readNode(oid);
|
|
369
|
+
if (!read) continue;
|
|
370
|
+
node = read;
|
|
371
|
+
walk.nodes.set(oid, node);
|
|
372
|
+
walk.pending.add(node);
|
|
373
|
+
walk.frontier.add(node);
|
|
374
|
+
}
|
|
375
|
+
node.commit.refs.push(label);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const {nodes, pending, frontier} = walk;
|
|
379
|
+
const expand = async (node: HistoryNode) => {
|
|
380
|
+
frontier.delete(node);
|
|
381
|
+
node.expanded = true;
|
|
382
|
+
for (const parent of new Set(node.commit.parents)) {
|
|
383
|
+
let next = nodes.get(parent);
|
|
384
|
+
if (!next) {
|
|
385
|
+
const read = await readNode(parent);
|
|
386
|
+
if (!read) continue;
|
|
387
|
+
next = read;
|
|
388
|
+
nodes.set(parent, next);
|
|
389
|
+
pending.add(next);
|
|
390
|
+
frontier.add(next);
|
|
391
|
+
}
|
|
392
|
+
if (pending.has(node) && pending.has(next)) next.children++;
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
const newer = (a: HistoryNode, b: HistoryNode) => a.time !== b.time ? a.time > b.time : a.commit.oid > b.commit.oid;
|
|
396
|
+
const pick = (items: Iterable<HistoryNode>, accept: (node: HistoryNode) => boolean) => {
|
|
397
|
+
let best: HistoryNode | null = null;
|
|
398
|
+
for (const node of items) if (accept(node) && (!best || newer(node, best))) best = node;
|
|
399
|
+
return best;
|
|
400
|
+
};
|
|
401
|
+
const commits: HistoryCommit[] = [];
|
|
402
|
+
while (commits.length < limit) {
|
|
403
|
+
let ready = pick(pending, node => node.children === 0);
|
|
404
|
+
let latest = pick(frontier, () => true);
|
|
405
|
+
// Read on while something unread may be newer than the next commit, keeping a page read ahead.
|
|
406
|
+
while (latest && (!ready || !ready.expanded || latest.time >= ready.time || pending.size < limit * 2)) {
|
|
407
|
+
await expand(latest);
|
|
408
|
+
ready = pick(pending, node => node.children === 0);
|
|
409
|
+
latest = pick(frontier, () => true);
|
|
410
|
+
}
|
|
411
|
+
if (!ready) break;
|
|
412
|
+
pending.delete(ready);
|
|
413
|
+
for (const parent of new Set(ready.commit.parents)) {
|
|
414
|
+
const node = nodes.get(parent);
|
|
415
|
+
if (node && pending.has(node) && ready.expanded) node.children--;
|
|
416
|
+
}
|
|
417
|
+
commits.push(ready.commit);
|
|
418
|
+
}
|
|
419
|
+
await fs.checkReads();
|
|
420
|
+
if (!pending.size) return {commits, cursor: null};
|
|
421
|
+
const cursor: HistoryCursor = Object.freeze({continues: 'history' as const});
|
|
422
|
+
walks.set(cursor, walk);
|
|
423
|
+
return {commits, cursor};
|
|
289
424
|
};
|
|
290
425
|
const historyChanges = async (snapshot: Snapshot, oid: string): Promise<FileChange[]> => {
|
|
291
426
|
fs.clearReads();
|
|
@@ -463,9 +598,28 @@ export async function openRepository(root: FileSystemDirectoryHandle) {
|
|
|
463
598
|
/** Stages, unstages or commits; see `runLocalOperation`. `guard` throws to stop before each write. */
|
|
464
599
|
localOperation: (operation: LocalOperation, guard: () => void) =>
|
|
465
600
|
serial(() => runLocalOperation(root, operation, guard)),
|
|
601
|
+
/**
|
|
602
|
+
* Whether a local branch, `origin/<name>` with `remote`, or the commit `name` with `remote` set to `'commit'`,
|
|
603
|
+
* has the folder `path` (the root always exists).
|
|
604
|
+
*/
|
|
605
|
+
hasFolder: (name: string, path: string, remote: boolean | 'commit' = false) => serial(async () => {
|
|
606
|
+
const ref = remote === 'commit' ? name : remote ? `refs/remotes/origin/${name}` : `refs/heads/${name}`;
|
|
607
|
+
if (!path) return true;
|
|
608
|
+
try {
|
|
609
|
+
const {tree} = await git.readTree({...options, oid: await git.resolveRef({...options, ref}), filepath: path});
|
|
610
|
+
return tree.length > 0;
|
|
611
|
+
} catch {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
}),
|
|
466
615
|
read: (scope?: string) => serial(() => read(scope)),
|
|
467
616
|
readDiff: (snapshot: Snapshot, change: FileChange) => serial(() => readDiff(snapshot, change)),
|
|
468
617
|
historyChanges: (snapshot: Snapshot, oid: string) => serial(() => historyChanges(snapshot, oid)),
|
|
618
|
+
/**
|
|
619
|
+
* Reads one page of the commits reachable from the local branches, `origin/*` and a detached HEAD, newest first
|
|
620
|
+
* with each child above its parents, and the refs at each. Pass the returned `cursor` as `after` for the next page.
|
|
621
|
+
*/
|
|
622
|
+
history: (options?: {limit?: number; after?: HistoryCursor | null}) => serial(() => history(options)),
|
|
469
623
|
/**
|
|
470
624
|
* Reads the assets of both versions of a selected project file, for thumbnails. Staged changes compare HEAD with
|
|
471
625
|
* the index, unstaged ones the index with the working tree, untracked ones nothing with the working tree, and
|
package/src/style.css
CHANGED
|
@@ -26,15 +26,14 @@
|
|
|
26
26
|
.twgit-panel aside li { overflow-wrap: anywhere; }
|
|
27
27
|
.twgit-panel summary { cursor: pointer; }
|
|
28
28
|
.twgit-panel .twgit-file { width: 100%; border: 0; margin: 1px 0; }
|
|
29
|
-
.twgit-panel .twgit-
|
|
29
|
+
.twgit-panel .twgit-file-row { display: flex; gap: 6px; align-items: center; }
|
|
30
|
+
.twgit-panel .twgit-file-row .twgit-file { flex: 1; min-width: 0; width: auto; text-align: start; }
|
|
31
|
+
.twgit-panel .twgit-stage { flex: none; font-size: 11px; margin: 0; white-space: nowrap; }
|
|
30
32
|
.twgit-commit { display: grid; gap: 6px; margin: 10px 0; padding: 8px; border: 1px solid color-mix(in srgb, CanvasText 18%, Canvas); border-radius: 6px; }
|
|
31
33
|
.twgit-commit label { display: grid; gap: 4px; font-size: 12px; }
|
|
32
34
|
.twgit-commit textarea { font: inherit; resize: vertical; min-height: 3em; }
|
|
33
35
|
.twgit-diff { padding: 0 16px 16px; min-width: 0; overflow: auto; }
|
|
34
36
|
.twgit-diff .diff { font-size: 12px; }
|
|
35
|
-
.twgit-history { list-style: none; margin: 0; padding: 0; }
|
|
36
|
-
.twgit-history li { padding: 5px 0; }
|
|
37
|
-
.twgit-history small { display: block; font-size: 11px; }
|
|
38
37
|
.twgit-panel > footer { font-size: 11px; padding: 8px 16px; border-top: 1px solid GrayText; }
|
|
39
38
|
.twgit-error { margin: 6px 16px; color: #a11919; padding: 8px; background: #fff0f0; }
|
|
40
39
|
@media (max-width: 700px) { .twgit-content { grid-template-columns: minmax(180px, 42%) minmax(0, 1fr); } .twgit-panel > header { flex-wrap: wrap; } }
|
|
@@ -65,3 +64,27 @@
|
|
|
65
64
|
.twgit-comparison-body > p { margin: 0 0 8px; }
|
|
66
65
|
.twgit-comparison-body > section { margin-top: 16px; }
|
|
67
66
|
.twgit-comparison-actions { display: flex; gap: 8px; justify-content: flex-end; padding: 10px 16px; border-top: 1px solid GrayText; }
|
|
67
|
+
.twgit-branches ul { list-style: none; padding: 0; margin: 4px 0; }
|
|
68
|
+
.twgit-branches li { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 2px 0; }
|
|
69
|
+
.twgit-branches form { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 6px 0; }
|
|
70
|
+
.twgit-graph-list { list-style: none; margin: 0; padding: 0; }
|
|
71
|
+
.twgit-graph-row { position: relative; display: flex; align-items: stretch; gap: 4px; height: 44px; }
|
|
72
|
+
.twgit-graph-lanes { flex: none; display: block; }
|
|
73
|
+
.twgit-panel .twgit-graph-commit, .twgit-graph-commit { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: center;
|
|
74
|
+
gap: 1px; margin: 2px 0; padding: 2px 6px; border: 0; text-align: start; }
|
|
75
|
+
.twgit-graph-subject, .twgit-graph-meta { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
76
|
+
.twgit-graph-meta { font-size: 11px; opacity: .8; }
|
|
77
|
+
.twgit-graph-ref { display: inline-block; margin-inline-end: 4px; padding: 0 5px; font-size: 11px; line-height: 16px; border-radius: 8px;
|
|
78
|
+
border: 1px solid color-mix(in srgb, CanvasText 35%, Canvas); }
|
|
79
|
+
.twgit-graph-ref-current { font-weight: 600; border-color: currentColor; }
|
|
80
|
+
.twgit-graph-ref-remote { border-style: dashed; }
|
|
81
|
+
.twgit-panel .twgit-graph-menu-button, .twgit-graph-menu-button { flex: none; align-self: center; padding: 0 6px; line-height: 22px; }
|
|
82
|
+
.twgit-graph-popup { position: absolute; z-index: 6; top: 100%; right: 0; display: grid; gap: 2px; min-width: 200px; padding: 6px;
|
|
83
|
+
background: Canvas; color: CanvasText; border: 1px solid GrayText; border-radius: 6px; box-shadow: 0 8px 24px #0004; }
|
|
84
|
+
.twgit-panel .twgit-graph-popup [role="menuitem"], .twgit-graph-popup [role="menuitem"] { border: 0; text-align: start; }
|
|
85
|
+
.twgit-graph-popup form { display: grid; gap: 6px; font-size: 12px; }
|
|
86
|
+
.twgit-graph-form-actions { display: flex; gap: 6px; justify-content: flex-end; }
|
|
87
|
+
.twgit-graph-more { margin: 6px 0; }
|
|
88
|
+
.twgit-graph-status { margin: 4px 0; font-size: 11px; min-height: 1em; }
|
|
89
|
+
.twgit-panel .twgit-graph-popup [role="menuitem"]:focus-visible, .twgit-graph-popup [role="menuitem"]:focus-visible { outline-offset: -2px; }
|
|
90
|
+
.twgit-graph-form-error { margin: 0; color: #a11919; overflow-wrap: anywhere; }
|