dsh-ide-git 0.21.0 → 0.21.2

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/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-ide-git",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
4
4
  "main": "./src/index.js",
5
5
  "description": "IDE 级 Git 操作面板:分支树 + 提交图谱 + 变更/暂存/提交 + 提交详情。装了 dsh-better-sidebar 时挂成它的原生 tab(右侧栏 + 底部工作台);没装时直接挂 DSH 原生右侧栏,单独安装即可用。JetBrains 风格 Git 工具窗口 for DeepSeek Harness.",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ide-git",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
4
4
  "description": "DSH plugin: an IDE-grade Git tool window as a native dsh-better-sidebar tab — branch tree, commit graph, changes, commit details, JetBrains-style actions, in the right sidebar and the bottom panel. | DSH 插件:IDE 级 Git 工具窗口,以 dsh-better-sidebar 原生 Tab 挂载——分支树 / 提交图谱 / 变更与提交详情 / JetBrains 风格操作,右侧栏与底部面板双布局自适应。",
5
5
  "type": "module",
6
6
  "repository": {
package/src/client.js CHANGED
@@ -173,7 +173,7 @@ window.__ModuleLoader__.load({
173
173
  'action.mergeIntoCurrent': '合并到当前分支',
174
174
  'action.rebaseCurrentOnto': '将当前分支变基到此',
175
175
  'action.compare': '与当前分支比较',
176
- 'action.update': '更新(Fetch)',
176
+ 'action.update': '拉取',
177
177
  'action.push': '推送',
178
178
  'action.newTag': '新建标签...',
179
179
  'action.cherryPick': '优选(Cherry-Pick)',
@@ -515,7 +515,7 @@ window.__ModuleLoader__.load({
515
515
  'action.mergeIntoCurrent': 'Merge into current branch',
516
516
  'action.rebaseCurrentOnto': 'Rebase current branch onto this',
517
517
  'action.compare': 'Compare with current branch',
518
- 'action.update': 'Update (fetch)',
518
+ 'action.update': 'Pull',
519
519
  'action.push': 'Push',
520
520
  'action.newTag': 'New tag...',
521
521
  'action.cherryPick': 'Cherry-pick',
@@ -865,7 +865,7 @@ window.__ModuleLoader__.load({
865
865
  'action.mergeIntoCurrent': '合併到當前分支',
866
866
  'action.rebaseCurrentOnto': '將當前分支變基到此',
867
867
  'action.compare': '同當前分支比較',
868
- 'action.update': '更新(抓取)',
868
+ 'action.update': '拉取',
869
869
  'action.push': '推送',
870
870
  'action.newTag': '新建標籤...',
871
871
  'action.cherryPick': '揀選',
@@ -1207,7 +1207,7 @@ window.__ModuleLoader__.load({
1207
1207
  'action.mergeIntoCurrent': '合併到目前分支',
1208
1208
  'action.rebaseCurrentOnto': '將目前分支變基到此',
1209
1209
  'action.compare': '與目前分支比較',
1210
- 'action.update': '更新(抓取)',
1210
+ 'action.update': '拉取',
1211
1211
  'action.push': '推送',
1212
1212
  'action.newTag': '新建標籤...',
1213
1213
  'action.cherryPick': '揀選',
@@ -1549,7 +1549,7 @@ window.__ModuleLoader__.load({
1549
1549
  'action.mergeIntoCurrent': '合併到當前分支',
1550
1550
  'action.rebaseCurrentOnto': '將當前分支變基到此',
1551
1551
  'action.compare': '同當前分支比較',
1552
- 'action.update': '更新(抓取)',
1552
+ 'action.update': '拉取',
1553
1553
  'action.push': '推送',
1554
1554
  'action.newTag': '新建標籤...',
1555
1555
  'action.cherryPick': '揀選',
@@ -1891,7 +1891,7 @@ window.__ModuleLoader__.load({
1891
1891
  'action.mergeIntoCurrent': '現在のブランチにマージ',
1892
1892
  'action.rebaseCurrentOnto': '現在のブランチをここにリベース',
1893
1893
  'action.compare': '現在のブランチと比較',
1894
- 'action.update': '更新(フェッチ)',
1894
+ 'action.update': 'プル',
1895
1895
  'action.push': 'プッシュ',
1896
1896
  'action.newTag': '新しいタグ...',
1897
1897
  'action.cherryPick': 'チェリーピック',
@@ -2233,7 +2233,7 @@ window.__ModuleLoader__.load({
2233
2233
  'action.mergeIntoCurrent': '현재 브랜치로 병합',
2234
2234
  'action.rebaseCurrentOnto': '현재 브랜치를 여기로 리베이스',
2235
2235
  'action.compare': '현재 브랜치와 비교',
2236
- 'action.update': '업데이트(페치)',
2236
+ 'action.update': '풀',
2237
2237
  'action.push': '푸시',
2238
2238
  'action.newTag': '새 태그...',
2239
2239
  'action.cherryPick': '체리픽',
@@ -2575,7 +2575,7 @@ window.__ModuleLoader__.load({
2575
2575
  'action.mergeIntoCurrent': 'In aktuellen Branch mergen',
2576
2576
  'action.rebaseCurrentOnto': 'Aktuellen Branch hierauf rebasen',
2577
2577
  'action.compare': 'Mit aktuellem Branch vergleichen',
2578
- 'action.update': 'Aktualisieren (fetch)',
2578
+ 'action.update': 'Pull',
2579
2579
  'action.push': 'Push',
2580
2580
  'action.newTag': 'Neuer Tag...',
2581
2581
  'action.cherryPick': 'Cherry-Pick',
@@ -2917,7 +2917,7 @@ window.__ModuleLoader__.load({
2917
2917
  'action.mergeIntoCurrent': 'Fusionner dans la branche courante',
2918
2918
  'action.rebaseCurrentOnto': 'Rebaser la branche courante sur celle-ci',
2919
2919
  'action.compare': 'Comparer avec la branche courante',
2920
- 'action.update': 'Mettre à jour (fetch)',
2920
+ 'action.update': 'Tirer',
2921
2921
  'action.push': 'Pousser',
2922
2922
  'action.newTag': 'Nouvelle étiquette...',
2923
2923
  'action.cherryPick': 'Cherry-pick',
@@ -3259,7 +3259,7 @@ window.__ModuleLoader__.load({
3259
3259
  'action.mergeIntoCurrent': 'Влить в текущую ветку',
3260
3260
  'action.rebaseCurrentOnto': 'Перебазировать текущую ветку сюда',
3261
3261
  'action.compare': 'Сравнить с текущей веткой',
3262
- 'action.update': 'Обновить (fetch)',
3262
+ 'action.update': 'Забрать',
3263
3263
  'action.push': 'Отправить',
3264
3264
  'action.newTag': 'Новый тег...',
3265
3265
  'action.cherryPick': 'Cherry-pick',
@@ -3601,7 +3601,7 @@ window.__ModuleLoader__.load({
3601
3601
  'action.mergeIntoCurrent': 'Mesclar na branch atual',
3602
3602
  'action.rebaseCurrentOnto': 'Rebasear a branch atual sobre esta',
3603
3603
  'action.compare': 'Comparar com a branch atual',
3604
- 'action.update': 'Atualizar (fetch)',
3604
+ 'action.update': 'Puxar',
3605
3605
  'action.push': 'Enviar',
3606
3606
  'action.newTag': 'Nova tag...',
3607
3607
  'action.cherryPick': 'Cherry-pick',
@@ -3943,7 +3943,7 @@ window.__ModuleLoader__.load({
3943
3943
  'action.mergeIntoCurrent': 'Unisci nel branch corrente',
3944
3944
  'action.rebaseCurrentOnto': 'Rebase del branch corrente su questo',
3945
3945
  'action.compare': 'Confronta con il branch corrente',
3946
- 'action.update': 'Aggiorna (fetch)',
3946
+ 'action.update': 'Pull',
3947
3947
  'action.push': 'Push',
3948
3948
  'action.newTag': 'Nuovo tag...',
3949
3949
  'action.cherryPick': 'Cherry-pick',
@@ -4285,7 +4285,7 @@ window.__ModuleLoader__.load({
4285
4285
  'action.mergeIntoCurrent': 'Samenvoegen in huidige branch',
4286
4286
  'action.rebaseCurrentOnto': 'Huidige branch rebasen op deze',
4287
4287
  'action.compare': 'Vergelijken met huidige branch',
4288
- 'action.update': 'Bijwerken (fetch)',
4288
+ 'action.update': 'Pull',
4289
4289
  'action.push': 'Pushen',
4290
4290
  'action.newTag': 'Nieuwe tag...',
4291
4291
  'action.cherryPick': 'Cherry-pick',
@@ -4627,7 +4627,7 @@ window.__ModuleLoader__.load({
4627
4627
  'action.mergeIntoCurrent': 'Scal z bieżącą gałęzią',
4628
4628
  'action.rebaseCurrentOnto': 'Zrebase’uj bieżącą gałąź na tę',
4629
4629
  'action.compare': 'Porównaj z bieżącą gałęzią',
4630
- 'action.update': 'Odśwież (fetch)',
4630
+ 'action.update': 'Ściągnij',
4631
4631
  'action.push': 'Wypchnij',
4632
4632
  'action.newTag': 'Nowy tag...',
4633
4633
  'action.cherryPick': 'Cherry-pick',
@@ -4969,7 +4969,7 @@ window.__ModuleLoader__.load({
4969
4969
  'action.mergeIntoCurrent': 'Sammanfoga in i aktuell gren',
4970
4970
  'action.rebaseCurrentOnto': 'Rebasa aktuell gren mot denna',
4971
4971
  'action.compare': 'Jämför med aktuell gren',
4972
- 'action.update': 'Uppdatera (fetch)',
4972
+ 'action.update': 'Pull',
4973
4973
  'action.push': 'Pusha',
4974
4974
  'action.newTag': 'Ny tagg...',
4975
4975
  'action.cherryPick': 'Cherry-pick',
@@ -5311,7 +5311,7 @@ window.__ModuleLoader__.load({
5311
5311
  'action.mergeIntoCurrent': 'Geçerli dala birleştir',
5312
5312
  'action.rebaseCurrentOnto': 'Geçerli dalı bunun üzerine rebase et',
5313
5313
  'action.compare': 'Geçerli dalla karşılaştır',
5314
- 'action.update': 'Güncelle (fetch)',
5314
+ 'action.update': 'Pull',
5315
5315
  'action.push': 'Gönder',
5316
5316
  'action.newTag': 'Yeni etiket...',
5317
5317
  'action.cherryPick': 'Cherry-pick',
@@ -5653,7 +5653,7 @@ window.__ModuleLoader__.load({
5653
5653
  'action.mergeIntoCurrent': 'Gabungkan ke branch saat ini',
5654
5654
  'action.rebaseCurrentOnto': 'Rebase branch saat ini ke ini',
5655
5655
  'action.compare': 'Bandingkan dengan branch saat ini',
5656
- 'action.update': 'Perbarui (fetch)',
5656
+ 'action.update': 'Pull',
5657
5657
  'action.push': 'Push',
5658
5658
  'action.newTag': 'Tag baru...',
5659
5659
  'action.cherryPick': 'Cherry-pick',
@@ -5995,7 +5995,7 @@ window.__ModuleLoader__.load({
5995
5995
  'action.mergeIntoCurrent': 'Hợp nhất vào nhánh hiện tại',
5996
5996
  'action.rebaseCurrentOnto': 'Rebase nhánh hiện tại lên nhánh này',
5997
5997
  'action.compare': 'So sánh với nhánh hiện tại',
5998
- 'action.update': 'Cập nhật (fetch)',
5998
+ 'action.update': 'Pull',
5999
5999
  'action.push': 'Push',
6000
6000
  'action.newTag': 'Thẻ mới...',
6001
6001
  'action.cherryPick': 'Cherry-pick',
@@ -6337,7 +6337,7 @@ window.__ModuleLoader__.load({
6337
6337
  'action.mergeIntoCurrent': 'الدمج في الفرع الحالي',
6338
6338
  'action.rebaseCurrentOnto': 'إعادة تأسيس الفرع الحالي على هذا',
6339
6339
  'action.compare': 'المقارنة مع الفرع الحالي',
6340
- 'action.update': 'تحديث (fetch)',
6340
+ 'action.update': 'سحب',
6341
6341
  'action.push': 'دفع',
6342
6342
  'action.newTag': 'وسم جديد...',
6343
6343
  'action.cherryPick': 'انتقاء',
@@ -6679,7 +6679,7 @@ window.__ModuleLoader__.load({
6679
6679
  'action.mergeIntoCurrent': 'वर्तमान शाखा में मर्ज करें',
6680
6680
  'action.rebaseCurrentOnto': 'वर्तमान शाखा को इस पर रीबेस करें',
6681
6681
  'action.compare': 'वर्तमान शाखा से तुलना करें',
6682
- 'action.update': 'अद्यतन करें (fetch)',
6682
+ 'action.update': 'पुल',
6683
6683
  'action.push': 'पुश',
6684
6684
  'action.newTag': 'नया टैग...',
6685
6685
  'action.cherryPick': 'चेरी-पिक',
@@ -7021,7 +7021,7 @@ window.__ModuleLoader__.load({
7021
7021
  'action.mergeIntoCurrent': 'รวมเข้าแบรนช์ปัจจุบัน',
7022
7022
  'action.rebaseCurrentOnto': 'รีเบสแบรนช์ปัจจุบันไปที่นี้',
7023
7023
  'action.compare': 'เปรียบเทียบกับแบรนช์ปัจจุบัน',
7024
- 'action.update': 'อัปเดต (fetch)',
7024
+ 'action.update': 'Pull',
7025
7025
  'action.push': 'พุช',
7026
7026
  'action.newTag': 'แท็กใหม่...',
7027
7027
  'action.cherryPick': 'เชอร์รีพิก',
@@ -7425,6 +7425,19 @@ window.__ModuleLoader__.load({
7425
7425
  function isAuthRejection(status) {
7426
7426
  return status === 401 || status === 403
7427
7427
  }
7428
+
7429
+ /* Whether a proxy answer looks like OUR route (a JSON object). With no
7430
+ remote-web-ui installed the SPA static fallback serves /remote/<path> as
7431
+ '405 Method Not Allowed' with an EMPTY body (issue #20: on a plain-HTTP
7432
+ LAN page the panel surfaced a bare 'HTTP 405' instead of the real
7433
+ 403 refusal). A non-JSON retry therefore proves there is no proxy, and
7434
+ the original answer must be what the caller sees. Cloning leaves the
7435
+ caller's own body untouched. */
7436
+ async function answersJson(response) {
7437
+ let body = null
7438
+ try { body = await response.clone().json() } catch (error) { return false }
7439
+ return body !== null && typeof body === 'object'
7440
+ }
7428
7441
  /* ---- end remote channel fallback core ---- */
7429
7442
 
7430
7443
  /* Sticky remote-channel mode: once the proxy has answered, later calls go
@@ -7446,13 +7459,15 @@ window.__ModuleLoader__.load({
7446
7459
  } else {
7447
7460
  response = await fetch(direct, init)
7448
7461
  if (isAuthRejection(response.status) === true) {
7449
- /* Off the origin's trust path: try the pairing proxy once. With no
7450
- remote-web-ui installed the proxy path 404s — the retry then also
7451
- fails, and the ORIGINAL rejection is what the caller sees (a
7452
- proxy answer that is itself an auth rejection is discarded the
7453
- same way, so a genuine cross-site refusal can never be masked). */
7462
+ /* Off the origin's trust path: try the pairing proxy once. It has to
7463
+ answer like our route (a JSON body) to take over: with no
7464
+ remote-web-ui installed the SPA fallback serves the retry as 405
7465
+ with an empty body, and that non-answer must not outrank the
7466
+ ORIGINAL rejection (issue #20). A proxy answer that is itself an
7467
+ auth rejection is discarded the same way, so a genuine refusal can
7468
+ never be masked. */
7454
7469
  const retried = await fetch(remoteFallbackUrl(direct), init)
7455
- if (isAuthRejection(retried.status) !== true) {
7470
+ if (isAuthRejection(retried.status) !== true && (await answersJson(retried)) === true) {
7456
7471
  viaRemoteChannel = true
7457
7472
  response = retried
7458
7473
  }
@@ -9373,6 +9388,21 @@ window.__ModuleLoader__.load({
9373
9388
  try { window.localStorage.setItem(LINEDIFF_KEY, JSON.stringify(prefs)) } catch (error) { void error }
9374
9389
  }
9375
9390
 
9391
+ /* The changes view mode (flat vs by directory) is a habit too, not tab
9392
+ state: one localStorage home read on mount, so moving between
9393
+ repositories or reopening the panel keeps the view the user chose
9394
+ instead of snapping back to flat (v0.21.2). */
9395
+ const CHANGES_GROUP_KEY = 'dsh-ide-git.groupBy.v1'
9396
+ function readChangesGroupBy() {
9397
+ try {
9398
+ return window.localStorage.getItem(CHANGES_GROUP_KEY) === 'dir' ? 'dir' : 'flat'
9399
+ } catch (error) { void error }
9400
+ return 'flat'
9401
+ }
9402
+ function rememberChangesGroupBy(value) {
9403
+ try { window.localStorage.setItem(CHANGES_GROUP_KEY, value === 'dir' ? 'dir' : 'flat') } catch (error) { void error }
9404
+ }
9405
+
9376
9406
  /* The seed blocks the official review uses, resolved once. Every consumer
9377
9407
  null-checks: a host whose primitives predate an export keeps working
9378
9408
  through the hand-drawn fallbacks (labels instead of icons, a plain
@@ -10793,10 +10823,9 @@ window.__ModuleLoader__.load({
10793
10823
  },
10794
10824
  E('span', { className: 'dig-row-icon dig-tone-' + tone + (starred === true ? ' dig-star-filled' : '') }, E(Icon, { name: glyph, size: 12 })),
10795
10825
  E('span', { className: 'dig-row-label' }, props.label === undefined ? entry.name : props.label),
10796
- // Inline tracking target (issue #18): the title already said "name →
10797
- // upstream", but nobody hovers a tree to learn a binding. Local rows
10798
- // show it as muted small text; remote rows ARE the upstream.
10799
- kind !== 'remote' && kind !== 'tag' && entry.upstream !== null && entry.upstream !== undefined ? E('span', { className: 'dig-row-upstream' }, '→ ' + entry.upstream) : null,
10826
+ // The inline "→ upstream" target was removed (v0.21.2): it ate the
10827
+ // label's width so long branch names truncated early, and the binding
10828
+ // is still one hover away (the row title keeps "name → upstream").
10800
10829
  // A bare 'W' told nobody anything (reported right after the section counts):
10801
10830
  // it means this branch is checked out in ANOTHER working tree, which is why
10802
10831
  // git refuses to check it out here. An icon plus a spelled-out tooltip says so.
@@ -12713,7 +12742,14 @@ window.__ModuleLoader__.load({
12713
12742
  rail action, invariant 14) can drive the same groupBy and the sort
12714
12743
  order it introduces — one source of truth, wherever the click came
12715
12744
  from. sortKey 'git' keeps git's own status order. */
12716
- const [groupBy, setGroupBy] = useState('flat')
12745
+ /* Both the panel and the ⋯ menu write through this wrapper, so the
12746
+ choice survives a remount (the menu used to be the only writer that
12747
+ did not persist, and a repo/session switch silently reset it). */
12748
+ const [groupBy, setGroupByState] = useState(readChangesGroupBy)
12749
+ const setGroupBy = useCallback((value) => {
12750
+ setGroupByState(value)
12751
+ rememberChangesGroupBy(value)
12752
+ }, [])
12717
12753
  const [sortKey, setSortKey] = useState('git')
12718
12754
  /* The Git-output drawer (VS Code showOutput): null = closed, an array =
12719
12755
  the entries it currently shows. Opened on demand, refreshed by hand or
@@ -12949,9 +12985,15 @@ window.__ModuleLoader__.load({
12949
12985
 
12950
12986
  const run = useCallback(async (method, payload) => {
12951
12987
  const result = await guard(() => request(method, Object.assign({}, base, payload)))
12952
- if (result !== undefined) await refresh()
12988
+ if (result !== undefined) {
12989
+ await refresh()
12990
+ /* v0.21.2: the composer pill is a separate tree; without this it only
12991
+ learned about an in-panel checkout/commit at the next 30s poll. The
12992
+ panel's own watcher skips source 'panel' (refresh already ran). */
12993
+ if (repoRoot !== null) notifyBranchSwitched(sessionId, repoRoot, 'panel')
12994
+ }
12953
12995
  return result
12954
- }, [base, guard, refresh])
12996
+ }, [base, guard, refresh, sessionId, repoRoot])
12955
12997
 
12956
12998
  const pickRepo = useCallback((path) => {
12957
12999
  setRepoRoot(path)
@@ -12984,8 +13026,9 @@ window.__ModuleLoader__.load({
12984
13026
  if (sid !== sessionId || typeof root !== 'string' || root === '') return
12985
13027
  setRepoRoot((current) => (current === root ? current : pickRepo(root)))
12986
13028
  }
12987
- const onBranch = () => {
12988
- if (repoRoot === null) return
13029
+ const onBranch = (sid, root, source) => {
13030
+ if (sid !== sessionId || source === 'panel') return
13031
+ if (repoRoot === null || root !== repoRoot) return
12989
13032
  void refresh()
12990
13033
  }
12991
13034
  composerRepoWatchers.add(onRepo)
@@ -13436,8 +13479,15 @@ window.__ModuleLoader__.load({
13436
13479
  ].filter((item) => item !== null))
13437
13480
  return
13438
13481
  }
13482
+ const updateItem = { id: 'update', icon: 'fetch', tone: 'cyan', label: t('action.update'), run: () => { void run('fetch', { prune: true }) } }
13483
+ const pushItem = { id: 'push', icon: 'push', tone: 'success', label: t('action.push'), disabled: blocked, reason: t('operation.hint'), run: () => setDialog({ kind: 'push' }) }
13439
13484
  const items = [
13440
13485
  { id: 'checkout', icon: 'checkout', tone: 'accent', label: t('action.checkout'), disabled: entry.head === true || entry.tag === true || blocked, reason: blocked ? t('operation.hint') : reason, run: () => { void checkout(entry) } },
13486
+ // v0.21.2: on a LOCAL row the two high-frequency verbs sit right under
13487
+ // 签出 (2nd/3rd) instead of past 删除; remote/tag rows keep the old
13488
+ // tail placement (their menu is about the ref, not the working tree).
13489
+ isLocal ? updateItem : null,
13490
+ isLocal ? pushItem : null,
13441
13491
  isLocal ? { id: 'rebase', icon: 'compare', tone: 'violet', label: t('action.rebaseCurrentOnto'), disabled: entry.head === true || blocked, reason: blocked ? t('operation.hint') : reason, run: () => { void run('rebase', { onto: entry.name }) } } : null,
13442
13492
  isLocal ? { id: 'merge', icon: 'compare', tone: 'accent', label: t('action.mergeIntoCurrent'), disabled: entry.head === true || blocked, reason: blocked ? t('operation.hint') : reason, run: () => { void run('merge', { branch: entry.name }) } } : null,
13443
13493
  { id: 'compare', icon: 'filter', tone: 'violet', label: t('action.compare'), disabled: entry.head === true || current === '', reason: reason, run: () => { void compareWith(current, entry.name) } },
@@ -13479,8 +13529,8 @@ window.__ModuleLoader__.load({
13479
13529
  },
13480
13530
  }),
13481
13531
  null,
13482
- { id: 'update', icon: 'fetch', tone: 'cyan', label: t('action.update'), run: () => { void run('fetch', { prune: true }) } },
13483
- { id: 'push', icon: 'push', tone: 'success', label: t('action.push'), disabled: blocked, reason: t('operation.hint'), run: () => setDialog({ kind: 'push' }) },
13532
+ isLocal ? null : updateItem,
13533
+ isLocal ? null : pushItem,
13484
13534
  ].filter((item) => item !== null)
13485
13535
  openMenuAt(event, items)
13486
13536
  }, [branches, checkout, run, t, compareWith, toggleFavoriteBranch, openMenuAt, favorites, blocked, sendGitSummaryToChat, repoLabel, copyValues])
@@ -14997,7 +15047,15 @@ window.__ModuleLoader__.load({
14997
15047
  tone) via the host's body[data-ds-dark-theme] switch. Selection reads
14998
15048
  as gray on both themes, wallpaper/frost stays visible, never white. */
14999
15049
  '.dig-row-selected{background:color-mix(in srgb,var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover)) 58%,var(--dsw-static-neutral-bluish-75,var(--dsw-alias-bg-layer-3)) 42%);color:var(--dsw-alias-label-primary);box-shadow:inset 2px 0 0 var(--dsw-alias-brand-primary)}',
15000
- 'body[data-ds-dark-theme] .dig-row-selected{background:color-mix(in srgb,var(--dsw-alias-interactive-bg-active,var(--dsw-alias-interactive-bg-hover)) 58%,var(--dsw-static-neutral-bluish-800,var(--dsw-alias-bg-layer-3)) 42%)}',
15050
+ /* v0.21.2: the checked-out row is amber; selecting it must not wash that
15051
+ out to primary — the selection is a frame, not a recolour. */
15052
+ '.dig-row-head.dig-row-selected{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary))}',
15053
+ /* v0.21.2 (user): on the dark theme the 58/42 charcoal mix still read as
15054
+ a bright frame over a wallpaper — the selection should be a BLACK frame
15055
+ that the wallpaper shows through, not a lit one. rgba(0,0,0,.55) is
15056
+ opaque enough to hold the amber/primary label, transparent enough to
15057
+ stay in the any-background story. The light theme keeps the host gray. */
15058
+ 'body[data-ds-dark-theme] .dig-row-selected{background:rgba(0,0,0,.55)}',
15001
15059
  '.dig-row-remote .dig-row-label{color:var(--dsw-alias-label-secondary);font-weight:500}',
15002
15060
  '.dig-row-tag .dig-row-label{color:var(--dsw-alias-state-warn-primary,var(--dsw-alias-brand-primary));font-weight:500}',
15003
15061
  '.dig-row-head .dig-row-label{font-weight:600}',
@@ -15005,7 +15063,6 @@ window.__ModuleLoader__.load({
15005
15063
  '.dig-row-label{overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0;font-weight:600}',
15006
15064
  // Inline tracking target (issue #18): flex:none so it never starves the
15007
15065
  // label, capped and ellipsised so a long origin/… cannot push the badges.
15008
- '.dig-row-upstream{flex:none;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;font-weight:400;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary))}',
15009
15066
  '.dig-row-sub{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis}',
15010
15067
  '.dig-row-dir{margin-left:auto;opacity:.75}',
15011
15068
  '.dig-badge{font-size:10px;padding:0 5px;border-radius:999px;corner-shape:round;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);flex:none}',
@@ -15455,7 +15512,9 @@ window.__ModuleLoader__.load({
15455
15512
  right repository without any extra wiring. */
15456
15513
 
15457
15514
  const composerRepoWatchers = new Set() /* (sessionId, repoRoot) */
15458
- const composerBranchWatchers = new Set() /* (sessionId, repoRoot) */
15515
+ /* (sessionId, repoRoot, source) — source is 'composer' | 'panel' so the
15516
+ seat that performed the switch can skip its own echo (v0.21.2). */
15517
+ const composerBranchWatchers = new Set()
15459
15518
 
15460
15519
  function rememberRepoShared(sessionId, repoRoot) {
15461
15520
  rememberRepo(sessionId, repoRoot)
@@ -15464,9 +15523,9 @@ window.__ModuleLoader__.load({
15464
15523
  }
15465
15524
  }
15466
15525
 
15467
- function notifyBranchSwitched(sessionId, repoRoot) {
15526
+ function notifyBranchSwitched(sessionId, repoRoot, source) {
15468
15527
  for (const watcher of composerBranchWatchers) {
15469
- try { watcher(sessionId, repoRoot) } catch (error) { void error }
15528
+ try { watcher(sessionId, repoRoot, source) } catch (error) { void error }
15470
15529
  }
15471
15530
  }
15472
15531
 
@@ -15901,7 +15960,10 @@ window.__ModuleLoader__.load({
15901
15960
  /* A branch switched in the panel (or here) refreshes the summary now
15902
15961
  instead of at the next poll tick. */
15903
15962
  useEffect(() => {
15904
- const watcher = () => { setTick((value) => value + 1) }
15963
+ const watcher = (sid, root, source) => {
15964
+ if (sid !== sessionId || source === 'composer') return
15965
+ setTick((value) => value + 1)
15966
+ }
15905
15967
  composerBranchWatchers.add(watcher)
15906
15968
  return () => { composerBranchWatchers.delete(watcher) }
15907
15969
  }, [sessionId])
@@ -15979,7 +16041,7 @@ window.__ModuleLoader__.load({
15979
16041
  await request('checkout', { cwd: cwd, repoRoot: repoRoot, branch: name, confirm: true })
15980
16042
  setMenu(null)
15981
16043
  setTick((value) => value + 1)
15982
- notifyBranchSwitched(sessionId, repoRoot)
16044
+ notifyBranchSwitched(sessionId, repoRoot, 'composer')
15983
16045
  } catch (caught) {
15984
16046
  setMenu(null)
15985
16047
  setError(caught instanceof Error ? caught.message : String(caught))
package/src/index.js CHANGED
@@ -2086,6 +2086,61 @@ function headerOf(req, name) {
2086
2086
  return value
2087
2087
  }
2088
2088
 
2089
+ /* Normalize an authority ("host[:port]" or an absolute URL) to
2090
+ "hostname:port", filling the scheme default port when it is implicit.
2091
+ Returns '' for anything unparseable (including the "null" opaque origin). */
2092
+ function authorityKey(value, protocol) {
2093
+ const text = String(value === undefined || value === null ? '' : value).trim()
2094
+ if (text === '') return ''
2095
+ let hostname = text
2096
+ let port = ''
2097
+ let scheme = protocol
2098
+ if (text.includes('://') === true) {
2099
+ let parsed = null
2100
+ try { parsed = new URL(text) } catch (error) { parsed = null }
2101
+ if (parsed === null) return ''
2102
+ hostname = parsed.hostname
2103
+ port = parsed.port
2104
+ scheme = parsed.protocol
2105
+ } else {
2106
+ const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(text)
2107
+ if (bracketed !== null) {
2108
+ hostname = bracketed[1]
2109
+ port = bracketed[2] === undefined ? '' : bracketed[2]
2110
+ } else {
2111
+ const plain = /^([^:]+)(?::(\d+))?$/.exec(text)
2112
+ if (plain === null) return ''
2113
+ hostname = plain[1]
2114
+ port = plain[2] === undefined ? '' : plain[2]
2115
+ }
2116
+ }
2117
+ if (hostname === '') return ''
2118
+ if (port === '') port = scheme === 'https:' ? '443' : '80'
2119
+ return hostname.toLowerCase() + ':' + port
2120
+ }
2121
+
2122
+ /* Same-origin fallback for browsers that send no Sec-Fetch-* headers. A page
2123
+ served over plain HTTP from a non-loopback address is not a secure context,
2124
+ and the fetch-metadata headers are omitted there (issue #20: the git panel
2125
+ answered 403 on a LAN address). Origin and Referer are forbidden headers a
2126
+ page cannot forge — a cross-site request always carries the caller's own
2127
+ origin — so comparing either against the Host header restores the
2128
+ same-origin fence without trusting anything the request body claims. */
2129
+ function sameOriginOf(req, host) {
2130
+ const hostKey = authorityKey(host, 'http:')
2131
+ if (hostKey === '') return false
2132
+ /* A Host header without a port says nothing about the scheme, so either
2133
+ scheme default may be the one the browser used in Origin. */
2134
+ const candidates = new Set([hostKey])
2135
+ if (/:\d+$/.test(String(host).trim()) !== true) candidates.add(authorityKey(host, 'https:'))
2136
+ const matches = (value) => {
2137
+ const key = authorityKey(value, 'http:')
2138
+ return key !== '' && candidates.has(key)
2139
+ }
2140
+ if (matches(headerOf(req, 'origin')) === true) return true
2141
+ return matches(headerOf(req, 'referer'))
2142
+ }
2143
+
2089
2144
  /** Loopback authority, or a same-origin browser request (desktop shell / remote UI). */
2090
2145
  function isTrusted(req) {
2091
2146
  const host = headerOf(req, 'host')
@@ -2094,7 +2149,8 @@ function isTrusted(req) {
2094
2149
  if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0') return true
2095
2150
  if (/^127\./.test(hostname)) return true
2096
2151
  const site = headerOf(req, 'sec-fetch-site')
2097
- return site === 'same-origin' || site === 'same-site'
2152
+ if (site === 'same-origin' || site === 'same-site') return true
2153
+ return sameOriginOf(req, host)
2098
2154
  }
2099
2155
 
2100
2156
  function sendJson(res, status, body) {
@@ -2136,7 +2192,13 @@ function readJsonBody(req, limit = 1 << 20) {
2136
2192
 
2137
2193
  async function handle(req, res) {
2138
2194
  if (!isTrusted(req)) {
2139
- sendJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
2195
+ sendJson(res, 403, {
2196
+ ok: false,
2197
+ error: {
2198
+ code: 'forbidden',
2199
+ message: 'forbidden: a non-loopback request must be same-origin (open this host from its own page) or come through the remote pairing proxy',
2200
+ },
2201
+ })
2140
2202
  return
2141
2203
  }
2142
2204
  if (req.method !== 'POST') {