@kubohiroya/turbowarp-plus-editor-git 0.12.0 → 0.12.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 CHANGED
@@ -16,6 +16,8 @@ Node.js 26 で `npm ci` と `npm test` を実行します。Vite でライブラ
16
16
 
17
17
  ## Smart HTTP アダプター
18
18
 
19
+ `test/smart-http-fixture.js` は、`git-http-backend` を IPv4 のループバックで動かす検証用のフィクスチャです。Node.js の標準モジュールだけを使い、利用側のブラウザーテストが同じ版を使えるよう配布物に含めます。
20
+
19
21
  `@kubohiroya/turbowarp-plus-editor-git/smart-http` の `createGitSmartHttp` に、Git 設定から選んだ GitHub の HTTPS リモート、信頼済みの接続先(`mode` と `proxyOrigin`)、現在のプロキシセッションか `null` を返す関数を渡します。auth パッケージの `GitAuthProvider` を使えます。Git パッケージは必要なセッションの型だけを構造的に参照するため、読み取り専用パネルとアダプターは auth パッケージと独立してインストールできます。
20
22
 
21
23
  ```ts
package/README.md CHANGED
@@ -16,6 +16,8 @@ The panel structure and local Git approach were informed by [duff](https://githu
16
16
 
17
17
  ## Smart HTTP adapter
18
18
 
19
+ `test/smart-http-fixture.js` runs `git-http-backend` on the IPv4 loopback for tests. It uses only Node.js built-in modules and ships in the package so browser tests of a host use the fixture of the same version.
20
+
19
21
  Import `createGitSmartHttp` from `@kubohiroya/turbowarp-plus-editor-git/smart-http`. Pass the selected GitHub HTTPS remote from Git config, a trusted connection (`mode` and `proxyOrigin`), and a function that returns the current proxy session or `null`. A `GitAuthProvider` from the auth package satisfies this contract. The Git package uses the session fields structurally, so the read-only panel and the adapter can be installed independently of the auth package.
20
22
 
21
23
  ```ts
package/dist/index.js CHANGED
@@ -3557,6 +3557,8 @@ function GitPanel({ vm, document, host, controller }) {
3557
3557
  const alive = useRef(true);
3558
3558
  const opening = useRef(false);
3559
3559
  const dialog = useRef(null);
3560
+ const shownRef = useRef(false);
3561
+ const deferred = useRef(false);
3560
3562
  const reviewing = useRef(review);
3561
3563
  reviewing.current = review;
3562
3564
  const handled = useRef(null);
@@ -3582,6 +3584,11 @@ function GitPanel({ vm, document, host, controller }) {
3582
3584
  } catch (error) {
3583
3585
  if (epoch.current !== ticket) return;
3584
3586
  const code = error instanceof GitPanelError ? error.code : "failed";
3587
+ if (automatic && code === "changed") {
3588
+ handled.current = null;
3589
+ setState((previous) => _objectSpread2$1(_objectSpread2$1({}, previous), {}, { busy: false }));
3590
+ return;
3591
+ }
3585
3592
  const message = labels[code] || labels.failed;
3586
3593
  setState((previous) => _objectSpread2$1(_objectSpread2$1({}, previous), {}, {
3587
3594
  error: automatic && previous.error ? previous.error : message,
@@ -3590,6 +3597,7 @@ function GitPanel({ vm, document, host, controller }) {
3590
3597
  }));
3591
3598
  } finally {
3592
3599
  running.current = false;
3600
+ if (epoch.current !== ticket && !refreshPending.current && alive.current) setState((previous) => _objectSpread2$1(_objectSpread2$1({}, previous), {}, { busy: false }));
3593
3601
  if (automatic) {
3594
3602
  background.current = null;
3595
3603
  settle();
@@ -3632,20 +3640,29 @@ function GitPanel({ vm, document, host, controller }) {
3632
3640
  };
3633
3641
  controller.refresh = async (automatic = false) => {
3634
3642
  if (!alive.current || !current.current.repository) return;
3643
+ if (!shownRef.current) {
3644
+ deferred.current = true;
3645
+ return;
3646
+ }
3635
3647
  if (running.current) {
3636
3648
  if (!automatic) refreshPending.current = true;
3637
3649
  return;
3638
3650
  }
3639
3651
  await run(async () => {
3640
3652
  const { repository, project } = current.current;
3641
- const snapshot = await repository.read(project !== null && project !== void 0 ? project : "");
3642
- return {
3643
- snapshot,
3644
- changes: snapshot.changes,
3645
- selected: null,
3646
- diff: null,
3647
- commit: null
3648
- };
3653
+ for (let attempt = 1;; attempt++) try {
3654
+ const snapshot = await repository.read(project !== null && project !== void 0 ? project : "");
3655
+ return {
3656
+ snapshot,
3657
+ changes: snapshot.changes,
3658
+ selected: null,
3659
+ diff: null,
3660
+ commit: null
3661
+ };
3662
+ } catch (error) {
3663
+ if (!(error instanceof GitPanelError && error.code === "changed") || attempt >= 5) throw error;
3664
+ await new Promise((resolve) => setTimeout(resolve, 200 * attempt));
3665
+ }
3649
3666
  }, automatic);
3650
3667
  };
3651
3668
  useEffect(() => {
@@ -3662,7 +3679,7 @@ function GitPanel({ vm, document, host, controller }) {
3662
3679
  selected: null,
3663
3680
  diff: null,
3664
3681
  commit: null,
3665
- busy: false
3682
+ busy: running.current
3666
3683
  }));
3667
3684
  queueMicrotask(() => void controller.refresh());
3668
3685
  }
@@ -3675,6 +3692,13 @@ function GitPanel({ vm, document, host, controller }) {
3675
3692
  unsubscribe === null || unsubscribe === void 0 || unsubscribe();
3676
3693
  };
3677
3694
  }, [host]);
3695
+ useEffect(() => {
3696
+ shownRef.current = shown;
3697
+ if (shown && deferred.current) {
3698
+ deferred.current = false;
3699
+ controller.refresh();
3700
+ }
3701
+ }, [shown]);
3678
3702
  useEffect(() => {
3679
3703
  const repository = state.repository;
3680
3704
  if (!shown || !repository) return;
@@ -3683,7 +3707,8 @@ function GitPanel({ vm, document, host, controller }) {
3683
3707
  let checking = false;
3684
3708
  const check = async () => {
3685
3709
  const { snapshot } = current.current;
3686
- if (stopped || checking || running.current || document.visibilityState === "hidden" || !snapshot || current.current.repository !== repository) return;
3710
+ const hidden = !dialog.current || !dialog.current.getClientRects().length;
3711
+ if (stopped || checking || running.current || document.visibilityState === "hidden" || hidden || !snapshot || current.current.repository !== repository) return;
3687
3712
  checking = true;
3688
3713
  try {
3689
3714
  const probe = await repository.probe(snapshot.scope);
@@ -3978,6 +4003,7 @@ function GitPanel({ vm, document, host, controller }) {
3978
4003
  }) : /* @__PURE__ */ React.createElement("p", null, labels.select))));
3979
4004
  if (slot) return createPortal(/* @__PURE__ */ React.createElement("div", {
3980
4005
  className: "twgit-panel twgit-panel-tab",
4006
+ ref: dialog,
3981
4007
  role: "region",
3982
4008
  "aria-label": labels.title
3983
4009
  }, content), slot.element);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-plus-editor-git",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Read-only local Git panel for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "MPL-2.0",
@@ -36,6 +36,7 @@
36
36
  "tsconfig.json",
37
37
  "README.ja.md",
38
38
  "THIRD_PARTY_NOTICES.md",
39
+ "test/smart-http-fixture.js",
39
40
  "README.md",
40
41
  "LICENSE"
41
42
  ],
package/src/index.tsx CHANGED
@@ -93,6 +93,9 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
93
93
  const alive = useRef(true);
94
94
  const opening = useRef(false);
95
95
  const dialog = useRef<HTMLDivElement>(null);
96
+ // A closed panel reads nothing; a refresh asked while it is closed waits until it opens again.
97
+ const shownRef = useRef(false);
98
+ const deferred = useRef(false);
96
99
  const reviewing = useRef(review);
97
100
  reviewing.current = review;
98
101
  // The last fingerprint acted on, so a failing read or an open diff is not retried on every check.
@@ -115,10 +118,20 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
115
118
  } catch (error) {
116
119
  if (epoch.current !== ticket) return;
117
120
  const code = error instanceof GitPanelError ? error.code : 'failed';
121
+ // A background read that met another tool still writing tries again on the next check, without an alert.
122
+ if (automatic && code === 'changed') {
123
+ handled.current = null;
124
+ setState(previous => ({...previous, busy: false}));
125
+ return;
126
+ }
118
127
  const message = labels[code as keyof typeof labels] || labels.failed;
119
128
  setState(previous => ({...previous, error: automatic && previous.error ? previous.error : message, busy: false, diff: null}));
120
129
  } finally {
121
130
  running.current = false;
131
+ // A result dropped by a newer load still ends the busy state, unless a refresh follows at once.
132
+ if (epoch.current !== ticket && !refreshPending.current && alive.current) {
133
+ setState(previous => ({...previous, busy: false}));
134
+ }
122
135
  if (automatic) {background.current = null; settle();}
123
136
  if (refreshPending.current) {
124
137
  refreshPending.current = false;
@@ -143,14 +156,26 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
143
156
  };
144
157
  controller.refresh = async (automatic = false) => {
145
158
  if (!alive.current || !current.current.repository) return;
159
+ if (!shownRef.current) {
160
+ deferred.current = true;
161
+ return;
162
+ }
146
163
  if (running.current) {
147
164
  if (!automatic) refreshPending.current = true;
148
165
  return;
149
166
  }
150
167
  await run(async () => {
151
168
  const {repository, project} = current.current;
152
- const snapshot = await repository!.read(project ?? '');
153
- return {snapshot, changes: snapshot.changes, selected: null, diff: null, commit: null};
169
+ // A save or a Git command may still be finishing its writes; read again briefly before reporting a change.
170
+ for (let attempt = 1; ; attempt++) {
171
+ try {
172
+ const snapshot = await repository!.read(project ?? '');
173
+ return {snapshot, changes: snapshot.changes, selected: null, diff: null, commit: null};
174
+ } catch (error) {
175
+ if (!(error instanceof GitPanelError && error.code === 'changed') || attempt >= 5) throw error;
176
+ await new Promise(resolve => setTimeout(resolve, 200 * attempt));
177
+ }
178
+ }
154
179
  }, automatic);
155
180
  };
156
181
  useEffect(() => {
@@ -159,13 +184,21 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
159
184
  setDirty(host.isProjectDirty());
160
185
  if (event === 'loaded' && !opening.current) {
161
186
  epoch.current++;
187
+ // An operation still running stays busy; its end, or the refresh queued after it, clears the flag.
162
188
  setState(previous => ({...previous, project: null, snapshot: null, changes: [],
163
- selected: null, diff: null, commit: null, busy: false}));
189
+ selected: null, diff: null, commit: null, busy: running.current}));
164
190
  queueMicrotask(() => void controller.refresh());
165
191
  }
166
192
  });
167
193
  return () => {alive.current = false; epoch.current++; remoteRequest.current?.abort(); unsubscribe?.();};
168
194
  }, [host]);
195
+ useEffect(() => {
196
+ shownRef.current = shown;
197
+ if (shown && deferred.current) {
198
+ deferred.current = false;
199
+ void controller.refresh();
200
+ }
201
+ }, [shown]);
169
202
  // While the panel is open, notice Git commands, agents and editors that change the repository outside the Editor.
170
203
  useEffect(() => {
171
204
  const repository = state.repository;
@@ -175,7 +208,9 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
175
208
  let checking = false;
176
209
  const check = async () => {
177
210
  const {snapshot} = current.current;
178
- if (stopped || checking || running.current || document.visibilityState === 'hidden' ||
211
+ // A tab behind another one has no boxes; it reads again when it is shown.
212
+ const hidden = !dialog.current || !dialog.current.getClientRects().length;
213
+ if (stopped || checking || running.current || document.visibilityState === 'hidden' || hidden ||
179
214
  !snapshot || current.current.repository !== repository) return;
180
215
  checking = true;
181
216
  try {
@@ -409,7 +444,7 @@ function GitPanel({vm, document, host, controller}: {vm: object; document: Docum
409
444
  </div>}
410
445
  </>;
411
446
  // In a tab the Editor stays usable beside the panel, so there is no backdrop, focus trap or Escape to close.
412
- if (slot) return createPortal(<div className="twgit-panel twgit-panel-tab" role="region" aria-label={labels.title}>{content}</div>,
447
+ if (slot) return createPortal(<div className="twgit-panel twgit-panel-tab" ref={dialog} role="region" aria-label={labels.title}>{content}</div>,
413
448
  slot.element);
414
449
  return <div className="twgit-backdrop">
415
450
  <div className="twgit-panel" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-label={labels.title}
@@ -0,0 +1,178 @@
1
+ import {execFileSync} from 'node:child_process';
2
+ import {createServer} from 'node:http';
3
+ import {once} from 'node:events';
4
+ import {randomUUID} from 'node:crypto';
5
+ import {mkdtemp, mkdir, rm, writeFile} from 'node:fs/promises';
6
+ import {tmpdir} from 'node:os';
7
+ import {join} from 'node:path';
8
+
9
+ // Git's detached auto maintenance can repack loose objects while the code under test is reading them.
10
+ const gitEnv = {...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_COUNT: '2',
11
+ GIT_CONFIG_KEY_0: 'maintenance.auto', GIT_CONFIG_VALUE_0: 'false', GIT_CONFIG_KEY_1: 'gc.auto', GIT_CONFIG_VALUE_1: '0'};
12
+ const backend = join(execFileSync('git', ['--exec-path'], {encoding: 'utf8'}).trim(), 'git-http-backend');
13
+ export const remote = 'https://github.com/fixture/work.git';
14
+ export const readDiscovery = '/info/refs?service=git-upload-pack';
15
+ export const writeDiscovery = '/info/refs?service=git-receive-pack';
16
+ const routePrefix = '/v1/git/fixture/work.git';
17
+ const maxBody = 1024 * 1024;
18
+ const pushIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
19
+
20
+ export function git(dir, ...args) {
21
+ return execFileSync('git', args, {cwd: dir, encoding: 'utf8', env: gitEnv, stdio: ['ignore', 'pipe', 'pipe']}).trim();
22
+ }
23
+
24
+ export function commit(dir, message) {
25
+ git(dir, 'add', 'project.txt');
26
+ git(dir, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-qm', message);
27
+ return git(dir, 'rev-parse', 'HEAD');
28
+ }
29
+
30
+ export async function startFixture(t) {
31
+ const root = await mkdtemp(join(tmpdir(), 'tw-git-http-'));
32
+ t.after(() => rm(root, {recursive: true, force: true}));
33
+ await mkdir(join(root, 'fixture'));
34
+ const bare = join(root, 'fixture/work.git');
35
+ const seed = join(root, 'seed');
36
+ const client = join(root, 'client');
37
+ git(root, 'init', '-q', '--bare', '-b', 'main', bare);
38
+ git(bare, 'config', 'http.receivepack', 'true');
39
+ git(root, 'init', '-q', '-b', 'main', seed);
40
+ await writeFile(join(seed, 'project.txt'), 'initial\n');
41
+ commit(seed, 'initial');
42
+ git(seed, 'push', bare, 'main');
43
+ git(root, 'clone', '-q', bare, client);
44
+ git(client, 'remote', 'set-url', 'origin', remote);
45
+ git(client, 'config', 'http.corsProxy', 'https://rejected.example.invalid');
46
+
47
+ const origins = new Set(['https://editor.example.invalid']);
48
+ const sessions = new Map([
49
+ ['fixture-read', {write: false, mode: 'desktop'}],
50
+ ['fixture-write', {write: true, mode: 'desktop'}],
51
+ ['fixture-expired', {expires: true}],
52
+ ['fixture-cloudflare', {write: true, mode: 'cloudflare'}]
53
+ ]);
54
+ const requests = [];
55
+ const upstream = [];
56
+ const pushIds = new WeakMap();
57
+ const fixture = {root, bare, seed, client, origins, sessions, requests, upstream, redirect: false, dropPush: false};
58
+ const reply = (res, status, code) => {
59
+ res.writeHead(status, {'Content-Type': 'application/json'});
60
+ res.end(JSON.stringify({code, requestId: randomUUID()}));
61
+ };
62
+
63
+ async function handle(req, res) {
64
+ const {url, method, headers} = req;
65
+ requests.push({url, method, headers});
66
+ if (headers.host !== new URL(fixture.origin).host || req.socket.remoteAddress !== '127.0.0.1' ||
67
+ !origins.has(headers.origin)) return reply(res, 403, 'FORBIDDEN');
68
+
69
+ res.setHeader('Access-Control-Allow-Origin', headers.origin);
70
+ res.setHeader('Vary', 'Origin, Access-Control-Request-Method, Access-Control-Request-Headers');
71
+ res.setHeader('Cache-Control', 'no-store');
72
+ const effectiveMethod = method === 'OPTIONS' ? headers['access-control-request-method'] : method;
73
+ const path = url.startsWith(routePrefix + '/') ? url.slice(routePrefix.length) : '';
74
+ const service = ['git-upload-pack', 'git-receive-pack'].find(service =>
75
+ (effectiveMethod === 'GET' && path === '/info/refs?service=' + service) ||
76
+ (effectiveMethod === 'POST' && path === '/' + service));
77
+ if (!service) return reply(res, 400, 'BAD_REQUEST');
78
+ const push = effectiveMethod === 'POST' && service === 'git-receive-pack';
79
+ const requestType = 'application/x-' + service + '-request';
80
+ if (method === 'OPTIONS') {
81
+ const allowed = effectiveMethod === 'GET' ? ['authorization'] : ['authorization', 'content-type'];
82
+ if (push) allowed.push('git-push-id');
83
+ const asked = (headers['access-control-request-headers'] ?? '').toLowerCase().split(',').map(name => name.trim());
84
+ if (asked.some(name => !allowed.includes(name))) return reply(res, 403, 'FORBIDDEN');
85
+ res.writeHead(204, {'Access-Control-Allow-Methods': effectiveMethod, 'Access-Control-Allow-Headers': allowed.join(', ')});
86
+ return res.end();
87
+ }
88
+ const session = headers.authorization?.startsWith('Bearer ') && sessions.get(headers.authorization.slice(7));
89
+ if (!session) return reply(res, 401, 'SESSION_REQUIRED');
90
+ if (session.expires) return reply(res, 401, 'SESSION_EXPIRED');
91
+ if (session.mode !== 'desktop' || (service === 'git-receive-pack' && !session.write)) return reply(res, 403, 'FORBIDDEN');
92
+ if (fixture.redirect) {
93
+ if (fixture.redirect === 'upstream') return reply(res, 409, 'REDIRECT_REJECTED');
94
+ res.writeHead(307, {Location: fixture.origin + '/redirect-target'});
95
+ return res.end();
96
+ }
97
+
98
+ let input;
99
+ if (method === 'POST') {
100
+ if (headers['content-type'] !== requestType) return reply(res, 400, 'BAD_REQUEST');
101
+ if (!/^\d+$/u.test(headers['content-length'] ?? '')) return reply(res, 411, 'LENGTH_REQUIRED');
102
+ const expected = Number(headers['content-length']);
103
+ if (expected > maxBody) return reply(res, 413, 'PAYLOAD_TOO_LARGE');
104
+ let size = 0;
105
+ const chunks = [];
106
+ for await (const chunk of req) {
107
+ size += chunk.length;
108
+ if (size > expected) return reply(res, 400, 'BAD_REQUEST');
109
+ chunks.push(chunk);
110
+ }
111
+ if (size !== expected) return reply(res, 400, 'BAD_REQUEST');
112
+ input = Buffer.concat(chunks);
113
+ }
114
+ const pushId = headers['git-push-id'];
115
+ if (push ? !pushIdPattern.test(pushId ?? '') || req.headersDistinct['git-push-id']?.length !== 1 :
116
+ pushId !== undefined) return reply(res, 400, 'BAD_REQUEST');
117
+ if (push) {
118
+ let claimed = pushIds.get(session);
119
+ if (!claimed) {
120
+ claimed = new Set();
121
+ pushIds.set(session, claimed);
122
+ }
123
+ if (claimed.has(pushId)) return reply(res, 409, 'PUSH_DUPLICATE');
124
+ claimed.add(pushId);
125
+ }
126
+
127
+ const [pathInfo, query = ''] = ('/fixture/work.git' + path).split('?');
128
+ // Model the credential boundary and invoke Git's CGI backend locally.
129
+ const authorization = 'Basic ' + Buffer.from('x-access-token:fixture-upstream-token').toString('base64');
130
+ upstream.push({method, path: pathInfo, query, authorization});
131
+ const output = execFileSync(backend, [], {
132
+ input,
133
+ env: {...gitEnv, GIT_PROJECT_ROOT: root, GIT_HTTP_EXPORT_ALL: '1', REQUEST_METHOD: method,
134
+ PATH_INFO: pathInfo, QUERY_STRING: query, REMOTE_USER: 'fixture-user',
135
+ CONTENT_TYPE: headers['content-type'] ?? '', CONTENT_LENGTH: headers['content-length'] ?? '0'}
136
+ });
137
+ const dropPush = fixture.dropPush && method === 'POST' && service === 'git-receive-pack';
138
+ if (dropPush && fixture.dropPush !== 'after-headers') {
139
+ res.destroy();
140
+ return;
141
+ }
142
+ const end = output.indexOf('\r\n\r\n');
143
+ if (end < 0) throw new Error('Git CGI response had no header boundary');
144
+ const lines = output.subarray(0, end).toString().split('\r\n');
145
+ const statusLine = lines.find(line => line.startsWith('Status: '));
146
+ const contentType = lines.find(line => line.startsWith('Content-Type: '));
147
+ if (contentType) res.setHeader('Content-Type', contentType.slice(14));
148
+ if (dropPush) {
149
+ res.setHeader('Content-Length', output.length - end - 3);
150
+ res.setHeader('Connection', 'close');
151
+ }
152
+ res.writeHead(statusLine ? Number(statusLine.slice(8, 11)) : 200);
153
+ res.end(output.subarray(end + 4));
154
+ }
155
+
156
+ const server = createServer((req, res) => {
157
+ handle(req, res).catch(() => {
158
+ if (!res.headersSent) reply(res, 502, 'UPSTREAM_FAILURE');
159
+ else res.destroy();
160
+ });
161
+ });
162
+ server.listen(0, '127.0.0.1');
163
+ await once(server, 'listening');
164
+ t.after(() => new Promise(resolve => {server.close(resolve); server.closeAllConnections();}));
165
+ fixture.origin = 'http://127.0.0.1:' + server.address().port;
166
+ return fixture;
167
+ }
168
+
169
+ export const proxyPath = path => routePrefix + path;
170
+
171
+ export function adapterOptions(fixture, bearer = 'fixture-write') {
172
+ const connection = {mode: 'desktop', proxyOrigin: fixture.origin};
173
+ return {
174
+ remote, connection,
175
+ getSession: async () => ({connection, bearer, expiresAt: Date.now() + 60_000}),
176
+ fetch: (url, init) => fetch(url, {...init, headers: {...Object.fromEntries(init.headers), Origin: 'https://editor.example.invalid'}})
177
+ };
178
+ }