@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.
@@ -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, upstream};
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-stage { font-size: 11px; margin: 0 0 4px; }
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; }