@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.
- package/README.ja.md +5 -3
- package/README.md +5 -3
- package/dist/close-button.d.ts +6 -0
- 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 +697 -31
- package/dist/local-operations.d.ts +14 -0
- package/dist/messages.d.ts +52 -0
- package/dist/{project-comparison-LAyP_beL.js → project-comparison-1rggIJse.js} +240 -20
- package/dist/project-comparison.d.ts +1 -0
- 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 +31 -4
- package/package.json +1 -1
- package/src/close-button.tsx +10 -0
- 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 +137 -17
- package/src/local-operations.ts +59 -4
- package/src/messages.ts +26 -2
- package/src/project-comparison.tsx +4 -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 +31 -4
|
@@ -124,19 +124,27 @@ function useBlockLocation(comparison: Comparison, locateBlock?: ProjectBlockLoca
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
/** What a script is called in a review: its hat block, or both hat blocks when the change replaced it. */
|
|
128
|
+
function scriptReviewTitle(script: ScriptReview, labels: typeof ja) {
|
|
129
|
+
const beforeTitle = script.before[0] && scriptTitle(script.before[0], labels);
|
|
130
|
+
const afterTitle = script.after[0] && scriptTitle(script.after[0], labels);
|
|
131
|
+
return beforeTitle && afterTitle && beforeTitle !== afterTitle ? beforeTitle + ' → ' + afterTitle : afterTitle ?? beforeTitle;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ScriptComparison({script, labels, reviewed = false, setReviewed, renderBlock, loadAsset, location, expanded = false}: {
|
|
128
135
|
script: ScriptReview; labels: typeof ja; reviewed?: boolean; setReviewed?(value: boolean): void;
|
|
129
136
|
renderBlock: ProjectBlockRenderer; loadAsset?: ProjectAssetLoader; location?: BlockLocation;
|
|
137
|
+
/** Starts open, as in a tab that holds this one script. */
|
|
138
|
+
expanded?: boolean;
|
|
130
139
|
}) {
|
|
131
|
-
const [open, setOpen] = useState(
|
|
140
|
+
const [open, setOpen] = useState(expanded);
|
|
132
141
|
const {sync, toggle} = usePreviewSync(labels === ja ? 'ja' : 'en');
|
|
133
142
|
const status = script.kind;
|
|
134
143
|
const locale = labels === ja ? 'ja' : 'en';
|
|
135
144
|
const summary = script.summary.map(item => summaryPhrase(item, locale)).join(labels === ja ? '、' : ', ');
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return <details className="twgit-review-script" onToggle={event => setOpen((event.currentTarget as HTMLDetailsElement).open)}>
|
|
145
|
+
const title = scriptReviewTitle(script, labels);
|
|
146
|
+
return <details className="twgit-review-script" open={expanded || undefined}
|
|
147
|
+
onToggle={event => setOpen((event.currentTarget as HTMLDetailsElement).open)}>
|
|
140
148
|
<summary>
|
|
141
149
|
<span className="twgit-review-status">{labels[status]}</span>
|
|
142
150
|
<strong>{title}</strong>
|
|
@@ -339,8 +347,21 @@ const scriptBlocks = ({target, id}: ScriptReview['before'][number]) => {
|
|
|
339
347
|
};
|
|
340
348
|
const scriptPrint = (script: ScriptReview) => fingerprint([script.kind, script.before.map(scriptBlocks), script.after.map(scriptBlocks)]);
|
|
341
349
|
const storageKey = (name: string) => `turbowarp-plus-git:reviewed:${name}`;
|
|
350
|
+
// Marks of reviews that are not remembered, shared by the tabs of one comparison while the page is open.
|
|
351
|
+
const memoryMarks = new Map<string, Record<string, string>>();
|
|
352
|
+
// Whoever shows the marks of a review, such as the review panel and the tabs of the same pull request.
|
|
353
|
+
const markListeners = new Map<string, Set<() => void>>();
|
|
354
|
+
const subscribeMarks = (name: string, listener: () => void) => {
|
|
355
|
+
const listeners = markListeners.get(name) ?? new Set();
|
|
356
|
+
markListeners.set(name, listeners.add(listener));
|
|
357
|
+
return () => {
|
|
358
|
+
listeners.delete(listener);
|
|
359
|
+
if (!listeners.size) markListeners.delete(name);
|
|
360
|
+
};
|
|
361
|
+
};
|
|
342
362
|
// Browser storage can be missing or refuse access; the marks then last while the review is open.
|
|
343
|
-
const readMarks = (name: string): Record<string, string> => {
|
|
363
|
+
const readMarks = (name: string, remember = true): Record<string, string> => {
|
|
364
|
+
if (!remember) return memoryMarks.get(name) ?? {};
|
|
344
365
|
try {
|
|
345
366
|
const value = JSON.parse(globalThis.localStorage?.getItem(storageKey(name)) ?? '{}');
|
|
346
367
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
@@ -348,13 +369,18 @@ const readMarks = (name: string): Record<string, string> => {
|
|
|
348
369
|
return {};
|
|
349
370
|
}
|
|
350
371
|
};
|
|
351
|
-
const writeMarks = (name: string, marks: Record<string, string
|
|
372
|
+
const writeMarks = (name: string, marks: Record<string, string>, remember = true) => {
|
|
373
|
+
if (!remember) {
|
|
374
|
+
if (Object.keys(marks).length) memoryMarks.set(name, marks);
|
|
375
|
+
else memoryMarks.delete(name);
|
|
376
|
+
}
|
|
352
377
|
try {
|
|
353
|
-
if (Object.keys(marks).length) globalThis.localStorage?.setItem(storageKey(name), JSON.stringify(marks));
|
|
354
|
-
else globalThis.localStorage?.removeItem(storageKey(name));
|
|
378
|
+
if (remember && Object.keys(marks).length) globalThis.localStorage?.setItem(storageKey(name), JSON.stringify(marks));
|
|
379
|
+
else if (remember) globalThis.localStorage?.removeItem(storageKey(name));
|
|
355
380
|
} catch {
|
|
356
381
|
// Not kept; the marks still work for this review.
|
|
357
382
|
}
|
|
383
|
+
markListeners.get(name)?.forEach(listener => listener());
|
|
358
384
|
};
|
|
359
385
|
|
|
360
386
|
export function ProjectSourceReview({title, description, verification, files, renderBlock, locale = 'en', rememberAs}: ProjectSourceReviewProps) {
|
|
@@ -379,18 +405,24 @@ export function ProjectSourceReview({title, description, verification, files, re
|
|
|
379
405
|
return prints;
|
|
380
406
|
}, [entries]);
|
|
381
407
|
const [checked, setChecked] = useState<{entries: typeof entries; marks: Record<string, string>}>();
|
|
408
|
+
// A mark set in a tab of the same pull request shows here too.
|
|
409
|
+
const [, refresh] = useState(0);
|
|
410
|
+
useEffect(() => rememberAs ? subscribeMarks(rememberAs, () => {
|
|
411
|
+
setChecked(undefined);
|
|
412
|
+
refresh(count => count + 1);
|
|
413
|
+
}) : undefined, [rememberAs]);
|
|
382
414
|
const marks = checked?.entries === entries ? checked.marks : rememberAs ? readMarks(rememberAs) : {};
|
|
383
415
|
const reviewed = new Set([...scriptPrints].filter(([key, print]) => marks[key] === print).map(([key]) => key));
|
|
384
|
-
const setReviewed = (key: string, value: boolean) =>
|
|
385
|
-
const current = previous?.entries === entries ? previous.marks : rememberAs ? readMarks(rememberAs) : {};
|
|
416
|
+
const setReviewed = (key: string, value: boolean) => {
|
|
386
417
|
// Keep only marks that still match a script of this review.
|
|
387
418
|
const next: Record<string, string> = {};
|
|
388
|
-
for (const [name, print] of scriptPrints) if (
|
|
419
|
+
for (const [name, print] of scriptPrints) if (marks[name] === print) next[name] = print;
|
|
389
420
|
if (value) next[key] = scriptPrints.get(key)!;
|
|
390
421
|
else delete next[key];
|
|
422
|
+
// Writing tells the tabs of this review, and this panel, which then keeps the marks it has just set.
|
|
391
423
|
if (rememberAs) writeMarks(rememberAs, next);
|
|
392
|
-
|
|
393
|
-
}
|
|
424
|
+
setChecked({entries, marks: next});
|
|
425
|
+
};
|
|
394
426
|
const targets = entries.reduce((total, entry) => total + (entry.comparison?.targets.length ?? 0), 0);
|
|
395
427
|
const scripts = entries.reduce((total, entry) => total + (entry.comparison ? countScripts(entry.comparison) : 0), 0);
|
|
396
428
|
const body = description?.trim();
|
|
@@ -429,3 +461,83 @@ export function ProjectSourceReview({title, description, verification, files, re
|
|
|
429
461
|
</section>)}
|
|
430
462
|
</section>;
|
|
431
463
|
}
|
|
464
|
+
|
|
465
|
+
/** The words of one tab of a project comparison opened as tabs. */
|
|
466
|
+
export function reviewTabWords(locale: string) {
|
|
467
|
+
const labels = locale.startsWith('ja') ? ja : en;
|
|
468
|
+
return {
|
|
469
|
+
script: (script: ScriptReview) => `${scriptReviewTitle(script, labels)} · ${labels[script.kind]}`,
|
|
470
|
+
target: (target: TargetReview) => target.isStage ? labels.stage : target.name,
|
|
471
|
+
targetDetails: labels.properties,
|
|
472
|
+
project: labels.comparison,
|
|
473
|
+
noChanges: labels.noChanges
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Where a tab keeps its reviewed mark: under `name` with the review of the same name, in the browser when
|
|
479
|
+
* `remember` is set, or else while the page is open.
|
|
480
|
+
*/
|
|
481
|
+
export interface ReviewMarkPlace {
|
|
482
|
+
name: string;
|
|
483
|
+
remember: boolean;
|
|
484
|
+
file: string;
|
|
485
|
+
target: TargetReview;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function useReviewMark(place: ReviewMarkPlace | undefined, script: ScriptReview) {
|
|
489
|
+
const [, refresh] = useState(0);
|
|
490
|
+
useEffect(() => place && subscribeMarks(place.name, () => refresh(count => count + 1)), [place?.name]);
|
|
491
|
+
if (!place) return {};
|
|
492
|
+
const key = reviewKey(place.file, place.target, script);
|
|
493
|
+
const print = scriptPrint(script);
|
|
494
|
+
return {
|
|
495
|
+
reviewed: readMarks(place.name, place.remember)[key] === print,
|
|
496
|
+
setReviewed(value: boolean) {
|
|
497
|
+
const marks = {...readMarks(place.name, place.remember)};
|
|
498
|
+
if (value) marks[key] = print;
|
|
499
|
+
else delete marks[key];
|
|
500
|
+
writeMarks(place.name, marks, place.remember);
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** One script of a comparison, open, for a tab of its own. */
|
|
506
|
+
export function ScriptReviewTab({comparison, script, renderBlock, locale = 'en', loadAsset, locateBlock, mark}: {
|
|
507
|
+
comparison: Comparison; script: ScriptReview; renderBlock: ProjectBlockRenderer; locale?: string;
|
|
508
|
+
loadAsset?: ProjectAssetLoader; locateBlock?: ProjectBlockLocator;
|
|
509
|
+
/** Offers the reviewed mark, shared with the review panel and other tabs that use the same place. */
|
|
510
|
+
mark?: ReviewMarkPlace;
|
|
511
|
+
}) {
|
|
512
|
+
const labels = locale.startsWith('ja') ? ja : en;
|
|
513
|
+
const location = useBlockLocation(comparison, locateBlock);
|
|
514
|
+
const {reviewed, setReviewed} = useReviewMark(mark, script);
|
|
515
|
+
return <section className="twgit-block-diff twgit-review twgit-review-tab">
|
|
516
|
+
<ScriptComparison script={script} labels={labels} renderBlock={renderBlock} loadAsset={loadAsset} location={location} expanded
|
|
517
|
+
reviewed={reviewed} setReviewed={setReviewed}/>
|
|
518
|
+
<Legend labels={labels}/>
|
|
519
|
+
</section>;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* What changed in a stage or sprite outside its code, or in the project as a whole when no target is given,
|
|
524
|
+
* for a tab of its own.
|
|
525
|
+
*/
|
|
526
|
+
export function TargetReviewTab({comparison, target, locale = 'en', loadAsset}: {
|
|
527
|
+
comparison: Comparison; target?: TargetReview; locale?: string; loadAsset?: ProjectAssetLoader;
|
|
528
|
+
}) {
|
|
529
|
+
const labels = locale.startsWith('ja') ? ja : en;
|
|
530
|
+
if (!target) {
|
|
531
|
+
return <section className="twgit-block-diff twgit-review twgit-review-tab">
|
|
532
|
+
{comparison.properties.length > 0 ? <p>{labels.properties} · {propertyNames(comparison.properties, labels)}</p> :
|
|
533
|
+
<p>{labels.noChanges}</p>}
|
|
534
|
+
</section>;
|
|
535
|
+
}
|
|
536
|
+
return <section className="twgit-block-diff twgit-review twgit-review-tab">
|
|
537
|
+
<h5><TargetThumbnails target={target} labels={labels} loadAsset={loadAsset}/>{target.isStage ? labels.stage : target.name}</h5>
|
|
538
|
+
{target.status !== 'updated' && <p className="twgit-review-target-status">
|
|
539
|
+
{(target.status === 'added' ? labels.targetAdded : labels.targetRemoved)(target.name, target.isStage)}</p>}
|
|
540
|
+
{target.status === 'updated' && target.properties.length > 0 && <p>{labels.properties} · {propertyNames(target.properties, labels)}</p>}
|
|
541
|
+
{target.scripts.length > 0 && <p>{labels.scriptCount(target.scripts.length)}</p>}
|
|
542
|
+
</section>;
|
|
543
|
+
}
|
|
@@ -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
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
.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; }
|
|
3
3
|
.twgit-panel > header { display: flex; gap: 8px; align-items: center; padding: 10px 16px; border-bottom: 1px solid GrayText; }
|
|
4
4
|
.twgit-panel > header h2 { margin: 0 auto 0 0; font-size: 18px; }
|
|
5
|
+
.twgit-panel .twgit-close, .twgit-close { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0;
|
|
6
|
+
border: 1px solid transparent; border-radius: 4px; background: transparent; color: inherit; cursor: pointer; }
|
|
7
|
+
.twgit-panel .twgit-close:hover, .twgit-close:hover { background: color-mix(in srgb, currentColor 12%, transparent); }
|
|
8
|
+
.twgit-panel .twgit-close:focus-visible, .twgit-close:focus-visible { outline: 2px solid Highlight; outline-offset: 1px; }
|
|
5
9
|
.twgit-panel h3 { font-size: 14px; margin: 12px 0 5px; }
|
|
6
10
|
.twgit-panel button { color: inherit; background: Canvas; border: 1px solid GrayText; border-radius: 4px; padding: 4px 8px; cursor: pointer; font: inherit; text-align: start; }
|
|
7
11
|
.twgit-panel button:disabled { opacity: .5; cursor: default; }
|
|
@@ -26,15 +30,14 @@
|
|
|
26
30
|
.twgit-panel aside li { overflow-wrap: anywhere; }
|
|
27
31
|
.twgit-panel summary { cursor: pointer; }
|
|
28
32
|
.twgit-panel .twgit-file { width: 100%; border: 0; margin: 1px 0; }
|
|
29
|
-
.twgit-panel .twgit-
|
|
33
|
+
.twgit-panel .twgit-file-row { display: flex; gap: 6px; align-items: center; }
|
|
34
|
+
.twgit-panel .twgit-file-row .twgit-file { flex: 1; min-width: 0; width: auto; text-align: start; }
|
|
35
|
+
.twgit-panel .twgit-stage { flex: none; font-size: 11px; margin: 0; white-space: nowrap; }
|
|
30
36
|
.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
37
|
.twgit-commit label { display: grid; gap: 4px; font-size: 12px; }
|
|
32
38
|
.twgit-commit textarea { font: inherit; resize: vertical; min-height: 3em; }
|
|
33
39
|
.twgit-diff { padding: 0 16px 16px; min-width: 0; overflow: auto; }
|
|
34
40
|
.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
41
|
.twgit-panel > footer { font-size: 11px; padding: 8px 16px; border-top: 1px solid GrayText; }
|
|
39
42
|
.twgit-error { margin: 6px 16px; color: #a11919; padding: 8px; background: #fff0f0; }
|
|
40
43
|
@media (max-width: 700px) { .twgit-content { grid-template-columns: minmax(180px, 42%) minmax(0, 1fr); } .twgit-panel > header { flex-wrap: wrap; } }
|
|
@@ -65,3 +68,27 @@
|
|
|
65
68
|
.twgit-comparison-body > p { margin: 0 0 8px; }
|
|
66
69
|
.twgit-comparison-body > section { margin-top: 16px; }
|
|
67
70
|
.twgit-comparison-actions { display: flex; gap: 8px; justify-content: flex-end; padding: 10px 16px; border-top: 1px solid GrayText; }
|
|
71
|
+
.twgit-branches ul { list-style: none; padding: 0; margin: 4px 0; }
|
|
72
|
+
.twgit-branches li { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 2px 0; }
|
|
73
|
+
.twgit-branches form { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 6px 0; }
|
|
74
|
+
.twgit-graph-list { list-style: none; margin: 0; padding: 0; }
|
|
75
|
+
.twgit-graph-row { position: relative; display: flex; align-items: stretch; gap: 4px; height: 44px; }
|
|
76
|
+
.twgit-graph-lanes { flex: none; display: block; }
|
|
77
|
+
.twgit-panel .twgit-graph-commit, .twgit-graph-commit { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: center;
|
|
78
|
+
gap: 1px; margin: 2px 0; padding: 2px 6px; border: 0; text-align: start; }
|
|
79
|
+
.twgit-graph-subject, .twgit-graph-meta { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
80
|
+
.twgit-graph-meta { font-size: 11px; opacity: .8; }
|
|
81
|
+
.twgit-graph-ref { display: inline-block; margin-inline-end: 4px; padding: 0 5px; font-size: 11px; line-height: 16px; border-radius: 8px;
|
|
82
|
+
border: 1px solid color-mix(in srgb, CanvasText 35%, Canvas); }
|
|
83
|
+
.twgit-graph-ref-current { font-weight: 600; border-color: currentColor; }
|
|
84
|
+
.twgit-graph-ref-remote { border-style: dashed; }
|
|
85
|
+
.twgit-panel .twgit-graph-menu-button, .twgit-graph-menu-button { flex: none; align-self: center; padding: 0 6px; line-height: 22px; }
|
|
86
|
+
.twgit-graph-popup { position: absolute; z-index: 6; top: 100%; right: 0; display: grid; gap: 2px; min-width: 200px; padding: 6px;
|
|
87
|
+
background: Canvas; color: CanvasText; border: 1px solid GrayText; border-radius: 6px; box-shadow: 0 8px 24px #0004; }
|
|
88
|
+
.twgit-panel .twgit-graph-popup [role="menuitem"], .twgit-graph-popup [role="menuitem"] { border: 0; text-align: start; }
|
|
89
|
+
.twgit-graph-popup form { display: grid; gap: 6px; font-size: 12px; }
|
|
90
|
+
.twgit-graph-form-actions { display: flex; gap: 6px; justify-content: flex-end; }
|
|
91
|
+
.twgit-graph-more { margin: 6px 0; }
|
|
92
|
+
.twgit-graph-status { margin: 4px 0; font-size: 11px; min-height: 1em; }
|
|
93
|
+
.twgit-panel .twgit-graph-popup [role="menuitem"]:focus-visible, .twgit-graph-popup [role="menuitem"]:focus-visible { outline-offset: -2px; }
|
|
94
|
+
.twgit-graph-form-error { margin: 0; color: #a11919; overflow-wrap: anywhere; }
|