finch-git-branch 0.3.5 → 0.3.7

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/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', {
@@ -30,9 +30,7 @@
30
30
  }
31
31
  @media (prefers-color-scheme: dark) {
32
32
  :root {
33
- --bg: #1e1e20;
34
- --elev: #262629;
35
- --hover: #303034;
33
+ --elev: var(--finch-bg-elevated, #262629);
36
34
  --text: #e8e8e9;
37
35
  --muted: #a3a3aa;
38
36
  --border: #38383d;
@@ -766,8 +764,17 @@
766
764
  },
767
765
  };
768
766
  let words = t.en;
767
+ // Attach the latest env-derived scope to every backend action. This is
768
+ // both restoration metadata and a safety boundary: if a Session/Space
769
+ // switch races with an action, the backend can reject stale repo state
770
+ // instead of executing against the previous cwd.
769
771
  const post = (type, data = {}) =>
770
- window.finch?.postMessage({ type, ...data });
772
+ window.finch?.postMessage({
773
+ type,
774
+ cwd: state.cwd,
775
+ scopeKey: state.scopeKey,
776
+ ...data,
777
+ });
771
778
  const esc = (s) =>
772
779
  String(s).replace(
773
780
  /[&<>'"]/g,
@@ -884,28 +891,39 @@
884
891
  document.querySelectorAll(".file").forEach((row) => {
885
892
  const path = row.dataset.path,
886
893
  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 = () =>
894
+ const bindFileAction = (selector, handler) => {
895
+ const button = row.querySelector(selector);
896
+ if (!button) return;
897
+ // The row itself opens Diff on pointerdown. Handle child actions in
898
+ // the same event phase so the parent never consumes the first tap.
899
+ button.onpointerdown = (event) => {
900
+ event.preventDefault();
901
+ event.stopPropagation();
902
+ void handler();
903
+ };
904
+ };
905
+ bindFileAction(".open", () =>
906
+ post("openFile", { repoPath: state.path, filePath: path }),
907
+ );
908
+ bindFileAction(".stage", () =>
890
909
  post(file.staged ? "unstage" : "stage", {
891
910
  repoPath: state.path,
892
911
  filePath: path,
912
+ }),
913
+ );
914
+ bindFileAction(".undo", async () => {
915
+ const ok = await window.finch.ui.confirm({
916
+ title: words.discard,
917
+ message: path,
918
+ variant: "danger",
893
919
  });
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",
920
+ if (ok.confirmed)
921
+ post("discard", {
922
+ repoPath: state.path,
923
+ filePath: path,
924
+ status: file.status,
901
925
  });
902
- if (ok.confirmed)
903
- post("discard", {
904
- repoPath: state.path,
905
- filePath: path,
906
- status: file.status,
907
- });
908
- };
926
+ });
909
927
  row.onpointerdown = async (event) => {
910
928
  if (event.target.closest(".file-actions")) return;
911
929
  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,17 +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
- // A panel can receive its initial bridge message before `visible` flips to
158
- // true. Treat the live handle as available so that init is never dropped;
159
- // visibility still triggers an explicit refresh below.
160
- const active = () => !unavailable;
186
+ let disposed = false;
187
+ const active = () => !disposed;
161
188
  const post = async (message) => {
162
189
  if (!active())
163
190
  return false;
@@ -165,9 +192,11 @@ export function activateScmPanel(ctx) {
165
192
  await panel.postMessage(message);
166
193
  return true;
167
194
  }
168
- catch {
169
- // The Session may have dropped its viewer between the visibility check and send.
170
- 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);
171
200
  return false;
172
201
  }
173
202
  };
@@ -177,8 +206,8 @@ export function activateScmPanel(ctx) {
177
206
  try {
178
207
  await panel.updateToolbarItem('scm-title', { label, icon });
179
208
  }
180
- catch {
181
- unavailable = true;
209
+ catch (error) {
210
+ ctx.logger.debug('SCM toolbar update deferred until next refresh', error);
182
211
  }
183
212
  };
184
213
  const sendConfig = async () => {
@@ -243,18 +272,17 @@ export function activateScmPanel(ctx) {
243
272
  try {
244
273
  await ctx.ui.showToast({ title, variant, position: 'TC' });
245
274
  }
246
- catch {
247
- unavailable = true;
275
+ catch (error) {
276
+ ctx.logger.debug('SCM toast skipped during scope transition', error);
248
277
  }
249
278
  };
250
279
  ctx.subscriptions.push(panel.onDidChangeVisibility((visible) => {
251
- if (!visible)
280
+ if (!visible || !active())
252
281
  return;
253
- unavailable = false;
254
282
  void sendConfig().then(() => refresh()).catch(() => undefined);
255
283
  }));
256
284
  ctx.subscriptions.push(panel.onDidDispose(() => {
257
- unavailable = true;
285
+ disposed = true;
258
286
  generation += 1;
259
287
  repos = [];
260
288
  }));
@@ -282,6 +310,15 @@ export function activateScmPanel(ctx) {
282
310
  await refresh();
283
311
  return;
284
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
+ }
285
322
  const repoPath = allowedRepo(repos, message.repoPath);
286
323
  if (!repoPath)
287
324
  return;
@@ -313,6 +350,17 @@ export function activateScmPanel(ctx) {
313
350
  await runGit(repoPath, ['fetch', '--prune'], 60_000);
314
351
  await toast(ctx.i18n.t('git.scm.fetch.success'));
315
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
+ }
316
364
  if (message.type === 'requestCommit') {
317
365
  const result = await ctx.ui.showModalDialog({
318
366
  title: ctx.i18n.t('git.scm.commit.title'),
@@ -350,8 +398,13 @@ export function activateScmPanel(ctx) {
350
398
  }
351
399
  if (message.type === 'openFile') {
352
400
  const file = allowedFile(repoPath, message.filePath);
353
- if (file && existsSync(file))
354
- 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
+ }
355
408
  return;
356
409
  }
357
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.5",
3
+ "version": "0.3.7",
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",