zip from the folder, extract on double-click, show location
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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',
|
||||
});
|
||||
|
||||
+8
@@ -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
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</ContextMenuItem>
|
||||
{/* 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. */}
|
||||
<ContextMenuItem onClick={() => handleShowLocation(entry)} className="cursor-pointer">
|
||||
<FolderSearch className="mr-2 h-4 w-4" />
|
||||
Show location
|
||||
</ContextMenuItem>
|
||||
{absPath && (
|
||||
<ContextMenuItem onClick={() => handleCopyAbsPath(absPath)} className="cursor-pointer">
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user