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,