@kubohiroya/turbowarp-plus-editor-git 0.16.1 → 0.18.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/dist/style.css CHANGED
@@ -214,4 +214,32 @@
214
214
  .twgit-graph-status { margin: 4px 0; font-size: 11px; min-height: 1em; }
215
215
  .twgit-panel .twgit-graph-popup [role="menuitem"]:focus-visible, .twgit-graph-popup [role="menuitem"]:focus-visible { outline-offset: -2px; }
216
216
  .twgit-graph-form-error { margin: 0; color: #a11919; overflow-wrap: anywhere; }
217
+ /* The branch button sits in the host's menu bar and takes its text color; its menu uses the host's menu colors. */
218
+ .twgit-branch-entry { position: relative; flex: 0 1 auto; align-self: center; display: flex; min-width: 0; font: inherit; }
219
+ .twgit-branch-button { display: flex; align-items: center; gap: 4px; min-width: 0; max-width: 16rem; min-height: 2rem; padding: 0 8px;
220
+ color: inherit; background: transparent; border: 0; border-radius: 4px; font: inherit; white-space: nowrap; cursor: pointer; }
221
+ .twgit-branch-button:hover, .twgit-branch-button[aria-expanded="true"] { background: color-mix(in srgb, currentColor 15%, transparent); }
222
+ .twgit-branch-button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
223
+ .twgit-branch-icon { flex: none; }
224
+ .twgit-branch-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
225
+ .twgit-branch-unborn { flex: none; font-size: 11px; opacity: .85; }
226
+ .twgit-branch-caret { flex: none; font-size: 10px; }
227
+ .twgit-branch-popup { position: absolute; z-index: 510; top: calc(100% + 4px); right: 0; display: grid; gap: 2px; min-width: 220px;
228
+ max-width: min(360px, 90vw); max-height: 70vh; overflow: auto; box-sizing: border-box; padding: 4px;
229
+ color: var(--text-primary, CanvasText); background: var(--ui-white, Canvas); border: 1px solid var(--ui-black-transparent, GrayText);
230
+ border-radius: 6px; box-shadow: 0 8px 24px #0004; font: 14px/1.5 system-ui, sans-serif; white-space: normal; }
231
+ .twgit-branch-popup [role^="menuitem"] { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; color: inherit;
232
+ background: transparent; border: 0; border-radius: 4px; font: inherit; text-align: start; overflow-wrap: anywhere; cursor: pointer; }
233
+ .twgit-branch-popup [role^="menuitem"]:hover, .twgit-branch-popup [role^="menuitem"]:focus-visible {
234
+ background: color-mix(in srgb, currentColor 12%, transparent); }
235
+ .twgit-branch-popup [role^="menuitem"]:focus-visible { outline: 2px solid var(--looks-secondary, Highlight); outline-offset: -2px; }
236
+ .twgit-branch-popup [aria-disabled="true"]:not([aria-checked="true"]) { opacity: .55; cursor: default; }
237
+ .twgit-branch-popup [aria-checked="true"] { font-weight: 600; }
238
+ .twgit-branch-popup [data-separator-before="true"] { margin-top: 3px; box-shadow: 0 -3px 0 -2px color-mix(in srgb, currentColor 25%, transparent); }
239
+ .twgit-branch-check { flex: none; width: 1em; text-align: center; }
240
+ .twgit-branch-popup form { display: grid; gap: 6px; padding: 6px; font-size: 12px; }
241
+ .twgit-branch-popup form label { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
242
+ .twgit-branch-popup form input:not([type]) { flex: 1; min-width: 10em; font: inherit; }
243
+ .twgit-branch-popup form button { color: inherit; background: transparent; border: 1px solid currentColor; border-radius: 4px; padding: 3px 8px; font: inherit; cursor: pointer; }
244
+ .twgit-branch-popup form button:disabled { opacity: .5; cursor: default; }
217
245
  /*$vite$:1*/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-plus-editor-git",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "Read-only local Git panel for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "MPL-2.0",
@@ -0,0 +1,216 @@
1
+ import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
2
+ import type {HistoryActionResult} from './history-graph-view.js';
3
+ import {labelsFor} from './messages.js';
4
+ import type {BranchRefs} from './repository.js';
5
+
6
+ /** What the branch button shows: the branches of the chosen repository as the Git panel last read them. */
7
+ export interface BranchButtonState extends BranchRefs {
8
+ /** Whether a repository has been chosen. */
9
+ repository: boolean;
10
+ /** Whether the host lets the panel switch and create branches. */
11
+ writable: boolean;
12
+ /** Whether the panel is running an operation. */
13
+ busy: boolean;
14
+ }
15
+ /** What the branch button asks the Git panel to do. Branch operations resolve as the panel's own buttons do. */
16
+ export interface BranchButtonActions {
17
+ open(): void;
18
+ choose(): void;
19
+ switchBranch(name: string): Promise<HistoryActionResult>;
20
+ /** Creates a branch at HEAD. */
21
+ createBranch(name: string, checkout: boolean): Promise<HistoryActionResult>;
22
+ /** Creates a local branch from `origin/<name>` and switches to it. */
23
+ createFromRemote(name: string): Promise<HistoryActionResult>;
24
+ }
25
+ export interface BranchButtonProps extends BranchButtonActions {
26
+ state: BranchButtonState;
27
+ locale?: string;
28
+ }
29
+ interface Item {id: string; label: string; checked?: boolean; disabled?: boolean; separatorBefore?: boolean; run(): void}
30
+
31
+ const menuItems = '[role="menuitem"], [role="menuitemradio"]';
32
+
33
+ /** Git's branch glyph, drawn in the text color. */
34
+ function BranchIcon() {
35
+ return <svg className="twgit-branch-icon" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
36
+ <g fill="none" stroke="currentColor" strokeWidth="1.5">
37
+ <circle cx="4.5" cy="3.5" r="1.75"/><circle cx="4.5" cy="12.5" r="1.75"/><circle cx="11.5" cy="5.5" r="1.75"/>
38
+ <path d="M4.5 5.25v5.5M11.5 7.25c0 2.5-2.5 2.75-6 3.75"/>
39
+ </g>
40
+ </svg>;
41
+ }
42
+
43
+ /**
44
+ * The current branch as a menu button for the host's menu bar, with the menu of branches. Before a repository is
45
+ * chosen it reads "Git" and offers to choose one. A detached HEAD shows its short commit ID. The menu moves with the
46
+ * arrow keys, Home and End; Escape closes it and returns to the button. Without `writable` it lists the branches only.
47
+ */
48
+ export function BranchButton({state, locale = 'en', ...actions}: BranchButtonProps) {
49
+ const labels = labelsFor(locale);
50
+ const [mode, setMode] = useState<'closed' | 'menu' | 'create'>('closed');
51
+ const [initial, setInitial] = useState<'checked' | 'first' | 'last'>('checked');
52
+ const [name, setName] = useState('');
53
+ const [checkout, setCheckout] = useState(true);
54
+ const [creating, setCreating] = useState(false);
55
+ const [createError, setCreateError] = useState('');
56
+ const root = useRef<HTMLDivElement>(null);
57
+ const trigger = useRef<HTMLButtonElement>(null);
58
+ const popup = useRef<HTMLDivElement>(null);
59
+ const mounted = useRef(true);
60
+ useEffect(() => () => {mounted.current = false;}, []);
61
+ useLayoutEffect(() => {
62
+ const element = popup.current;
63
+ if (!element) return;
64
+ if (mode === 'create') {
65
+ element.querySelector('input')?.focus();
66
+ return;
67
+ }
68
+ const items = [...element.querySelectorAll<HTMLElement>(menuItems)];
69
+ const checked = items.find(item => item.getAttribute('aria-checked') === 'true');
70
+ (initial === 'last' ? items[items.length - 1] : initial === 'checked' && checked || items[0])?.focus();
71
+ }, [mode]);
72
+ // A click elsewhere closes the menu, also where nothing takes the focus.
73
+ useEffect(() => {
74
+ if (mode === 'closed') return;
75
+ const node = root.current!;
76
+ const dismiss = (event: Event) => {
77
+ if (!node.contains(event.target as Node) && !creating) setMode('closed');
78
+ };
79
+ node.ownerDocument.addEventListener('pointerdown', dismiss, true);
80
+ return () => node.ownerDocument.removeEventListener('pointerdown', dismiss, true);
81
+ }, [mode, creating]);
82
+ // After a refused create, the name is selected for correcting.
83
+ useEffect(() => {
84
+ if (!createError || creating) return;
85
+ const input = popup.current?.querySelector('input:not([type])') as HTMLInputElement | null;
86
+ input?.focus();
87
+ input?.select();
88
+ }, [createError, creating]);
89
+
90
+ const {branch, head} = state;
91
+ const unborn = state.repository && !head;
92
+ const text = !state.repository ? 'Git' : branch ?? (head ? head.slice(0, 7) : labels.unborn);
93
+ const accessibleName = !state.repository ? labels.title :
94
+ `${branch ? labels.branch : labels.detached}: ${text}${unborn && branch ? ` (${labels.unborn})` : ''}`;
95
+ const close = (focus = true) => {
96
+ setMode('closed');
97
+ if (focus) trigger.current?.focus({preventScroll: true});
98
+ };
99
+ const openMenu = (from: 'checked' | 'first' | 'last') => {
100
+ setInitial(from);
101
+ setMode('menu');
102
+ };
103
+ /** Runs a branch operation and shows its result, or why it did not run, in the panel. */
104
+ const change = async (operation: () => Promise<HistoryActionResult>) => {
105
+ const result = await operation();
106
+ if (result.done || result.error) actions.open();
107
+ };
108
+ const items: Item[] = [];
109
+ if (!state.repository) {
110
+ items.push({id: 'choose', label: labels.choose, run: () => {close(); actions.choose();}});
111
+ } else {
112
+ const switching = state.writable && !state.busy;
113
+ for (const local of state.branches) {
114
+ items.push({id: 'branch:' + local, label: local, checked: local === branch, disabled: !state.writable || state.busy && local !== branch,
115
+ run: () => {
116
+ close();
117
+ if (local !== branch && switching) void change(() => actions.switchBranch(local));
118
+ }});
119
+ }
120
+ if (state.writable) {
121
+ state.remoteBranches.forEach((remote, index) => items.push({id: 'remote:' + remote,
122
+ label: labels.historyCreateFromRemote.replace('{name}', remote), disabled: state.busy, separatorBefore: index === 0,
123
+ run: () => {
124
+ close();
125
+ if (!state.busy) void change(() => actions.createFromRemote(remote));
126
+ }}));
127
+ if (head) items.push({id: 'create', label: labels.newBranchItem, disabled: state.busy, separatorBefore: true, run: () => {
128
+ if (state.busy) return;
129
+ setName('');
130
+ setCheckout(true);
131
+ setCreateError('');
132
+ setMode('create');
133
+ }});
134
+ }
135
+ }
136
+ // "Open Local Git" goes with "New Branch…" and stands apart from the branches.
137
+ items.push({id: 'open', label: labels.openPanel, separatorBefore: items.length > 0 && items[items.length - 1].id !== 'create',
138
+ run: () => {close(); actions.open();}});
139
+
140
+ const onMenuKey = (event: React.KeyboardEvent<HTMLElement>) => {
141
+ if (event.key === 'Escape') {
142
+ event.preventDefault();
143
+ event.stopPropagation();
144
+ if (!creating) close();
145
+ return;
146
+ }
147
+ if (mode !== 'menu') return;
148
+ if (event.key === 'Tab') {close(false); return;}
149
+ const elements = [...event.currentTarget.querySelectorAll<HTMLElement>(menuItems)];
150
+ const index = elements.indexOf(event.target as HTMLElement);
151
+ const target = {ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: elements.length - 1}[event.key];
152
+ if (target === undefined || !elements.length) return;
153
+ event.preventDefault();
154
+ elements[(target + elements.length) % elements.length].focus();
155
+ };
156
+ const submit = async (event: React.FormEvent) => {
157
+ event.preventDefault();
158
+ const branchName = name.trim();
159
+ if (!branchName || creating) return;
160
+ setCreating(true);
161
+ setCreateError('');
162
+ let result: HistoryActionResult;
163
+ try {
164
+ result = await actions.createBranch(branchName, checkout);
165
+ } catch (error) {
166
+ result = {done: false, error: error instanceof Error && error.message ? error.message : labels.failed};
167
+ }
168
+ if (!mounted.current) return;
169
+ setCreating(false);
170
+ if (result.done) {
171
+ close();
172
+ actions.open();
173
+ } else setCreateError(result.error ?? '');
174
+ };
175
+ return <div className="twgit-branch-entry" ref={root} onBlur={event => {
176
+ const next = event.relatedTarget as Node | null;
177
+ if (next && !event.currentTarget.contains(next) && !creating) setMode('closed');
178
+ }}>
179
+ <button type="button" className="twgit-branch-button" ref={trigger} aria-label={accessibleName} title={accessibleName}
180
+ aria-haspopup="menu" aria-expanded={mode === 'menu'} data-git-branch={state.repository ? branch ?? head ?? '' : undefined}
181
+ onClick={() => mode === 'closed' ? openMenu('checked') : close(false)}
182
+ onKeyDown={event => {
183
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
184
+ event.preventDefault();
185
+ openMenu(event.key === 'ArrowUp' ? 'last' : 'checked');
186
+ }}>
187
+ <BranchIcon/>
188
+ <span className="twgit-branch-name">{text}</span>
189
+ {unborn && branch && <small className="twgit-branch-unborn">{labels.unborn}</small>}
190
+ <span className="twgit-branch-caret" aria-hidden="true">▾</span>
191
+ </button>
192
+ {mode === 'menu' && <div ref={popup} className="twgit-branch-popup" role="menu" aria-label={labels.branchMenu} onKeyDown={onMenuKey}>
193
+ {items.map(item => <button key={item.id} type="button" tabIndex={-1} data-action={item.id}
194
+ role={item.checked === undefined ? 'menuitem' : 'menuitemradio'} aria-checked={item.checked}
195
+ aria-disabled={item.disabled || undefined} data-separator-before={item.separatorBefore || undefined}
196
+ onClick={() => {if (!item.disabled || item.checked) item.run();}}>
197
+ {item.checked !== undefined && <span className="twgit-branch-check" aria-hidden="true">{item.checked ? '✓' : ''}</span>}
198
+ {item.label}
199
+ </button>)}
200
+ </div>}
201
+ {mode === 'create' && <div ref={popup} className="twgit-branch-popup" role="dialog" aria-label={labels.newBranchTitle}
202
+ onKeyDown={onMenuKey}>
203
+ <form onSubmit={event => void submit(event)}>
204
+ <label>{labels.newBranch} <input value={name} required disabled={creating} aria-invalid={createError ? true : undefined}
205
+ onChange={event => setName(event.currentTarget.value)}/></label>
206
+ <label><input type="checkbox" checked={checkout} disabled={creating}
207
+ onChange={event => setCheckout(event.currentTarget.checked)}/> {labels.switchAfterCreate}</label>
208
+ {createError && <p className="twgit-graph-form-error" role="alert">{createError}</p>}
209
+ <span className="twgit-graph-form-actions">
210
+ <button type="submit" disabled={creating || state.busy || !name.trim()}>{labels.createBranch}</button>
211
+ <button type="button" disabled={creating} onClick={() => close()}>{labels.cancel}</button>
212
+ </span>
213
+ </form>
214
+ </div>}
215
+ </div>;
216
+ }
package/src/filesystem.ts CHANGED
@@ -2,7 +2,8 @@ export class GitPanelError extends Error {
2
2
  constructor(public code: 'invalid' | 'changed' | 'large' | 'unsupported' | 'pullUnsafe' | 'diverged' |
3
3
  'session' | 'pushUnknown' | 'pushRefresh' | 'rollbackFailed' | 'remoteFailed' |
4
4
  'saveFirst' | 'author' | 'nothingStaged' | 'commitMessage' |
5
- 'switchUnsafe' | 'branchExists' | 'branchName' | 'locked', message: string = code) {
5
+ 'switchUnsafe' | 'branchExists' | 'branchConflict' | 'pullRequestBranch' | 'branchName' | 'locked' |
6
+ 'pullRequestNotFound' | 'forbidden' | 'network' | 'baseMissing', message: string = code) {
6
7
  super(message);
7
8
  this.name = 'GitPanelError';
8
9
  }
@@ -165,6 +165,13 @@ export function createGitHubPullRequests(repository: string, send: typeof fetch
165
165
  await request(repoPath(name) + '/contents/' + contentPath(path) + '?ref=' + revision, signal, 'application/vnd.github.raw+json'), sourceLimit);
166
166
 
167
167
  return {
168
+ /** Reads one pull request's title, description, state and both revisions, without its files (one request). */
169
+ async metadata(number: number, signal: AbortSignal) {
170
+ if (!Number.isSafeInteger(number) || number < 1) throw invalidResponse();
171
+ const pr = await metadata(number, signal);
172
+ if (pr.number !== number) throw invalidResponse();
173
+ return {...pr, url: 'https://github.com/' + repository + '/pull/' + number};
174
+ },
168
175
  async list(page: number, signal: AbortSignal) {
169
176
  const result = await json(repo + '/pulls?state=all&sort=updated&per_page=20&page=' + page, signal);
170
177
  if (!Array.isArray(result)) throw invalidResponse();