From a84eedd126ad72f5bed2d617e3c8e021fd496e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 15 Aug 2026 20:43:57 +0000 Subject: [PATCH] zip from the folder, extract on double-click, show location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things from testing. ── The download zip carried the path to itself ── Selecting files in ~/Tests/test_folder and downloading produced an archive whose contents were `Tests/test_folder/…`. `POST /download` spawned zip with `cwd: rootDir` and root-relative paths, so the whole chain from home went in. Nobody selecting three files is asking for the route to that folder to be part of what they downloaded. Now it zips from the items' own folder using bare names. A MIXED selection keeps the old form, because two `notes.txt` from different folders would collide without the paths keeping them apart — the UI only selects within one folder, so that branch is a safety net rather than the case. `/compress` already did it this way; this is the same fix on the endpoint that predates it. Verified against real zip: `Tests/a.txt` → `a.txt`, `Tests/test_folder/b.txt` → `test_folder/b.txt`. ── Double-clicking an archive extracts it ── It opened a pane that rendered "Archive file" and the name you could already see in the listing. That is a step on the way to the only thing anyone opens a zip for. Extract resolves collisions, so a second double-click makes a sibling rather than overwriting. The menu's Extract stays — a double-click is not a discoverable way to learn what will happen. ── Show location ── A search result answers "here is the file"; its path underneath raises "where does this live", and clicking only ever answered the first. This goes to the containing folder, clears the search and SELECTS the row, so a folder of forty files does not leave you hunting. A directory hit reveals its parent — where is this means the folder it sits in, not the folder it is. tsgo clean, frontend builds, 817 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 --- src/servers/api/file-browser/router.ts | 18 ++++++++-- .../components/FileViewContainer.tsx | 8 +++++ .../FileBrowserApp/useFileBrowserApp.ts | 33 +++++++++++++++++-- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index a8a3f4fb..f3a602e1 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -1484,8 +1484,22 @@ router.post('/download', async (ctx) => { items.push(relPath); } - const proc = Bun.spawn(['zip', '-r', '-', ...items], { - cwd: rootDir, + // Zip from the items' own folder, not from the home root. + // + // `cwd: rootDir` with paths like `Tests/test_folder` put the whole chain from home inside the archive — + // unzip it and you got `Tests/test_folder/...` rather than `test_folder/...`. Nobody selecting three + // files in a folder is asking for the path to that folder to be part of what they downloaded. + // + // A mixed selection cannot collapse that way without colliding (two `notes.txt` from different folders + // would overwrite), so it keeps the root-relative form, where the paths are what keeps them apart. The + // UI only ever selects within one folder, so the second branch is the safety net rather than the case. + const parents = new Set(items.map((i) => dirname(resolveUserPath(rootDir, i)))); + const sameFolder = parents.size === 1; + const cwd = sameFolder ? [...parents][0]! : rootDir; + const args = sameFolder ? items.map((i) => i.split('/').pop()!) : items; + + const proc = Bun.spawn(['zip', '-r', '-', ...args], { + cwd, stdout: 'pipe', stderr: 'ignore', }); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx index b1520c82..2a46ce74 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx @@ -13,6 +13,7 @@ import { Download, Mic, ExternalLink, + FolderSearch, GitBranch, Eye, EyeOff, @@ -39,6 +40,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps searching, searchResults, handleSearchResultClick, + handleShowLocation, dragging, handleDragEnter, handleDragLeave, @@ -208,6 +210,12 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps Open + {/* The other question a search result raises. Clicking it opens it; this goes + to the folder it lives in and selects it there, leaving nothing open. */} + handleShowLocation(entry)} className="cursor-pointer"> + + Show location + {absPath && ( handleCopyAbsPath(absPath)} className="cursor-pointer"> diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index 3029d1a0..359df8ef 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -4,6 +4,7 @@ import { toast } from 'sonner'; import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI'; import { usePinnedFiles } from '../usePinnedFiles'; import { EDITOR_FILE_PARAM } from '../../CodeEditor/useEditorState'; +import { getFileType } from '../../FileViewer'; import { useTasks, type TaskSummary } from '../useTasks'; import { useAgents, type AgentSummary } from '../useAgents'; import { useUserState } from 'state/useUserState'; @@ -294,6 +295,23 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa setSearchQuery(''); }; + /** + * Go to a search hit's folder and select it, without opening anything. + * + * Clicking a result opens it, which answers "show me this file" but not "where does this live" — and a + * search result's whole context is the path under its name. This is the second question, and it is the + * one a `Reveal in Finder` answers everywhere else. + */ + const handleShowLocation = (entry: DirEntry) => { + if (!entry.path) return; + // The containing folder either way — a directory hit reveals its PARENT, same as a file, because + // "where is this" means the folder it sits in, not the folder it is. + setCurrentPath(entry.path.substring(0, entry.path.lastIndexOf('/')) || '/'); + setSearchQuery(''); + // Selected rather than merely listed, so a folder of forty files does not leave you hunting for it. + setSelected(new Set([entry.name])); + }; + const handleNavigate = (path: string) => { if (basePath !== '/' && !path.startsWith(basePath)) { setCurrentPath(basePath); @@ -307,10 +325,18 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa if (entry.type === 'directory') { const next = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`; setCurrentPath(next); - } else { - const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`; - setViewerParams({ view: filePath }); + return; } + // An archive extracts rather than opening a pane. The viewer has nothing to show for one — it renders + // a placeholder reading "Archive file" and the name you can already see in the listing — so a pane + // was a step on the way to the only thing anyone opens a zip for. Extract resolves collisions, so a + // second double-click makes a sibling folder rather than overwriting the first. + if (getFileType(entry.name) === 'archive') { + void handleExtract(entry); + return; + } + const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`; + setViewerParams({ view: filePath }); }; const handleCreateDir = async (name: string) => { @@ -866,6 +892,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa fileScrollRef, // Handlers handleSearchResultClick, + handleShowLocation, handleNavigate, handleOpen, handleCreateDir,