From 6c5d2598d6f866d6cd2e1974303a5a2e89ef75de Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Sat, 3 Oct 2026 13:43:32 -0700 Subject: [PATCH] Show full Google Drive paths in picker search results Signed-off-by: Jeremy lewi --- .../GoogleDriveResourcePickerDialog.test.tsx | 167 ++++++++- .../GoogleDriveResourcePickerDialog.tsx | 50 ++- .../Workspace/googleDriveBrowser.test.ts | 83 +++-- .../Workspace/googleDriveBrowser.ts | 14 +- .../Workspace/googleDrivePaths.test.ts | 345 ++++++++++++++++++ .../components/Workspace/googleDrivePaths.ts | 160 ++++++++ app/test/fixtures/drive-paths.html | 11 + app/test/fixtures/drive-paths.tsx | 34 ++ docs-dev/CUJs/assets/drive-search-paths.png | Bin 0 -> 34574 bytes docs-dev/CUJs/drive-search-paths.md | 41 +++ testing/drive-paths/main.go | 58 +++ 11 files changed, 912 insertions(+), 51 deletions(-) create mode 100644 app/src/components/Workspace/googleDrivePaths.test.ts create mode 100644 app/src/components/Workspace/googleDrivePaths.ts create mode 100644 app/test/fixtures/drive-paths.html create mode 100644 app/test/fixtures/drive-paths.tsx create mode 100644 docs-dev/CUJs/assets/drive-search-paths.png create mode 100644 docs-dev/CUJs/drive-search-paths.md create mode 100644 testing/drive-paths/main.go diff --git a/app/src/components/Workspace/GoogleDriveResourcePickerDialog.test.tsx b/app/src/components/Workspace/GoogleDriveResourcePickerDialog.test.tsx index c80fa5f2..abcbcc13 100644 --- a/app/src/components/Workspace/GoogleDriveResourcePickerDialog.test.tsx +++ b/app/src/components/Workspace/GoogleDriveResourcePickerDialog.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { GoogleDriveResourcePickerDialog } from './GoogleDriveResourcePickerDialog' @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ listChildren: vi.fn(), listRoots: vi.fn(), searchResources: vi.fn(), + resolvePaths: vi.fn(), })) vi.mock('./googleDriveBrowser', async () => { @@ -23,8 +24,14 @@ vi.mock('./googleDriveBrowser', async () => { } }) +vi.mock('./googleDrivePaths', () => ({ + resolveGoogleDriveResourcePaths: mocks.resolvePaths, +})) + describe('GoogleDriveResourcePickerDialog', () => { beforeEach(() => { + mocks.resolvePaths.mockReset() + mocks.resolvePaths.mockResolvedValue(undefined) mocks.listRoots.mockReset() mocks.listRoots.mockResolvedValue([ { id: 'my-drive-root-id', name: 'My Drive' }, @@ -118,9 +125,7 @@ describe('GoogleDriveResourcePickerDialog', () => { it('keeps an actionable error open and retries root listing', async () => { mocks.listRoots .mockRejectedValueOnce(new Error('Drive API disabled')) - .mockResolvedValueOnce([ - { id: 'my-drive-root-id', name: 'My Drive' }, - ]) + .mockResolvedValueOnce([{ id: 'my-drive-root-id', name: 'My Drive' }]) render( { trigger.remove() }) }) + +/** Starts a search only after root discovery has completed. */ +async function searchNotebooks() { + await screen.findByRole('button', { name: 'Open My Drive' }) + fireEvent.change(screen.getByRole('searchbox'), { + target: { value: 'Notebooks' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Search' })) +} + +it('shows duplicate names immediately and progressively adds accessible full paths', async () => { + mocks.listRoots.mockResolvedValue([{ id: 'my-root', name: 'My Drive' }]) + mocks.searchResources.mockResolvedValue([ + { + id: 'a', + name: 'Notebooks', + mimeType: 'application/vnd.google-apps.folder', + }, + { + id: 'b', + name: 'Notebooks', + mimeType: 'application/vnd.google-apps.folder', + }, + ]) + let publish!: (id: string, path: { label: string; complete: boolean }) => void + let finish!: () => void + mocks.resolvePaths.mockImplementation((_token, _matches, _roots, onPath) => { + publish = onPath + return new Promise((resolve) => { + finish = resolve + }) + }) + render( + + ) + await searchNotebooks() + expect( + await screen.findAllByRole('button', { name: 'Open folder Notebooks' }) + ).toHaveLength(2) + expect(screen.getAllByText('Loading path…')).toHaveLength(2) + await act(async () => { + publish('a', { label: 'My Drive / Work / Notebooks', complete: true }) + publish('b', { label: 'Engineering / Team / Notebooks', complete: true }) + finish() + }) + const rows = screen.getAllByRole('button', { name: 'Open folder Notebooks' }) + expect( + document.getElementById(rows[0].getAttribute('aria-describedby')!) + ?.textContent + ).toBe('My Drive / Work / Notebooks') + expect(screen.getByTitle('Engineering / Team / Notebooks')).toBeTruthy() +}) + +it.each(['navigation', 'new search', 'credentials', 'unmount'])( + 'aborts paths and ignores late callbacks after %s', + async (action) => { + mocks.listRoots.mockResolvedValue([{ id: 'my-root', name: 'My Drive' }]) + mocks.listChildren.mockResolvedValue([]) + mocks.searchResources.mockResolvedValue([ + { + id: 'a', + name: 'Notebooks', + mimeType: 'application/vnd.google-apps.folder', + }, + ]) + const pending: Array<{ + publish: (id: string, path: { label: string; complete: boolean }) => void + signal: AbortSignal + finish: () => void + }> = [] + mocks.resolvePaths.mockImplementation( + (_token, _matches, _roots, publish, signal) => + new Promise((finish) => pending.push({ publish, signal, finish })) + ) + const props = { + accessToken: 'token', + mode: 'folder' as const, + onCancel: vi.fn(), + onSelect: vi.fn(), + } + const view = render() + await searchNotebooks() + await screen.findByRole('button', { name: 'Open folder Notebooks' }) + const first = pending[0] + if (action === 'navigation') + fireEvent.click( + screen.getByRole('button', { name: 'Open folder Notebooks' }) + ) + if (action === 'new search') { + fireEvent.change(screen.getByRole('searchbox'), { + target: { value: 'Other' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Search' })) + await waitFor(() => expect(pending).toHaveLength(2)) + } + if (action === 'credentials') + view.rerender( + + ) + if (action === 'unmount') view.unmount() + expect(first.signal.aborted).toBe(true) + await act(async () => { + first.publish('a', { label: 'Stale path', complete: true }) + first.finish() + pending[1]?.finish() + }) + expect(screen.queryByText('Stale path')).toBeNull() + } +) + +it('keeps file selection usable while a partial path is published', async () => { + mocks.listRoots.mockResolvedValue([{ id: 'my-root', name: 'My Drive' }]) + mocks.searchResources.mockResolvedValue([ + { + id: 'file-id', + name: 'notes.json', + mimeType: 'application/json', + resourceKey: 'key', + }, + ]) + mocks.resolvePaths.mockImplementation( + async (_token, _matches, _roots, publish) => { + publish('file-id', { + label: '… / notes.json (partial path)', + complete: false, + }) + } + ) + const onSelect = vi.fn() + render( + + ) + await searchNotebooks() + fireEvent.click( + await screen.findByRole('button', { name: 'Select file notes.json' }) + ) + expect(screen.getByText('… / notes.json (partial path)')).toBeTruthy() + fireEvent.click( + screen.getByRole('button', { name: 'Select file', exact: true }) + ) + expect(onSelect).toHaveBeenCalledWith( + expect.objectContaining({ id: 'file-id', resourceKey: 'key' }) + ) +}) diff --git a/app/src/components/Workspace/GoogleDriveResourcePickerDialog.tsx b/app/src/components/Workspace/GoogleDriveResourcePickerDialog.tsx index b7331e5b..58ed753c 100644 --- a/app/src/components/Workspace/GoogleDriveResourcePickerDialog.tsx +++ b/app/src/components/Workspace/GoogleDriveResourcePickerDialog.tsx @@ -14,6 +14,11 @@ import type { GoogleDriveResource, } from './googleDriveBrowser' +import { + resolveGoogleDriveResourcePaths, + type GoogleDriveResourcePath, +} from './googleDrivePaths' + export type GoogleDrivePickerMode = 'file' | 'folder' export type PickedGoogleDriveResource = { @@ -54,6 +59,10 @@ export function GoogleDriveResourcePickerDialog({ }: GoogleDriveResourcePickerDialogProps) { const dialogRef = useRef(null) const requestIdRef = useRef(0) + const pathsAbortRef = useRef(null) + const [paths, setPaths] = useState>( + {} + ) const [roots, setRoots] = useState([]) const [breadcrumbs, setBreadcrumbs] = useState([]) const [resources, setResources] = useState([]) @@ -69,6 +78,8 @@ export function GoogleDriveResourcePickerDialog({ const loadRoots = useCallback(async () => { const requestId = ++requestIdRef.current + pathsAbortRef.current?.abort() + setPaths({}) setLoading(true) setErrorMessage('') setBreadcrumbs([]) @@ -107,6 +118,8 @@ export function GoogleDriveResourcePickerDialog({ nextBreadcrumbs: GoogleDriveLocation[] ) => { const requestId = ++requestIdRef.current + pathsAbortRef.current?.abort() + setPaths({}) setLoading(true) setErrorMessage('') setBreadcrumbs(nextBreadcrumbs) @@ -155,6 +168,8 @@ export function GoogleDriveResourcePickerDialog({ return } const requestId = ++requestIdRef.current + pathsAbortRef.current?.abort() + setPaths({}) setLoading(true) setErrorMessage('') setActiveSearch(query) @@ -169,6 +184,20 @@ export function GoogleDriveResourcePickerDialog({ ) if (requestId === requestIdRef.current) { setResources(matches) + setLoading(false) + const controller = new AbortController() + pathsAbortRef.current = controller + await resolveGoogleDriveResourcePaths( + accessToken, + matches, + roots, + (id, path) => { + if (requestId === requestIdRef.current) { + setPaths((previous) => ({ ...previous, [id]: path })) + } + }, + controller.signal + ) } } catch (error) { if (requestId !== requestIdRef.current) { @@ -192,13 +221,14 @@ export function GoogleDriveResourcePickerDialog({ } } }, - [accessToken, loadRoots, mode] + [accessToken, loadRoots, mode, roots] ) useEffect(() => { void loadRoots() return () => { requestIdRef.current += 1 + pathsAbortRef.current?.abort() } }, [loadRoots]) @@ -445,10 +475,12 @@ export function GoogleDriveResourcePickerDialog({

) : (
- {visibleResources.map((resource) => { + {visibleResources.map((resource, index) => { const isFolder = resource.mimeType === GOOGLE_DRIVE_FOLDER_MIME_TYPE const selected = selectedFile?.id === resource.id + const pathLabel = paths[resource.id]?.label ?? 'Loading path…' + const pathId = `google-drive-resource-picker-path-${index}` return ( +

{selected}

+ {open && ( + setOpen(false)} + onSelect={(resource) => { + setSelected(`Selected ID: ${resource.id}`) + setOpen(false) + }} + /> + )} + + ) +} + +createRoot(document.getElementById('root')!).render() diff --git a/docs-dev/CUJs/assets/drive-search-paths.png b/docs-dev/CUJs/assets/drive-search-paths.png new file mode 100644 index 0000000000000000000000000000000000000000..a7da6be1fd01945f6f23e78b6bfcb54979b8dc9b GIT binary patch literal 34574 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 S0Qt;5erfji)_~yqfqw$w6=#Y7 literal 0 HcmV?d00001 diff --git a/docs-dev/CUJs/drive-search-paths.md b/docs-dev/CUJs/drive-search-paths.md new file mode 100644 index 00000000..22922627 --- /dev/null +++ b/docs-dev/CUJs/drive-search-paths.md @@ -0,0 +1,41 @@ +# Disambiguate Google Drive search results + +The production folder and file picker displays a full path below each search +match. Paths load progressively; inaccessible ancestry is labeled partial. +Selection uses Drive IDs, including when full paths themselves collide. + +## Reproduce with synthetic data + +1. Run `go run testing/drive-paths/main.go` from the repository root. +2. Run `pnpm -C app dev --host 127.0.0.1 --port 5186`. +3. Open `http://127.0.0.1:5186/test/fixtures/drive-paths.html`. +4. Search for `Notebooks`. +5. Verify three independently selectable rows with these descriptions: + - `My Drive / Projects / Notebooks` + - `Engineering / Runme / Notebooks` + - `… / Notebooks (partial path)` +6. Open the Engineering result, then choose **Select this folder**. +7. Verify the page reports `Selected ID: team-notebooks`. +8. Reopen the picker and use keyboard focus to inspect path descriptions. + +The fixture imports the production React dialog and resolver and sends real +HTTP requests to a read-only Go fixture. Its token and data are synthetic. +The fixture is a Vite development entry point, not a production app route. +Save screenshots and assertion transcripts under `app/test/browser/test-output/`. + +## Verified UI + +![Full and partial paths in the production picker with synthetic data](assets/drive-search-paths.png) + +## Regression coverage + +- `googleDriveBrowser.test.ts`: search requests and preserves parent IDs; + shortcut source parents are not attached to target IDs. +- `googleDrivePaths.test.ts`: root naming, shared-ancestor request deduplication, + result-metadata reuse, target resource keys, inaccessible/malformed ancestors, + cycle/depth limits, bounded concurrency, timeout and cancellation. +- `GoogleDriveResourcePickerDialog.test.tsx`: progressive path descriptions and + stale callback rejection after search, navigation, token changes and unmount. + +The design and implementation record is in +[20261003_google_drive_full_paths.runme](https://web.runme.dev/?doc=https%3A%2F%2Fdrive.google.com%2Ffile%2Fd%2F100i8UJfmZyvqw-1UxFiIs5pxM2Nt79oC%2Fview). diff --git a/testing/drive-paths/main.go b/testing/drive-paths/main.go new file mode 100644 index 00000000..56e782ac --- /dev/null +++ b/testing/drive-paths/main.go @@ -0,0 +1,58 @@ +// A read-only Drive fixture for the full-path picker CUJ. All data is synthetic. +package main + +import ( + "encoding/json" + "flag" + "log" + "net/http" + "strings" +) + +func main() { + addr := flag.String("addr", "127.0.0.1:9098", "listen address") + flag.Parse() + folders := []map[string]any{ + {"id": "personal-notebooks", "name": "Notebooks", "mimeType": "application/vnd.google-apps.folder", "parents": []string{"personal-projects"}}, + {"id": "team-notebooks", "name": "Notebooks", "mimeType": "application/vnd.google-apps.folder", "parents": []string{"team-projects"}, "driveId": "engineering"}, + {"id": "archive-notebooks", "name": "Notebooks", "mimeType": "application/vnd.google-apps.folder", "parents": []string{"restricted"}}, + } + metadata := map[string]any{ + "root": map[string]any{"id": "my-root"}, + "personal-projects": map[string]any{"id": "personal-projects", "name": "Projects", "parents": []string{"my-root"}}, + "team-projects": map[string]any{"id": "team-projects", "name": "Runme", "parents": []string{"engineering"}, "driveId": "engineering"}, + } + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, X-Goog-Drive-Resource-Keys") + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var response any + switch { + case r.URL.Path == "/drive/v3/drives": + response = map[string]any{"drives": []map[string]string{{"id": "engineering", "name": "Engineering"}}} + case r.URL.Path == "/drive/v3/files": + if strings.Contains(r.URL.Query().Get("q"), "name contains") { + response = map[string]any{"files": folders} + } else { + response = map[string]any{"files": []any{}} + } + default: + var ok bool + response, ok = metadata[strings.TrimPrefix(r.URL.Path, "/drive/v3/files/")] + if !ok { + w.WriteHeader(http.StatusNotFound) + response = map[string]string{"error": "unavailable"} + } + } + json.NewEncoder(w).Encode(response) + }) + log.Fatal(http.ListenAndServe(*addr, nil)) +}