finch-git-branch 0.3.3 → 0.3.6

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.md CHANGED
@@ -32,7 +32,7 @@ The first version intentionally leaves out force-push, rebase, reset, cherry-pic
32
32
 
33
33
  ## Permissions
34
34
 
35
- This tool needs shell permission because it runs `git` commands locally. No network access needed.
35
+ This tool needs shell permission to run local `git` commands and read-only filesystem permission to inspect repository metadata and changed files. No network access needed.
36
36
 
37
37
  ## Development
38
38
 
package/README.zh-CN.md CHANGED
@@ -27,7 +27,7 @@ Git 源码管理小工具是 [Finch](https://finchwork.app/) 的扩展。Finch
27
27
 
28
28
  ## 权限
29
29
 
30
- 这个工具需要在本地执行 `git` 命令,所以需要 shell 权限。不需要联网。
30
+ 这个工具需要 shell 权限来执行本地 `git` 命令,以及只读文件权限来读取仓库元数据和变更文件。不需要联网。
31
31
 
32
32
  ## 开发
33
33
 
package/dist/index.js CHANGED
@@ -132,6 +132,7 @@ export function activate(ctx) {
132
132
  handshake: { svg: readIconSvg('handshake'), description: 'Include Finch Code co-author' },
133
133
  'git-branch': { svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></svg>', description: 'Git repository' },
134
134
  'refresh-cw': { svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>', description: 'Refresh repository status' },
135
+ 'external-link': { svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>', description: 'Open remote repository' },
135
136
  ellipsis: { svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>', description: 'More repository actions' },
136
137
  }));
137
138
  const composerAction = ctx.composerActions.register('git-branch', {
@@ -766,8 +766,17 @@
766
766
  },
767
767
  };
768
768
  let words = t.en;
769
+ // Attach the latest env-derived scope to every backend action. This is
770
+ // both restoration metadata and a safety boundary: if a Session/Space
771
+ // switch races with an action, the backend can reject stale repo state
772
+ // instead of executing against the previous cwd.
769
773
  const post = (type, data = {}) =>
770
- window.finch?.postMessage({ type, ...data });
774
+ window.finch?.postMessage({
775
+ type,
776
+ cwd: state.cwd,
777
+ scopeKey: state.scopeKey,
778
+ ...data,
779
+ });
771
780
  const esc = (s) =>
772
781
  String(s).replace(
773
782
  /[&<>'"]/g,
@@ -884,28 +893,39 @@
884
893
  document.querySelectorAll(".file").forEach((row) => {
885
894
  const path = row.dataset.path,
886
895
  file = r.files.find((f) => f.path === path);
887
- row.querySelector(".open").onclick = () =>
888
- post("openFile", { repoPath: state.path, filePath: path });
889
- row.querySelector(".stage").onclick = () =>
896
+ const bindFileAction = (selector, handler) => {
897
+ const button = row.querySelector(selector);
898
+ if (!button) return;
899
+ // The row itself opens Diff on pointerdown. Handle child actions in
900
+ // the same event phase so the parent never consumes the first tap.
901
+ button.onpointerdown = (event) => {
902
+ event.preventDefault();
903
+ event.stopPropagation();
904
+ void handler();
905
+ };
906
+ };
907
+ bindFileAction(".open", () =>
908
+ post("openFile", { repoPath: state.path, filePath: path }),
909
+ );
910
+ bindFileAction(".stage", () =>
890
911
  post(file.staged ? "unstage" : "stage", {
891
912
  repoPath: state.path,
892
913
  filePath: path,
914
+ }),
915
+ );
916
+ bindFileAction(".undo", async () => {
917
+ const ok = await window.finch.ui.confirm({
918
+ title: words.discard,
919
+ message: path,
920
+ variant: "danger",
893
921
  });
894
- const undo = row.querySelector(".undo");
895
- if (undo)
896
- undo.onclick = async () => {
897
- const ok = await window.finch.ui.confirm({
898
- title: words.discard,
899
- message: path,
900
- variant: "danger",
922
+ if (ok.confirmed)
923
+ post("discard", {
924
+ repoPath: state.path,
925
+ filePath: path,
926
+ status: file.status,
901
927
  });
902
- if (ok.confirmed)
903
- post("discard", {
904
- repoPath: state.path,
905
- filePath: path,
906
- status: file.status,
907
- });
908
- };
928
+ });
909
929
  row.onpointerdown = async (event) => {
910
930
  if (event.target.closest(".file-actions")) return;
911
931
  event.preventDefault();
package/dist/scm-panel.js CHANGED
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { existsSync, realpathSync } from 'node:fs';
3
3
  import { mkdtemp, writeFile } from 'node:fs/promises';
4
4
  import { homedir, tmpdir } from 'node:os';
5
- import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  const execFileAsync = promisify(execFile);
8
8
  function repoSignature(repos) {
@@ -147,14 +147,44 @@ async function fileAtRevision(repoPath, revision, filePath) {
147
147
  async function hasStagedChanges(repoPath) {
148
148
  return Boolean(await runGit(repoPath, ['diff', '--cached', '--name-only']));
149
149
  }
150
+ function remoteToBrowserUrl(url) {
151
+ const trimmed = url.trim();
152
+ if (!trimmed)
153
+ return undefined;
154
+ // git@github.com:user/repo.git -> https://github.com/user/repo
155
+ const sshMatch = trimmed.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
156
+ if (sshMatch) {
157
+ const [, host, path] = sshMatch;
158
+ return `https://${host}/${path.replace(/\.git$/i, '')}`;
159
+ }
160
+ // ssh://git@host/path/repo.git
161
+ const sshProtoMatch = trimmed.match(/^ssh:\/\/(?:git@)?([^/]+)\/(.+?)(?:\.git)?$/);
162
+ if (sshProtoMatch) {
163
+ const [, host, path] = sshProtoMatch;
164
+ return `https://${host}/${path.replace(/\.git$/i, '')}`;
165
+ }
166
+ // https://host/path/repo.git or http://...
167
+ if (/^https?:\/\//i.test(trimmed)) {
168
+ return trimmed.replace(/\.git$/i, '');
169
+ }
170
+ return undefined;
171
+ }
172
+ function openInBrowser(url, platform) {
173
+ if (platform === 'darwin')
174
+ execFile('open', [url]);
175
+ else if (platform === 'win32')
176
+ execFile('cmd', ['/c', 'start', '', url]);
177
+ else
178
+ execFile('xdg-open', [url]);
179
+ }
150
180
  export function activateScmPanel(ctx) {
151
181
  ctx.subscriptions.push(panelUi(ctx).onDidOpenPanel((panel) => {
152
182
  let cwd = '';
153
183
  let scopeKey = '';
154
184
  let generation = 0;
155
185
  let repos = [];
156
- let unavailable = false;
157
- const active = () => !unavailable && panel.visible;
186
+ let disposed = false;
187
+ const active = () => !disposed;
158
188
  const post = async (message) => {
159
189
  if (!active())
160
190
  return false;
@@ -162,9 +192,11 @@ export function activateScmPanel(ctx) {
162
192
  await panel.postMessage(message);
163
193
  return true;
164
194
  }
165
- catch {
166
- // The Session may have dropped its viewer between the visibility check and send.
167
- unavailable = true;
195
+ catch (error) {
196
+ // Session/Space navigation can briefly leave this scope without a
197
+ // viewer. That is retryable: only onDidDispose permanently ends the
198
+ // handle, and the page's next init/refresh handshake must still work.
199
+ ctx.logger.debug('SCM panel post deferred until next handshake', error);
168
200
  return false;
169
201
  }
170
202
  };
@@ -174,8 +206,8 @@ export function activateScmPanel(ctx) {
174
206
  try {
175
207
  await panel.updateToolbarItem('scm-title', { label, icon });
176
208
  }
177
- catch {
178
- unavailable = true;
209
+ catch (error) {
210
+ ctx.logger.debug('SCM toolbar update deferred until next refresh', error);
179
211
  }
180
212
  };
181
213
  const sendConfig = async () => {
@@ -240,18 +272,17 @@ export function activateScmPanel(ctx) {
240
272
  try {
241
273
  await ctx.ui.showToast({ title, variant, position: 'TC' });
242
274
  }
243
- catch {
244
- unavailable = true;
275
+ catch (error) {
276
+ ctx.logger.debug('SCM toast skipped during scope transition', error);
245
277
  }
246
278
  };
247
279
  ctx.subscriptions.push(panel.onDidChangeVisibility((visible) => {
248
- if (!visible)
280
+ if (!visible || !active())
249
281
  return;
250
- unavailable = false;
251
282
  void sendConfig().then(() => refresh()).catch(() => undefined);
252
283
  }));
253
284
  ctx.subscriptions.push(panel.onDidDispose(() => {
254
- unavailable = true;
285
+ disposed = true;
255
286
  generation += 1;
256
287
  repos = [];
257
288
  }));
@@ -279,6 +310,15 @@ export function activateScmPanel(ctx) {
279
310
  await refresh();
280
311
  return;
281
312
  }
313
+ // Every page action carries its current env-derived scope. If an init
314
+ // message was lost during navigation, this prevents a stale repoPath
315
+ // from running against the previous Space; refresh the new scope and
316
+ // require a fresh user action instead.
317
+ if (await setScope(message)) {
318
+ await sendConfig();
319
+ await refresh();
320
+ return;
321
+ }
282
322
  const repoPath = allowedRepo(repos, message.repoPath);
283
323
  if (!repoPath)
284
324
  return;
@@ -310,6 +350,17 @@ export function activateScmPanel(ctx) {
310
350
  await runGit(repoPath, ['fetch', '--prune'], 60_000);
311
351
  await toast(ctx.i18n.t('git.scm.fetch.success'));
312
352
  }
353
+ if (message.type === 'openRemote') {
354
+ const remoteUrl = await runGit(repoPath, ['remote', 'get-url', 'origin']).catch(() => '');
355
+ const browserUrl = remoteToBrowserUrl(remoteUrl);
356
+ if (!browserUrl) {
357
+ await toast(ctx.i18n.t('git.scm.openRemote.none'), 'error');
358
+ return;
359
+ }
360
+ openInBrowser(browserUrl, process.platform);
361
+ await toast(ctx.i18n.t('git.scm.openRemote.success'));
362
+ return;
363
+ }
313
364
  if (message.type === 'requestCommit') {
314
365
  const result = await ctx.ui.showModalDialog({
315
366
  title: ctx.i18n.t('git.scm.commit.title'),
@@ -347,8 +398,13 @@ export function activateScmPanel(ctx) {
347
398
  }
348
399
  if (message.type === 'openFile') {
349
400
  const file = allowedFile(repoPath, message.filePath);
350
- if (file && existsSync(file))
351
- await panelUi(ctx).openFilePreview(realpathSync(file));
401
+ if (file && existsSync(file)) {
402
+ const previewPath = realpathSync(file);
403
+ const htmlOptions = ['.html', '.htm'].includes(extname(previewPath).toLowerCase())
404
+ ? { htmlPreview: 'code' }
405
+ : undefined;
406
+ await panelUi(ctx).openFilePreview(previewPath, htmlOptions);
407
+ }
352
408
  return;
353
409
  }
354
410
  if (message.type === 'prepareFileDiff') {
package/i18n/en-US.json CHANGED
@@ -16,6 +16,7 @@
16
16
  { "label": "Source Control" },
17
17
  {},
18
18
  { "tooltip": "Refresh repository status" },
19
+ { "tooltip": "Open remote repository" },
19
20
  { "items": [{ "label": "Fetch" }, { "label": "Pull" }, { "label": "Push" }] }
20
21
  ]
21
22
  },
@@ -67,6 +68,8 @@
67
68
  "git.scm.pull.success": "Pulled latest changes",
68
69
  "git.scm.push.success": "Pushed to remote",
69
70
  "git.scm.fetch.success": "Fetched remote updates",
71
+ "git.scm.openRemote.success": "Opened remote repository",
72
+ "git.scm.openRemote.none": "No remote repository configured",
70
73
  "tool.create.title": "Create Git Branch",
71
74
  "tool.create.desc": "Create and switch to a new Git branch. Pass branchName to create it without a form when the name is known; otherwise omit it to collect the name in a form."
72
75
  }
package/i18n/zh-CN.json CHANGED
@@ -16,6 +16,7 @@
16
16
  { "label": "源代码管理" },
17
17
  {},
18
18
  { "tooltip": "刷新存储库状态" },
19
+ { "tooltip": "打开远程存储库" },
19
20
  { "items": [{ "label": "获取" }, { "label": "拉取" }, { "label": "推送" }] }
20
21
  ]
21
22
  },
@@ -67,6 +68,8 @@
67
68
  "git.scm.pull.success": "已拉取最新更改",
68
69
  "git.scm.push.success": "已推送到远程",
69
70
  "git.scm.fetch.success": "已获取远程更新",
71
+ "git.scm.openRemote.success": "已打开远程存储库",
72
+ "git.scm.openRemote.none": "未配置远程存储库",
70
73
  "tool.create.title": "Create Git Branch",
71
74
  "tool.create.desc": "创建并检出 Git 分支。已确定分支名时传入 branchName 参数,以静默创建;否则省略该参数以打开表单收集分支名。"
72
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "finch-git-branch",
3
- "version": "0.3.3",
3
+ "version": "0.3.6",
4
4
  "description": "Git Source Control for Finch Composer toolbar.",
5
5
  "author": {
6
6
  "name": "Finch Team",
@@ -74,6 +74,11 @@
74
74
  "icon": "ext:git-branch/refresh-cw",
75
75
  "tooltip": "Refresh repository status"
76
76
  },
77
+ {
78
+ "id": "openRemote",
79
+ "icon": "ext:git-branch/external-link",
80
+ "tooltip": "Open remote repository"
81
+ },
77
82
  {
78
83
  "type": "menu",
79
84
  "id": "sync",
@@ -98,7 +103,7 @@
98
103
  "skills": false
99
104
  },
100
105
  "permissions": {
101
- "filesystem": "none",
106
+ "filesystem": "read",
102
107
  "network": false,
103
108
  "shell": true
104
109
  }