fix(directory-picker-browse): advertise the path editor and walk the panes with the draft

The Select Workspace Directory dialog hid its one route into typing a path
behind an invisible click target, and once the editor opened the panes stayed
on whatever level was listed when it opened — so the typed text and the list
under it disagreed for the whole edit.

The edit zone now carries a pencil glyph at the bar's right edge and lights in
the editor's own footprint on hover/focus (the bar keeps one height across the
swap). While editing, the panes follow the draft: a directory part no pane
lists is scanned after a 250ms rest and lands in place, so typing deeper
descends and erasing segments steps back up without leaving the editor, and a
final segment nobody matches releases the prefix filter instead of emptying
the pane it is being spelled into. The draft-following scan is speculative and
silent on failure; Enter still owns the view from submission until landing and
remains the only path that surfaces an error.
This commit is contained in:
creatixchu
2026-08-03 11:24:35 +08:00
parent 8bf606ba80
commit 79072e356c
11 changed files with 345 additions and 45 deletions

View File

@@ -4,7 +4,8 @@
- button "Home"
- img
- button "browse-golden"
- button "Edit path"
- button "Edit path":
- img
- list:
- listitem:
- button "adopted":

View File

@@ -1,6 +1,7 @@
// Web e2e scenarios: workspace management — adding a workspace through the
// composed directory dialog (its own New folder affordance is the product's
// one creation route), same-basename directory adoption, the rename round
// one creation route), the dialog's path editor walking the panes with the
// typed draft, same-basename directory adoption, the rename round
// trip over the real wire (workspace.rename RPC + durable registry), the
// duplicate-name pre-check, the
// flat "In one list" view with its persisted group-by preference, the session
@@ -12,7 +13,7 @@
// seeded-history seed reused verbatim — no new recording).
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { join, sep } from 'node:path'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
@@ -403,6 +404,36 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('walks the panes with the typed path: deeper past a separator, back up on erase, whole on a miss', async () => {
// The panes must track the draft without leaving the editor, so the
// typed text and what is listed under it never disagree.
const staged = join(scaffold.workspaceCwd, 'browse-golden')
await mkdir(join(staged, 'alpha', 'only-under-alpha'), { recursive: true })
const dialog = await browseTo(staged)
await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
await dialog.getByRole('button', { name: 'Edit path' }).click()
const path = dialog.getByLabel('Edit path')
// A directory part no pane lists: the panes follow it and keep the editor.
await path.fill(`${join(staged, 'alpha')}${sep}`)
await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
// The editor is still up with the draft intact: the panes moved under it.
expect(await path.inputValue()).toBe(`${join(staged, 'alpha')}${sep}`)
// Erasing back past the separator steps the panes up, the tail filtering
// the level it returns to.
await path.fill(`${staged}${sep}al`)
await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
expect(await dialog.getByText('beta', { exact: true }).count()).toBe(0)
expect(await dialog.getByText('only-under-alpha', { exact: true }).count()).toBe(0)
// 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`)
await expect.poll(() => dialog.getByText('beta', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1)
await dialog.getByRole('button', { name: 'Cancel' }).click()
await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
/**
* Expand Ungrouped and return its seeded session row. The only visible child
* is the non-blank persisted Session; the blank Session created while