dsh-ide-git 0.19.0 → 0.19.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.19.0",
3
+ "version": "0.19.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.19.0",
3
+ "version": "0.19.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
@@ -11035,7 +11035,7 @@ window.__ModuleLoader__.load({
11035
11035
  }, E(Icon, { name: 'more', size: 18 })))
11036
11036
  /* The selection action bar: visible only while a multi-select exists, so
11037
11037
  the mouse-only path (no right-click, no keyboard) keeps full power. */
11038
- const selectionBar = selection.size === 0 ? null : E('div', { className: 'dig-selection-bar' },
11038
+ const selectionBar = selection.size === 0 ? null : E('div', { className: 'dig-selection-bar', 'data-cordis-panel': '' },
11039
11039
  E('span', { className: 'dig-selection-count' }, fill(t('selection.count'), { n: selection.size })),
11040
11040
  props.onBulkStage === undefined ? null : E('button', { type: 'button', className: 'dig-link', onClick: () => props.onBulkStage() }, t('action.stage')),
11041
11041
  props.onBulkUnstage === undefined ? null : E('button', { type: 'button', className: 'dig-link', onClick: () => props.onBulkUnstage() }, t('action.unstage')),
@@ -11068,8 +11068,12 @@ window.__ModuleLoader__.load({
11068
11068
  visibleItems.map((item) => E(Fragment, { key: item.key }, item.node)),
11069
11069
  winLast < changeItems.length ? E('div', { key: 'pad-bottom', style: { height: String(changeOffsets[changeItems.length] - changeOffsets[winLast]) + 'px' } }) : null,
11070
11070
  ]),
11071
+ /* In-flow BETWEEN the list and the composer: a first selection
11072
+ shrinks the viewport bottom edge without moving the list content
11073
+ (scrollTop untouched), so the v0.18.3 dblclick precision holds —
11074
+ and nothing on the list (group head, rows) is ever covered. */
11075
+ selectionBar,
11071
11076
  composer),
11072
- selectionBar,
11073
11077
  marquee === null ? null : E('div', {
11074
11078
  className: 'dig-marquee',
11075
11079
  style: { left: marquee.x + 'px', top: marquee.y + 'px', width: marquee.w + 'px', height: marquee.h + 'px' },
@@ -12021,9 +12025,14 @@ window.__ModuleLoader__.load({
12021
12025
  roll live state back to a stale snapshot. */
12022
12026
  const cachedPage = readPaintPage(repoRoot)
12023
12027
  if (cachedPage !== undefined) {
12024
- if (summary === null) setSummary(cachedPage.summary)
12025
- if (branches === null) setBranches(cachedPage.branches)
12026
- if (commits.length === 0) { setCommits(cachedPage.commits); setHasMore(cachedPage.hasMore) }
12028
+ /* Field-wise: a pill may have painted only its summary (the pill
12029
+ merges its own two fields alone), so branches/commits can be
12030
+ absent from the page. Seeding undefined into state would crash
12031
+ the branch-tree render (undefined.remotes) — skip that field
12032
+ and let refresh() fetch it as usual. */
12033
+ if (summary === null && cachedPage.summary !== undefined) setSummary(cachedPage.summary)
12034
+ if (branches === null && cachedPage.branches !== undefined) setBranches(cachedPage.branches)
12035
+ if (commits.length === 0 && Array.isArray(cachedPage.commits)) { setCommits(cachedPage.commits); setHasMore(cachedPage.hasMore === true) }
12027
12036
  }
12028
12037
  void refresh()
12029
12038
  return undefined
@@ -13005,7 +13014,7 @@ window.__ModuleLoader__.load({
13005
13014
  }
13006
13015
 
13007
13016
  /* Remotes themselves, for "pull from" / "push to" style entries. */
13008
- const remoteNames = branches === null ? [] : branches.remotes.map((entry) => entry.name === undefined ? String(entry) : entry.name)
13017
+ const remoteNames = branches == null ? [] : branches.remotes.map((entry) => entry.name === undefined ? String(entry) : entry.name)
13009
13018
 
13010
13019
  const showWorkingDiff = () => {
13011
13020
  const changes = summary === null ? null : summary.changes
@@ -14123,12 +14132,26 @@ window.__ModuleLoader__.load({
14123
14132
  VIEWPORT-space .dig-changes (invariant 13: position:fixed would be
14124
14133
  captured by the workbench's contain:layout and flung off-panel). */
14125
14134
  '.dig-marquee{position:absolute;border:1px solid var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent);pointer-events:none;z-index:6}',
14126
- /* The selection bar FLOATS over the top of the list (absolute, solid
14127
- surface): as an in-flow bar it shoved the whole list down one row on
14128
- the first selection — the second click of every double-click then
14129
- landed on the shifted group head and the dblclick died (v0.18.3). */
14130
- '.dig-selection-bar{position:absolute;top:0;left:0;right:0;z-index:6;display:flex;align-items:center;gap:12px;padding:3px 10px;font-size:11px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1));border-bottom:1px solid var(--dsw-alias-border-subtle,transparent);box-shadow:var(--dsw-elevation-prominent,0 4px 14px rgba(0,0,0,.3))}',
14131
- '.dig-selection-bar .dig-link{margin-left:0}',
14135
+ /* The selection action bar (v0.19.1 redesign). It used to FLOAT over the
14136
+ top of the list (absolute, v0.18.3): that covered the group head and
14137
+ the first rows, and on transparency skins (dsh-any-background) its
14138
+ background went see-through, so its own text blended into the rows
14139
+ underneath — unreadable. Now it is an IN-FLOW island between the list
14140
+ and the composer: nothing is covered, and a first selection only
14141
+ shrinks the viewport bottom edge (scrollTop untouched), which keeps
14142
+ the v0.18.3 dblclick precision. The surface rides the official menu
14143
+ token; data-cordis-panel (set at the render site) lets transparency
14144
+ skins sync it exactly like the official menus. */
14145
+ /* Local alias restore: dsh-any-background re-declares --dsw-alias-bg-layer-1/2/3
14146
+ on [data-conversation-composer-overlay] (the session seat) as its trajectory
14147
+ vars, which are absent when that feature is off — the seat inherits
14148
+ guaranteed-invalid values, and var() fallbacks do NOT rescue IVCT, so the
14149
+ whole background chain below resolves to transparent. Re-declaring the two
14150
+ aliases from the official static token (the exact source the light theme's
14151
+ own alias defs point at, theme-correct in dark too) makes the chain valid
14152
+ again regardless of any-bg's injection state. */
14153
+ '.dig-selection-bar{--dsw-alias-bg-layer-1:var(--dsw-static-neutral-bluish-00,#fff);--dsw-alias-bg-layer-2:var(--dsw-static-neutral-bluish-00,#fff);display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:5px 10px;margin:6px 0;font-size:11px;line-height:16px;border-radius:var(--dsw-radius-sm,8px);background:var(--dsh-any-op-menu-cordis,var(--dsw-alias-bg-layer-2));border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-subtle,transparent));box-shadow:var(--dsw-elevation-prominent,0 4px 14px rgba(0,0,0,.25))}',
14154
+ '.dig-selection-bar .dig-link{margin-left:0;padding:2px 0}',
14132
14155
  '.dig-selection-bar .dig-link-danger{color:var(--dsw-alias-state-error-primary)}',
14133
14156
  '.dig-selection-count{color:var(--dsw-alias-label-secondary);white-space:nowrap}',
14134
14157
  /* Focusable lists keep their own selection highlight as the focus
@@ -14333,14 +14356,24 @@ window.__ModuleLoader__.load({
14333
14356
  '.dig-cs-behind{color:var(--dsw-alias-state-business-primary)}',
14334
14357
  '.dig-cs-ahead{color:var(--dsw-alias-state-success-primary)}',
14335
14358
  '.dig-cs-error{display:inline-flex;align-items:center;color:var(--dsw-alias-state-error-primary);font-size:12px;padding:0 3px;cursor:help}',
14336
- '.dig-cs-menu{position:absolute;top:calc(100% + 6px);left:0;z-index:60;min-width:220px;max-width:320px;max-height:320px;overflow:auto;padding:6px;border-radius:var(--dsw-radius-lg,12px);background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1)));backdrop-filter:var(--dsw-menu-backdrop-filter,blur(20px));box-shadow:var(--dsw-elevation-prominent,var(--dsw-elevation-dimmed,0 8px 24px rgba(0,0,0,.2)));color:var(--dsw-alias-label-secondary);font-size:12px;line-height:20px}',
14359
+ // .dig-cs-menu opens upward (the composer sits at the bottom of the UI). The surface
14360
+ // carries no backdrop-filter of its own: the data-cordis-panel attribute opts the popover
14361
+ // into dsh-any-background's input-surface channel (--dsw-specific-menu remapped to
14362
+ // var(--dsh-any-op-menu-cordis) on body — see the .dig-selection-bar note:
14363
+ // leading with the op var keeps the fill valid even when the plugin's
14364
+ // surface sampling misses and that remap would otherwise IVCT to transparent).
14365
+ // --dsh-any-op-menu-cordis at the input opacity tier, plus its input blur rule), so fill
14366
+ // opacity and frost follow the composer card exactly. Without that plugin the
14367
+ // theme-owned menu material applies.
14368
+ '.dig-cs-menu{--dsw-alias-bg-layer-1:var(--dsw-static-neutral-bluish-00,#fff);--dsw-alias-bg-layer-2:var(--dsw-static-neutral-bluish-00,#fff);position:absolute;bottom:calc(100% + 6px);left:0;z-index:60;min-width:220px;max-width:320px;max-height:320px;overflow:auto;padding:6px;border-radius:var(--dsw-radius-lg,12px);background:var(--dsh-any-op-menu-cordis,var(--dsw-alias-bg-layer-2));box-shadow:var(--dsw-elevation-prominent,var(--dsw-elevation-dimmed,0 8px 24px rgba(0,0,0,.2)));color:var(--dsw-alias-label-secondary);font-size:12px;line-height:20px}',
14337
14369
  '.dig-cs-item{display:flex;align-items:center;gap:8px;width:100%;padding:5px 10px;border:none;border-radius:var(--dsw-radius-sm,8px);background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:13px;line-height:20px;cursor:pointer;text-align:left}',
14338
14370
  '.dig-cs-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
14339
14371
  '.dig-cs-item:disabled{opacity:.5;cursor:default}',
14340
14372
  '.dig-cs-item[data-current=true]{color:var(--dsw-alias-brand-primary)}',
14341
14373
  '.dig-cs-item svg{flex:none}',
14342
14374
  '.dig-cs-item-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
14343
- '.dig-cs-item-path{margin-left:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-dimmed,var(--dsw-alias-label-tertiary));font-size:11px;max-width:140px}',
14375
+ '.dig-cs-item-main{display:flex;flex-direction:column;min-width:0;flex:1}',
14376
+ '.dig-cs-item-sub{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:12px}',
14344
14377
  '.dig-cs-item-note{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none}',
14345
14378
  '.dig-cs-item-counts{display:inline-flex;gap:5px;margin-left:auto;flex:none;font-variant-numeric:tabular-nums;font-size:11px}',
14346
14379
  '.dig-cs-static{color:var(--dsw-alias-label-tertiary);cursor:default}',
@@ -14480,14 +14513,68 @@ window.__ModuleLoader__.load({
14480
14513
  const COMPOSER_POLL_MS = 30000
14481
14514
  const COMPOSER_ERROR_TTL_MS = 6000
14482
14515
 
14516
+ /* Windows paths ride backslashes, so both pill labels cut the leaf with
14517
+ this slash-aware split instead of a bare forward-slash split — a drive
14518
+ path must read as the leaf name, not the whole path. */
14519
+ function baseNameOf(path) {
14520
+ if (typeof path !== 'string' || path === '') return ''
14521
+ const segments = path.split(/[\\/]/).filter((segment) => segment !== '')
14522
+ return segments.length > 0 ? segments[segments.length - 1] : path
14523
+ }
14524
+
14525
+ /* The pill feeds the SAME keep-warm cache the panel and the prewarmer
14526
+ use (paintRepos / paintPage): a workspace the user has visited — by
14527
+ panel, by pill, or by the session-switch prewarm — re-paints the pill
14528
+ on mount with zero round trips, so switching sessions swaps the text
14529
+ instead of tearing the whole component down and back. The pill only
14530
+ ever MERGES its two fields into an existing page; a panel page with
14531
+ commits must not lose them to a pill poll. */
14532
+ function mergePaintPage(repoRoot, patch) {
14533
+ const entry = readPaintPage(repoRoot)
14534
+ const page = {}
14535
+ if (entry !== undefined) {
14536
+ if (entry.summary !== undefined) page.summary = entry.summary
14537
+ if (entry.branches !== undefined) page.branches = entry.branches
14538
+ if (entry.commits !== undefined) page.commits = entry.commits
14539
+ if (entry.hasMore !== undefined) page.hasMore = entry.hasMore
14540
+ }
14541
+ Object.assign(page, patch)
14542
+ writePaintPage(repoRoot, page)
14543
+ }
14544
+
14545
+ /* The pill's repo pick order (remembered → workspace root when it is a
14546
+ repository → first row), factored out so the cached seed and the fresh
14547
+ discovery apply identically. */
14548
+ function composerPickRepoOf(sessionId, data) {
14549
+ const remembered = readRememberedRepo(sessionId)
14550
+ const paths = data.repos.map((repo) => repo.path)
14551
+ if (remembered !== undefined && paths.indexOf(remembered) >= 0) return remembered
14552
+ if (data.isRepo === true) return data.cwd
14553
+ if (paths.length > 0) return paths[0]
14554
+ return null
14555
+ }
14556
+
14483
14557
  function ComposerGitStatus(props) {
14484
14558
  const ctx = props.ctx
14485
14559
  const t = props.t
14486
14560
  const sessionId = typeof props.sessionId === 'string' && props.sessionId !== '' ? props.sessionId : 'default'
14487
14561
  const [enabled, setEnabled] = useState(() => composerStatusEnabled())
14488
14562
  const [cwd, setCwd] = useState(() => sessionCwdOf(ctx, sessionId))
14489
- const [repoState, setRepoState] = useState(null)
14490
- const [repoRoot, setRepoRoot] = useState(null)
14563
+ /* Warm first paint: a workspace the panel, the pill or the prewarmer
14564
+ has already visited starts from the keep-warm cache, so a session
14565
+ switch swaps text instead of flashing through an empty pill. */
14566
+ const [repoState, setRepoState] = useState(() => {
14567
+ const cwd0 = sessionCwdOf(ctx, sessionId)
14568
+ if (cwd0 === undefined) return null
14569
+ const cached = readPaintRepos(cwd0)
14570
+ return cached === undefined ? null : cached
14571
+ })
14572
+ const [repoRoot, setRepoRoot] = useState(() => {
14573
+ const cwd0 = sessionCwdOf(ctx, sessionId)
14574
+ if (cwd0 === undefined) return null
14575
+ const cached = readPaintRepos(cwd0)
14576
+ return cached === undefined ? null : composerPickRepoOf(sessionId, cached)
14577
+ })
14491
14578
  const [summary, setSummary] = useState(null)
14492
14579
  const [menu, setMenu] = useState(null)
14493
14580
  const [branchList, setBranchList] = useState(null)
@@ -14527,19 +14614,24 @@ window.__ModuleLoader__.load({
14527
14614
  useEffect(() => {
14528
14615
  if (cwd === undefined || enabled !== true) { setRepoState(null); setRepoRoot(null); setSummary(null); return undefined }
14529
14616
  let cancelled = false
14617
+ const applyRepos = (data) => {
14618
+ setRepoState(data)
14619
+ setRepoRoot(composerPickRepoOf(sessionId, data))
14620
+ }
14621
+ /* Warm seed first: a workspace the panel, the pill or the prewarmer
14622
+ already visited paints its labels this tick; the request below
14623
+ silently re-proves them. A failed refresh must not evict a good
14624
+ seed — only fall back to the empty state when nothing is on
14625
+ screen. */
14626
+ const seeded = readPaintRepos(cwd)
14627
+ if (seeded !== undefined) applyRepos(seeded)
14530
14628
  void (async () => {
14531
14629
  try {
14532
14630
  const data = await request('repos', { cwd: cwd })
14533
14631
  if (cancelled === true) return
14534
- setRepoState(data)
14535
- const remembered = readRememberedRepo(sessionId)
14536
- const paths = data.repos.map((repo) => repo.path)
14537
- let pick = null
14538
- if (remembered !== undefined && paths.indexOf(remembered) >= 0) pick = remembered
14539
- else if (data.isRepo === true) pick = data.cwd
14540
- else if (paths.length > 0) pick = paths[0]
14541
- setRepoRoot(pick)
14542
- } catch (error) { void error; if (cancelled !== true) setRepoState({ repos: [], isRepo: false }) }
14632
+ writePaintRepos(cwd, data)
14633
+ applyRepos(data)
14634
+ } catch (error) { void error; if (cancelled !== true && seeded === undefined) setRepoState({ repos: [], isRepo: false }) }
14543
14635
  })()
14544
14636
  return () => { cancelled = true }
14545
14637
  }, [cwd, sessionId, enabled])
@@ -14555,8 +14647,15 @@ window.__ModuleLoader__.load({
14555
14647
  const data = await request('summary', { cwd: cwd, repoRoot: repoRoot, stats: false })
14556
14648
  if (cancelled === true) return
14557
14649
  setSummary(data)
14650
+ /* Stale-while-revalidate both ways: our poll keeps the warm page
14651
+ (panel / prewarm / seed) fresh, and a warm page seeds the NEXT
14652
+ mount with zero round trips. Merge only our field — a panel
14653
+ page keeps its commits. */
14654
+ mergePaintPage(repoRoot, { summary: data })
14558
14655
  } catch (error) { void error }
14559
14656
  }
14657
+ const seed = readPaintPage(repoRoot)
14658
+ if (seed !== undefined && seed.summary !== undefined && seed.summary !== null) setSummary(seed.summary)
14560
14659
  void load()
14561
14660
  const timer = setInterval(() => { void load() }, COMPOSER_POLL_MS)
14562
14661
  return () => { cancelled = true; clearInterval(timer) }
@@ -14568,7 +14667,7 @@ window.__ModuleLoader__.load({
14568
14667
  const watcher = (sid, root) => {
14569
14668
  if (sid !== sessionId || typeof root !== 'string' || root === '') return
14570
14669
  setRepoRoot((current) => (current === root ? current : root))
14571
- setSummary(null)
14670
+ setSummary(seedSummaryOf(root))
14572
14671
  setBranchList(null)
14573
14672
  setTick((value) => value + 1)
14574
14673
  }
@@ -14610,7 +14709,12 @@ window.__ModuleLoader__.load({
14610
14709
  if (data === null || (data.isRepo !== true && (data.repos === undefined || data.repos.length === 0))) return null
14611
14710
  const repoPaths = data.repos.map((repo) => repo.path)
14612
14711
  const singleRepo = repoPaths.length <= 1
14613
- const repoName = repoRoot === null ? '' : (repoRoot.split('/').filter((segment) => segment !== '').pop() || repoRoot)
14712
+ /* The pill shows the repository's OWN name — the origin URL's leaf
14713
+ (summary.origin) — so a clone renamed on disk still reads as the
14714
+ repo it came from; the folder basename is only the fallback for
14715
+ repos without any remote. */
14716
+ const originName = summary !== null && typeof summary.origin === 'string' && summary.origin !== '' ? summary.origin : ''
14717
+ const repoName = originName !== '' ? originName : baseNameOf(repoRoot)
14614
14718
  const branchName = summary !== null && typeof summary.branch === 'string' && summary.branch !== '' ? summary.branch : null
14615
14719
  const detached = summary !== null && summary.detached === true
14616
14720
  const ahead = summary !== null && typeof summary.ahead === 'number' ? summary.ahead : 0
@@ -14621,12 +14725,17 @@ window.__ModuleLoader__.load({
14621
14725
 
14622
14726
  const openBranchMenu = async () => {
14623
14727
  setMenu('branch')
14624
- setBranchList(null)
14625
- setBranchLoading(true)
14728
+ /* A warm page (panel or prewarm) fills the list this tick; the fetch
14729
+ below re-proves it. On failure keep whatever is showing. */
14730
+ const seed = readPaintPage(repoRoot)
14731
+ const seeded = seed !== undefined && seed.branches !== undefined && seed.branches !== null ? seed.branches : null
14732
+ setBranchList(seeded)
14733
+ setBranchLoading(seeded === null)
14626
14734
  try {
14627
14735
  const branchesData = await request('branches', { cwd: cwd, repoRoot: repoRoot })
14628
14736
  setBranchList(branchesData)
14629
- } catch (caught) { void caught; setBranchList(null) } finally { setBranchLoading(false) }
14737
+ mergePaintPage(repoRoot, { branches: branchesData })
14738
+ } catch (caught) { void caught } finally { setBranchLoading(false) }
14630
14739
  }
14631
14740
  const checkoutBranch = async (name) => {
14632
14741
  if (repoRoot === null || checkingOut !== '') return
@@ -14641,10 +14750,18 @@ window.__ModuleLoader__.load({
14641
14750
  setError(caught instanceof Error ? caught.message : String(caught))
14642
14751
  } finally { setCheckingOut('') }
14643
14752
  }
14753
+ /* Swap to a warm summary synchronously when one exists — the null gap
14754
+ between pick and effect would paint one empty frame. */
14755
+ const seedSummaryOf = (root) => {
14756
+ if (typeof root !== 'string' || root === '') return null
14757
+ const seed = readPaintPage(root)
14758
+ return seed !== undefined && seed.summary !== undefined && seed.summary !== null ? seed.summary : null
14759
+ }
14760
+
14644
14761
  const pickRepoRoot = (path) => {
14645
14762
  setMenu(null)
14646
14763
  setRepoRoot(path)
14647
- setSummary(null)
14764
+ setSummary(seedSummaryOf(path))
14648
14765
  setBranchList(null)
14649
14766
  setTick((value) => value + 1)
14650
14767
  rememberRepoShared(sessionId, path)
@@ -14676,22 +14793,32 @@ window.__ModuleLoader__.load({
14676
14793
  behind > 0 ? E('span', { className: 'dig-cs-badge dig-cs-behind', title: t('composer.behindHint') }, '↓' + String(behind)) : null,
14677
14794
  ahead > 0 ? E('span', { className: 'dig-cs-badge dig-cs-ahead', title: t('composer.aheadHint') }, '↑' + String(ahead)) : null,
14678
14795
  error !== '' ? E('span', { className: 'dig-cs-error', title: error }, '×') : null,
14679
- menu === 'repo' ? E('div', { className: 'dig-cs-menu' },
14680
- repoPaths.map((path) => E('button', {
14681
- key: path,
14682
- type: 'button',
14683
- className: 'dig-cs-item',
14684
- 'data-current': path === repoRoot ? 'true' : 'false',
14685
- title: path,
14686
- onClick: () => pickRepoRoot(path),
14687
- },
14688
- E(Icon, { name: 'commit', size: 11 }),
14689
- E('span', { className: 'dig-cs-item-name' }, path.split('/').filter((segment) => segment !== '').pop() || path),
14690
- E('span', { className: 'dig-cs-item-path' }, path))))
14796
+ menu === 'repo' ? E('div', { className: 'dig-cs-menu', 'data-cordis-panel': '' },
14797
+ /* v0.19.2: one row per checkout — the repository's own name first
14798
+ (origin URL basename, falling back to the directory name), and the
14799
+ single-level directory name as a small sub line only when the two
14800
+ differ (two clones of one repository stay tellable apart). The
14801
+ full path lives in the tooltip, not in the row. */
14802
+ data.repos.map((row) => {
14803
+ const dirName = baseNameOf(row.path)
14804
+ const displayName = row.origin !== undefined && row.origin !== '' ? row.origin : dirName
14805
+ return E('button', {
14806
+ key: row.path,
14807
+ type: 'button',
14808
+ className: 'dig-cs-item',
14809
+ 'data-current': row.path === repoRoot ? 'true' : 'false',
14810
+ title: row.path,
14811
+ onClick: () => pickRepoRoot(row.path),
14812
+ },
14813
+ E(Icon, { name: 'commit', size: 11 }),
14814
+ E('span', { className: 'dig-cs-item-main' },
14815
+ E('span', { className: 'dig-cs-item-name' }, displayName),
14816
+ dirName !== displayName ? E('span', { className: 'dig-cs-item-sub' }, dirName) : null))
14817
+ }))
14691
14818
  : null,
14692
14819
  menu === 'branch' ? (branchLoading === true || branchList === null
14693
- ? E('div', { className: 'dig-cs-menu' }, E('div', { className: 'dig-cs-item dig-cs-static' }, '…'))
14694
- : E('div', { className: 'dig-cs-menu' },
14820
+ ? E('div', { className: 'dig-cs-menu', 'data-cordis-panel': '' }, E('div', { className: 'dig-cs-item dig-cs-static' }, '…'))
14821
+ : E('div', { className: 'dig-cs-menu', 'data-cordis-panel': '' },
14695
14822
  branchList.local.map((entry) => E('button', {
14696
14823
  key: entry.name,
14697
14824
  type: 'button',
package/src/index.js CHANGED
@@ -600,6 +600,35 @@ async function operationOf(root) {
600
600
 
601
601
  /* ============================== methods ============================== */
602
602
 
603
+ /* The repository's own name, cut from its origin URL — a cloned folder that
604
+ was renamed on disk still reports the name the remote gave it (the leaf of
605
+ the URL, minus a trailing .git). scp-style (user@host:path), URL-style and
606
+ plain local paths all cut on the last slash or colon. */
607
+ function repoNameFromUrl(url) {
608
+ if (typeof url !== 'string' || url.trim() === '') return ''
609
+ const tail = url.trim().replace(/\/+$/, '').replace(/\.git$/i, '')
610
+ const segments = tail.split(/[\\/:]/).filter((segment) => segment !== '')
611
+ return segments.length > 0 ? segments[segments.length - 1] : ''
612
+ }
613
+ /* origin first, then the first configured remote, then '' — the client
614
+ falls back to the folder basename when this is empty. Both reads are
615
+ local config reads, never network. */
616
+ async function originNameOf(root) {
617
+ try {
618
+ const origin = await git(root, ['remote', 'get-url', 'origin'])
619
+ const name = repoNameFromUrl(origin)
620
+ if (name !== '') return name
621
+ } catch (error) { void error }
622
+ try {
623
+ const remotesOut = await runGit(root, ['remote'])
624
+ if (remotesOut.code !== 0) return ''
625
+ const first = remotesOut.stdout.split('\n').map((line) => line.trim()).filter((line) => line !== '')[0]
626
+ if (first === undefined) return ''
627
+ const urlOut = await runGit(root, ['remote', 'get-url', first])
628
+ return urlOut.code === 0 ? repoNameFromUrl(urlOut.stdout.trim()) : ''
629
+ } catch (error) { void error; return '' }
630
+ }
631
+
603
632
  async function summary(payload) {
604
633
  const cwd = cwdOf(payload)
605
634
  const root = await repoRootOf(cwd, payload)
@@ -614,7 +643,7 @@ async function summary(payload) {
614
643
  large `status` scan no longer serialises behind five cheap calls
615
644
  (issue #11: the sum of ~ten sequential commands became the max of two
616
645
  chains). */
617
- const [branchInfo, statusOut, unstagedStat, stagedStat, ignored, stashCount, worktrees, operation, head] = await Promise.all([
646
+ const [branchInfo, statusOut, unstagedStat, stagedStat, ignored, stashCount, worktrees, operation, head, originName] = await Promise.all([
618
647
  (async () => {
619
648
  const branch = await currentBranchOf(root)
620
649
  const detached = branch === 'HEAD'
@@ -633,6 +662,7 @@ async function summary(payload) {
633
662
  checkouts — the panel's own actions already refresh everything, but
634
663
  nothing else watches the repo. Empty string on an unborn branch. */
635
664
  git(root, ['rev-parse', 'HEAD']).then((out) => out.trim()).catch(() => ''),
665
+ originNameOf(root),
636
666
  ])
637
667
  const groups = classify(parsePorcelain(statusOut))
638
668
  return {
@@ -644,6 +674,9 @@ async function summary(payload) {
644
674
  ahead: branchInfo.tracking.ahead,
645
675
  behind: branchInfo.tracking.behind,
646
676
  head: head,
677
+ /* The repository's own name (v0.19.1): the origin URL's leaf, so a
678
+ renamed clone still shows the real repo name, not the folder's. */
679
+ origin: originName,
647
680
  changes: {
648
681
  staged: decorate(groups.staged, stagedStat),
649
682
  unstaged: decorate(groups.unstaged, unstagedStat),
@@ -1401,6 +1434,12 @@ function repoRow(root, submodulePath, kind) {
1401
1434
  name: submodulePath === undefined ? path.basename(root) : submodulePath,
1402
1435
  label: submodulePath === undefined ? path.basename(root) : submodulePath.split('/').pop(),
1403
1436
  branch: null,
1437
+ /** The checkout's own repository name, cut from its origin URL (v0.19.2):
1438
+ * the picker shows this first and falls back to the directory name, with
1439
+ * the directory name as the small sub line whenever the two differ — so
1440
+ * two clones of one repository stay tellable apart. Filled in by the
1441
+ * scan below (an origin-less checkout keeps the empty string). */
1442
+ origin: '',
1404
1443
  kind,
1405
1444
  ...(submodulePath === undefined ? {} : { submodulePath }),
1406
1445
  }
@@ -1507,6 +1546,11 @@ async function scanRepos(cwd) {
1507
1546
  const capped = rows.slice(0, REPO_LIST_LIMIT).map((row) => ({ ...row }))
1508
1547
  const branches = await Promise.all(capped.map((row) => branchOrNull(row.path)))
1509
1548
  for (let index = 0; index < capped.length; index += 1) capped[index].branch = branches[index]
1549
+ /* Origin names ride the same one-call-per-row pass as the branches: a local
1550
+ `git remote get-url` read, cheap, and the 15s cache means repeated picker
1551
+ opens pay for it once. */
1552
+ const origins = await Promise.all(capped.map((row) => originNameOf(row.path)))
1553
+ for (let index = 0; index < capped.length; index += 1) capped[index].origin = origins[index]
1510
1554
  repoScanCache.set(cwd, { at: Date.now(), rows: capped })
1511
1555
  return { cached: false, rows: capped.map((row) => ({ ...row })) }
1512
1556
  }