Procházet zdrojové kódy

fix(directory-picker-browse): keep the typed level in the last pane

Skipping the draft-following scan whenever ANY pane happened to list the
directory was the cheaper rule and the wrong one: erasing a segment left the
level being typed on the LEFT, with its own child pane still standing to its
right, so the two panes stopped reading as "where I am, and where I came
from".

The pane arity is now the invariant the editor maintains — the last pane lists
the level the path names, its parent sits beside it, and only a display root
lists alone. Only that last pane's own tail costs no scan; every other
directory part re-lands.
creatixchu před 1 měsícem
rodič
revize
2ceed380dd

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
-2026-07-28-directory-picker-capability-seam.md: 15d0a6ad3fc1e92e0487024c0d7f611380382e2d
-2026-07-28-directory-picker-capability-seam.zh.md: 54042cea3bf4a4888855a60765ccc19977e6a061
+2026-07-28-directory-picker-capability-seam.md: bd1a0bc2f840416f4c939474d1d90c51701c7fac
+2026-07-28-directory-picker-capability-seam.zh.md: f836c00443ad460d9589deba859401b305d0d902

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md


+ 6 - 3
apps/web/tests/workspace-management.e2e.ts

@@ -423,11 +423,14 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
     expect(await dialog.getByRole('list').count()).toBe(2)
     expect(await path.inputValue()).toBe(`${join(staged, 'alpha')}${sep}`)
-    // Erasing back past the separator returns to a level already on screen:
-    // the tail filters it, no scan needed, both panes stay.
+    // Erasing back past the separator walks the panes up, so the level being
+    // typed is the last pane again (its children no longer stand to its
+    // right) and the tail filters it.
     await path.fill(`${staged}${sep}al`)
-    await expect.poll(() => dialog.getByText('beta', { exact: true }).count(), { timeout: 10_000 }).toBe(0)
+    await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(0)
     expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1)
+    expect(await dialog.getByText('beta', { exact: true }).count()).toBe(0)
+    expect(await dialog.getByRole('list').count()).toBe(2)
     // A tail nobody matches is a name still being spelled: the level shows
     // whole instead of emptying under it.
     await path.fill(`${staged}${sep}zzz`)

+ 2 - 2
packages/host/directory-picker-browse/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
-README.md: c0375331e0e82fd6864b2027e7e36e0c6cb9986a
-README.zh.md: 91d35de8821414c095db2a7834309864b5df0cd6
+README.md: 04d97adf71ae4a3ea8d89b24098f27cbdee20961
+README.zh.md: 603afaed7e4b4c77e699e009fc612e73c06d73aa

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
packages/host/directory-picker-browse/README.md


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
packages/host/directory-picker-browse/README.zh.md


+ 19 - 15
packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx

@@ -23,13 +23,14 @@
  * separator, and keeps the panes under the draft: the final segment
  * prefix-filters the level its directory part names (a dot-led prefix also
  * reveals the hidden entries it names, and a prefix nobody matches releases
- * the filter), while a directory part no pane lists is scanned after a short
+ * the filter), while any other directory part is scanned after a short
  * debounce and lands like any other navigation — selection-anchored and
- * two-pane away from the display root — so typing deeper descends and
- * erasing segments walks back up, moving the Miller view without leaving the
- * editor. Panes the draft walked to stay put when the editor closes
- * (cancellation included): the crumbs name where the walk ended, and Open's
- * fallback target follows them.
+ * two-pane away from the display root. The pane arity holds throughout: the
+ * last pane is the level the path names and the one beside it is its parent,
+ * so typing deeper descends and erasing segments walks back up, moving the
+ * Miller view without leaving the editor. Panes the draft walked to stay put
+ * when the editor closes (cancellation included): the crumbs name where the
+ * walk ended, and Open's fallback target follows them.
  */
 import { useCallback, useEffect, useRef, useState } from 'react'
 import clsx from 'clsx'
@@ -151,12 +152,16 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string
 }
 
 /**
- * The directory a draft addresses that no rendered pane lists — the level the
- * editor must scan for the panes to keep following the typed path. Null when
- * a pane already lists it (the prefix filter alone answers the draft), when
- * no separator has been typed yet, and when no level is listed at all: the
- * platform separator is read off a listing, so the editor's
- * failed-home-listing recovery path types blind until Enter.
+ * The directory a draft addresses that the panes are not already presenting
+ * as the current level — what the editor must scan to keep the view under the
+ * typed path. The pane arity is the invariant this preserves: the LAST pane
+ * always lists the level the path names, with its parent beside it (a display
+ * root lists alone), so a draft naming any other level re-lands rather than
+ * leaving a deeper level standing to the right of the one being typed. Null
+ * when that level is already the last pane, when no separator has been typed
+ * yet, and when no level is listed at all: the platform separator is read off
+ * a listing, so the editor's failed-home-listing recovery path types blind
+ * until Enter.
  */
 function pendingPreviewDirectory(
   parent: DirectoryListing | null,
@@ -165,9 +170,8 @@ function pendingPreviewDirectory(
 ): string | null {
   if (parent === null) return null
   const directory = draftDirectory(parent, draft)
-  if (directory === null || directory === levelDirectory(parent)) return null
-  if (child !== null && directory === levelDirectory(child)) return null
-  return directory
+  if (directory === null) return null
+  return directory === levelDirectory(child ?? parent) ? null : directory
 }
 
 /**

+ 26 - 4
packages/host/directory-picker-browse/tests/directory-browser.spec.tsx

@@ -707,11 +707,13 @@ describe('DirectoryBrowser', () => {
     fireEvent.change(input, { target: { value: `${DOCS}/zzz` } })
     expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
     expect(within(columns()[0]!).getByText('Documents')).toBeTruthy()
-    // Erasing back into the parent's own path moves the filter to the LEFT
-    // pane and releases the right one — no scan, both levels are on screen.
+    // Erasing back into the parent's own path re-lands on it rather than
+    // filtering the LEFT pane: the level being typed is always the last pane,
+    // never a pane with a deeper level standing to its right. Home is the
+    // display root, so it lands alone.
     fireEvent.change(input, { target: { value: `${HOME}/zz` } })
-    expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
-    expect(within(columns()[1]!).getByText('harness')).toBeTruthy()
+    await waitFor(() => { expect(columns()).toHaveLength(1) })
+    expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents'])
   })
 
   it('follows the draft into a directory no pane lists, landing the two-pane Miller view', async () => {
@@ -740,6 +742,26 @@ describe('DirectoryBrowser', () => {
     expect(columns()).toHaveLength(2)
   })
 
+  it('keeps the typed level in the last pane, its parent beside it, as the draft walks', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    // Two levels down: the typed level on the right, its parent on the left.
+    fireEvent.change(input, { target: { value: `${HARNESS}/` } })
+    await waitFor(() => { expect(within(columns()[0]!).getByText('harness')).toBeTruthy() })
+    expect(columns()).toHaveLength(2)
+    expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0)
+    // Erasing back to the parent's own path re-lands on it: the level being
+    // typed moves BACK into the last pane instead of staying on the left with
+    // its own child pane still to the right.
+    fireEvent.change(input, { target: { value: `${DOCS}/ha` } })
+    await waitFor(() => { expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() })
+    expect(columns()).toHaveLength(2)
+    expect(within(columns()[1]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['harness'])
+    expect(b.listDirectory).toHaveBeenCalledWith(`${DOCS}/`, expect.anything())
+  })
+
   it('walks the panes back up when erased segments leave the listed levels', async () => {
     const b = mount()
     await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů