diff --git a/src/main/ipc/filesystem/filesystem-host-browse-handlers.test.ts b/src/main/ipc/filesystem/filesystem-host-browse-handlers.test.ts new file mode 100644 index 000000000000..a4f3d3b5e65c --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-host-browse-handlers.test.ts @@ -0,0 +1,126 @@ +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { HostDirectoryListing } from '../../../shared/filesystem-entry-types' +import type { Store } from '../../persistence' +import { isPathAllowed, resolveAuthorizedPath } from './filesystem-auth' +import { + registerFilesystemHostBrowseHandlers, + resolveHostBrowseEntry +} from './filesystem-host-browse-handlers' + +const { handlers } = vi.hoisted(() => ({ + handlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, handler: (event: unknown, args: unknown) => unknown) => + handlers.set(channel, handler) + } +})) + +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: allowed-root lookups read only these four accessors. +const store = { + getRepos: () => [], + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({}) +} as unknown as Store + +describe('host browse handlers', () => { + let root: string + + beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'orca-host-browse-'))) + await mkdir(join(root, 'outside', 'nested'), { recursive: true }) + await writeFile(join(root, 'outside', 'notes.txt'), 'hi') + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + it('lists a directory outside allowed roots without granting access to it', async () => { + registerFilesystemHostBrowseHandlers() + const outside = join(root, 'outside') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: fs:browseHostDir resolves to a HostDirectoryListing. + const listing = (await handlers.get('fs:browseHostDir')?.(null, { + dirPath: outside + })) as HostDirectoryListing + + expect(listing.resolvedPath).toBe(outside) + expect(listing.entries.map((entry) => entry.name).sort()).toEqual(['nested', 'notes.txt']) + await expect(isPathAllowed(outside, store)).resolves.toBe(false) + await expect(isPathAllowed(join(outside, 'nested', 'x'), store)).resolves.toBe(false) + await expect(resolveAuthorizedPath(join(outside, 'nested'), store)).rejects.toThrow( + /Access denied/ + ) + }) + + it('classifies a regular file outside the workspace without granting it', async () => { + const file = join(root, 'outside', 'notes.txt') + const resolution = await resolveHostBrowseEntry(file) + + expect(resolution).toEqual({ kind: 'file', realPath: file, workspaceRelativePath: null }) + await expect(isPathAllowed(file, store)).resolves.toBe(false) + await expect(isPathAllowed(join(root, 'outside'), store)).resolves.toBe(false) + await expect(isPathAllowed(join(root, 'outside', 'nested'), store)).resolves.toBe(false) + }) + + it('classifies a symlink to a directory as a directory and grants nothing', async () => { + const target = join(root, 'outside', 'nested') + const link = join(root, 'dir-link') + await symlink(target, link, 'dir') + + const resolution = await resolveHostBrowseEntry(link) + + expect(resolution).toEqual({ kind: 'directory', realPath: target, workspaceRelativePath: null }) + await expect(isPathAllowed(join(target, 'anything'), store)).resolves.toBe(false) + await expect(isPathAllowed(join(link, 'anything'), store)).resolves.toBe(false) + }) + + it('reports the canonical path of a symlinked file for worktree ownership checks', async () => { + const target = join(root, 'outside', 'notes.txt') + const link = join(root, 'file-link') + await symlink(target, link, 'file') + + await expect(resolveHostBrowseEntry(link)).resolves.toEqual({ + kind: 'file', + realPath: target, + workspaceRelativePath: null + }) + }) + + it('rejects relative and null-byte paths, locally and over SSH', async () => { + await expect(resolveHostBrowseEntry('outside/notes.txt'), 'relative').rejects.toThrow( + /absolute/ + ) + await expect(resolveHostBrowseEntry(`${root}\0x`), 'local NUL').rejects.toThrow(/null bytes/) + await expect( + resolveHostBrowseEntry('/home/allen\0x', 'ssh-1'), + 'null-byte SSH path' + ).rejects.toThrow(/null bytes/) + }) + + it('resolves workspace ownership against the canonical workspace root', async () => { + const realWorkspace = join(root, 'real-workspace') + await mkdir(join(realWorkspace, 'src'), { recursive: true }) + await writeFile(join(realWorkspace, 'src', 'a.ts'), 'x') + const workspaceAlias = join(root, 'workspace-alias') + await symlink(realWorkspace, workspaceAlias, 'dir') + const outsideLink = join(root, 'outside', 'a-link.ts') + await symlink(join(realWorkspace, 'src', 'a.ts'), outsideLink, 'file') + + const resolution = await resolveHostBrowseEntry(outsideLink, undefined, workspaceAlias) + + expect(resolution).toEqual({ + kind: 'file', + realPath: join(realWorkspace, 'src', 'a.ts'), + workspaceRelativePath: 'src/a.ts' + }) + // Why: workspace files are reached through allowed roots; no external grant is added. + await expect(isPathAllowed(outsideLink, store)).resolves.toBe(false) + }) +}) diff --git a/src/main/ipc/filesystem/filesystem-host-browse-handlers.ts b/src/main/ipc/filesystem/filesystem-host-browse-handlers.ts new file mode 100644 index 000000000000..c414b560d009 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-host-browse-handlers.ts @@ -0,0 +1,69 @@ +import { ipcMain } from 'electron' +import { realpath, stat } from 'node:fs/promises' +import { isAbsolute } from 'node:path' +import { relativePathInsideRoot } from '../../../shared/cross-platform-path' +import type { + HostBrowseEntryResolution, + HostDirectoryListing +} from '../../../shared/filesystem-entry-types' +import { browseServerDirectory } from '../../runtime/runtime-server-environment-commands' + +async function canonicalRelativePath( + realPath: string, + workspaceRoot: string | undefined, + canonicalize: (path: string) => Promise +): Promise { + if (!workspaceRoot) { + return null + } + try { + return relativePathInsideRoot(await canonicalize(workspaceRoot), realPath) + } catch { + return null + } +} + +export async function resolveHostBrowseEntry( + targetPath: string, + connectionId?: string, + workspaceRoot?: string +): Promise { + if (targetPath.includes('\0')) { + throw new Error('Path cannot contain null bytes') + } + if (connectionId) { + // ponytail: no SSH filesystem provider exists since the relay removal (#31922); add an SSH realpath/stat RPC to classify remote entries. + throw new Error('Remote connection dropped. Click Reconnect on the SSH target before retrying.') + } + if (!isAbsolute(targetPath)) { + throw new Error('Host browse path must be absolute') + } + const realPath = await realpath(targetPath) + const target = await stat(realPath) + const workspaceRelativePath = await canonicalRelativePath(realPath, workspaceRoot, realpath) + if (target.isDirectory()) { + return { kind: 'directory', realPath, workspaceRelativePath } + } + if (!target.isFile()) { + return { kind: 'unsupported', realPath, workspaceRelativePath } + } + return { kind: 'file', realPath, workspaceRelativePath } +} + +export function registerFilesystemHostBrowseHandlers(): void { + // Why: names-only listing never touches the external-path allowlist, so browsing + // outside the workspace grants nothing to later read/write/delete handlers. + ipcMain.handle( + 'fs:browseHostDir', + (_event, args: { dirPath: string }): Promise => + browseServerDirectory(args.dirPath) + ) + ipcMain.handle( + 'fs:resolveHostBrowseEntry', + ( + _event, + args: { targetPath: string; connectionId?: string; workspaceRoot?: string } + ): Promise => + resolveHostBrowseEntry(args.targetPath, args.connectionId, args.workspaceRoot) + ) +} diff --git a/src/main/ipc/filesystem/filesystem.ts b/src/main/ipc/filesystem/filesystem.ts index 014f16a429a9..f4c1021e18ed 100644 --- a/src/main/ipc/filesystem/filesystem.ts +++ b/src/main/ipc/filesystem/filesystem.ts @@ -19,6 +19,7 @@ import { registerFilesystemGitRemoteHandlers } from './filesystem-git-remote-han import { registerFilesystemGitDiffHandlers } from './filesystem-git-diff-handlers' import { registerFilesystemGitIndexHandlers } from './filesystem-git-index-handlers' import { registerFilesystemGitUrlHandlers } from './filesystem-git-url-handlers' +import { registerFilesystemHostBrowseHandlers } from './filesystem-host-browse-handlers' export function registerFilesystemHandlers( store: Store, @@ -44,5 +45,6 @@ export function registerFilesystemHandlers( registerFilesystemGitDiffHandlers(context) registerFilesystemGitIndexHandlers(context) registerFilesystemGitUrlHandlers(context) + registerFilesystemHostBrowseHandlers() registerLocalLogTailHandlers(store) } diff --git a/src/main/runtime/runtime-server-environment-commands.ts b/src/main/runtime/runtime-server-environment-commands.ts index c75fd1ab4b72..bb0550336a45 100644 --- a/src/main/runtime/runtime-server-environment-commands.ts +++ b/src/main/runtime/runtime-server-environment-commands.ts @@ -2,7 +2,7 @@ import { readdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' import type { Dirent } from 'node:fs' import { isAbsolute, join, resolve } from 'node:path' -import type { DirEntry, FilesystemPathFlavor } from '../../shared/filesystem-entry-types' +import type { HostDirectoryListing } from '../../shared/filesystem-entry-types' import { sortDirEntries } from '../../shared/file-name-sort' import { probeGitAvailability } from '../git/git-availability' import { gitExecFileAsync } from '../git/runner' @@ -40,36 +40,36 @@ async function isBrowseEntryDirectory(dirPath: string, entry: Dirent): Promise { + if (isServerDriveListRequest(pathValue)) { + return listWindowsDrives() + } + const dirPath = resolveServerBrowsePath(pathValue) + const dirStat = await stat(dirPath) + if (!dirStat.isDirectory()) { + throw new Error(`${dirPath} is not a directory`) + } + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped = await Promise.all( + entries + .filter((entry) => entry.name !== '.' && entry.name !== '..') + .map(async (entry) => ({ + name: entry.name, + isDirectory: await isBrowseEntryDirectory(dirPath, entry), + isSymlink: entry.isSymbolicLink() + })) + ) + sortDirEntries(mapped) + return { + resolvedPath: dirPath, + entries: mapped, + pathFlavor: process.platform === 'win32' ? 'win32' : 'posix' + } +} + export class RuntimeServerEnvironmentCommands { - async browseDirectory(pathValue: string): Promise<{ - resolvedPath: string - entries: DirEntry[] - pathFlavor: FilesystemPathFlavor - }> { - if (isServerDriveListRequest(pathValue)) { - return listWindowsDrives() - } - const dirPath = resolveServerBrowsePath(pathValue) - const dirStat = await stat(dirPath) - if (!dirStat.isDirectory()) { - throw new Error(`${dirPath} is not a directory`) - } - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped = await Promise.all( - entries - .filter((entry) => entry.name !== '.' && entry.name !== '..') - .map(async (entry) => ({ - name: entry.name, - isDirectory: await isBrowseEntryDirectory(dirPath, entry), - isSymlink: entry.isSymbolicLink() - })) - ) - sortDirEntries(mapped) - return { - resolvedPath: dirPath, - entries: mapped, - pathFlavor: process.platform === 'win32' ? 'win32' : 'posix' - } + async browseDirectory(pathValue: string): Promise { + return browseServerDirectory(pathValue) } async isGitAvailable(): Promise { diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index d77799477763..e9362e59b58f 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -5,6 +5,8 @@ import type { PdfExportResult } from '../../shared/pdf-export-failure-copy' import type { DirEntry, FsChangedPayload, + HostBrowseEntryResolution, + HostDirectoryListing, MarkdownDocument } from '../../shared/filesystem-entry-types' import type { @@ -184,6 +186,14 @@ export type FilesystemApi = { permanent?: boolean } & SshMutationExpectation ) => Promise + /** Desktop-only names listing for the Explorer's Host mode; never widens path grants. */ + browseHostDir?: (args: { dirPath: string }) => Promise + /** Desktop-only; classifies a host path against the workspace without granting access. */ + resolveHostBrowseEntry?: (args: { + targetPath: string + connectionId?: string + workspaceRoot?: string + }) => Promise stat: (args: { filePath: string connectionId?: string diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index 663695932104..f8a1f450d1da 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -18,7 +18,11 @@ import type { AgentSessionAttachmentUploadTarget } from '../../shared/agent-session-attachments' import type { SearchResult } from '../../shared/code-search-types' -import type { FsChangedPayload } from '../../shared/filesystem-entry-types' +import type { + FsChangedPayload, + HostBrowseEntryResolution, + HostDirectoryListing +} from '../../shared/filesystem-entry-types' import type { ImportItemResult, ResolveDroppedPathsResult, @@ -183,6 +187,13 @@ export const fsApi = { permanent?: boolean } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:deletePath', args), + browseHostDir: (args: { dirPath: string }): Promise => + ipcRenderer.invoke('fs:browseHostDir', args), + resolveHostBrowseEntry: (args: { + targetPath: string + connectionId?: string + workspaceRoot?: string + }): Promise => ipcRenderer.invoke('fs:resolveHostBrowseEntry', args), stat: (args: { filePath: string connectionId?: string diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.host-mode.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.host-mode.test.tsx new file mode 100644 index 000000000000..329a92883877 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/FileExplorer.host-mode.test.tsx @@ -0,0 +1,239 @@ +// @vitest-environment happy-dom + +import { act, useEffect } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const h = vi.hoisted(() => ({ + hostActive: false, + setHostActive: (_active: boolean) => {}, + treeMounts: 0, + treeUnmounts: 0, + treeRenders: 0, + enter: () => {}, + exit: () => {}, + listFiles: vi.fn(), + search: vi.fn(), + watchWorktree: vi.fn(), + unwatchWorktree: vi.fn() +})) + +const state = { + rightSidebarExplorerView: 'files', + showRightSidebarFiles: () => {}, + showRightSidebarSearch: () => {}, + activeWorktreeId: 'wt-1', + expandedDirs: {}, + collapseAllDirs: () => {}, + activeFileId: null, + openFiles: [], + rightSidebarOpen: true, + showDotfilesByWorktree: {}, + toggleShowDotfilesForWorktree: () => {}, + explorerDisplayRootByWorktree: {}, + runtimeEnvironments: [] +} + +vi.mock('@/store', () => ({ + useAppStore: (selector: (s: typeof state) => unknown) => selector(state) +})) +vi.mock('@/store/selectors', () => ({ + useActiveWorktree: () => ({ id: 'wt-1', repoId: 'repo-1', path: '/home/allen/codes' }), + useRepoById: () => null +})) +vi.mock('./FileExplorerFilesTreePane', () => ({ + FileExplorerFilesTreePane: () => { + h.treeRenders += 1 + useEffect(() => { + h.treeMounts += 1 + return () => { + h.treeUnmounts += 1 + } + }, []) + return
+ } +})) +vi.mock('./FileExplorerToolbar', () => ({ + FileExplorerToolbar: () =>
+})) +vi.mock('./FileExplorerHostBar', () => ({ + FileExplorerHostBar: () =>
+})) +vi.mock('./FileExplorerHostList', () => ({ + FileExplorerHostList: () =>
+})) +vi.mock('./FileExplorerBackgroundMenu', () => ({ FileExplorerBackgroundMenu: () => null })) +vi.mock('./SearchResultsPane', () => ({ SearchResultsPane: () => null })) +vi.mock('./SearchQueryRow', () => ({ SearchQueryRow: () =>
})) +vi.mock('./SearchFilters', () => ({ SearchFilters: () => null })) +vi.mock('./useFileSearchPanel', () => ({ + useFileSearchPanel: () => ({ + activeWorktreeId: 'wt-1', + queryRowProps: {}, + filtersProps: {}, + resultsProps: {} + }) +})) +vi.mock('./useFileExplorerTree', () => ({ + useFileExplorerTree: () => ({ dirCache: {}, refreshTree: vi.fn() }) +})) +vi.mock('./use-file-explorer-name-filter', () => ({ + useFileExplorerNameFilter: () => ({ + nameFilterQuery: '', + setNameFilterQuery: vi.fn(), + nameFilterCollapsedPaths: new Set(), + setNameFilterCollapsedPaths: vi.fn(), + hasNameFilter: false, + nameFilterFiles: { loading: false }, + nameFilterSource: null, + handleClearNameFilter: vi.fn() + }) +})) +vi.mock('./useFileExplorerVisibleRowProjection', () => ({ + useFileExplorerVisibleRowProjection: () => ({ + rowProjection: { getVisibleCount: () => 0, getRowByPath: () => null }, + ignoredByRelativePath: new Map(), + showGitIgnoredFiles: false, + nameFilterExpandedPaths: new Set(), + toggleGitIgnoredFiles: vi.fn() + }) +})) +vi.mock('./useFileExplorerManualRefresh', () => ({ + useFileExplorerManualRefresh: () => ({ + isRefreshing: false, + showRefreshSpinner: false, + handleRefresh: vi.fn() + }) +})) +vi.mock('./useFileExplorerSelection', () => ({ + useFileExplorerSelection: () => ({ selectedPath: null }) +})) +vi.mock('./use-file-explorer-tree-pane-state', () => ({ + useFileExplorerTreePaneState: () => ({ + inlineInputState: { inlineInput: null, startNew: vi.fn() }, + rowScrolling: { setExplorerShellRef: vi.fn() }, + dragDrop: { dragSourcePath: null, isNativeDragOver: false } + }) +})) +vi.mock('./use-file-explorer-background-menu', () => ({ + useFileExplorerBackgroundMenu: () => ({ + bgMenuOpen: false, + setBgMenuOpen: vi.fn(), + bgMenuPoint: null, + handleExplorerBackgroundContextMenuCapture: vi.fn(), + handleExplorerBackgroundDoubleClick: vi.fn() + }) +})) +vi.mock('./use-file-explorer-scope-transition', () => ({ + useFileExplorerScopeTransition: () => {} +})) +vi.mock('./use-file-explorer-host-mode', async () => { + const { useState } = await import('react') + return { + useFileExplorerHostMode: () => { + const [active, setActive] = useState(h.hostActive) + h.setHostActive = setActive + return { + active, + filterQuery: '', + setFilterQuery: vi.fn(), + available: true, + browser: {} + } + } + } +}) + +import FileExplorer from './FileExplorer' + +let root: Root +let container: HTMLDivElement + +async function render(): Promise { + await act(async () => { + root.render() + }) +} + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + h.hostActive = false + h.treeMounts = 0 + h.treeUnmounts = 0 + h.treeRenders = 0 + Object.defineProperty(window, 'api', { + configurable: true, + value: { + fs: { + listFiles: h.listFiles, + search: h.search, + watchWorktree: h.watchWorktree, + unwatchWorktree: h.unwatchWorktree + } + } + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + document.body.replaceChildren() +}) + +describe('FileExplorer Host mode', () => { + it('overlays the Host list without remounting, re-rendering, or re-watching the tree', async () => { + await render() + const rendersBefore = h.treeRenders + expect(h.treeMounts).toBe(1) + expect(container.querySelector('[data-testid="host-list"]')).toBeNull() + + for (const active of [true, false, true, false]) { + await act(async () => h.setHostActive(active)) + expect(container.querySelector('[data-testid="host-list"]') !== null).toBe(active) + } + + expect(h.treeRenders, 'toggling Host mode does not re-render the Project tree').toBe( + rendersBefore + ) + expect(h.treeMounts).toBe(1) + expect(h.treeUnmounts).toBe(0) + expect(h.watchWorktree).not.toHaveBeenCalled() + expect(h.unwatchWorktree).not.toHaveBeenCalled() + }) + + it('makes the hidden tree inert and swaps in the Host bar and list', async () => { + h.hostActive = true + await render() + + const tree = container.querySelector('[data-testid="tree"]') + expect(tree?.closest('[inert]')).not.toBeNull() + expect(container.querySelector('[data-testid="host-bar"]')).not.toBeNull() + expect(container.querySelector('[data-testid="host-list"]')).not.toBeNull() + expect(h.listFiles).not.toHaveBeenCalled() + expect(h.search).not.toHaveBeenCalled() + }) + + it('keeps the Project tree and query rows interactive until Host mode makes them inert', async () => { + await render() + expect( + container.querySelector('[data-testid="tree"]')?.closest('[inert]'), + 'Project tree is interactive outside Host mode' + ).toBeNull() + expect( + container.querySelector('[data-testid="host-bar"]'), + 'no Host bar outside Host mode' + ).toBeNull() + const projectFilter = container.querySelector('input[aria-label="Find files"]') + const contentsQuery = container.querySelector('[data-testid="contents-query"]') + expect(projectFilter?.closest('[inert]')).toBeNull() + expect(contentsQuery?.closest('[inert]')).toBeNull() + + await act(async () => h.setHostActive(true)) + + expect(projectFilter?.isConnected).toBe(true) + expect(projectFilter?.closest('[inert]')).not.toBeNull() + expect(contentsQuery?.closest('[inert]')).not.toBeNull() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 374fc8d432ec..3059b592f736 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -15,17 +15,15 @@ import { FileExplorerScopeNotice } from './FileExplorerScopeNotice' import { useFileExplorerRootNavigation } from './use-file-explorer-root-navigation' import { useFileExplorerScopeTransition } from './use-file-explorer-scope-transition' import { basename } from '@/lib/path' -import { cn } from '@/lib/utils' import { isGitRepoKind } from '../../../../shared/repo-kind' import { getVisibleFileExplorerWorktreePath } from './file-explorer-reset' import { FileExplorerBackgroundMenu } from './FileExplorerBackgroundMenu' import { FileExplorerFilesTreePane } from './FileExplorerFilesTreePane' import { FileExplorerNameFilter } from './FileExplorerNameFilter' import { FileExplorerQueryStrip } from './FileExplorerQueryStrip' -import { FileExplorerToolbar } from './FileExplorerToolbar' import { SearchFilters } from './SearchFilters' import { SearchQueryRow } from './SearchQueryRow' -import { SearchResultsPane } from './SearchResultsPane' +import { FileExplorerSearchResultsLayer } from './FileExplorerSearchResultsLayer' import { useFileSearchPanel } from './useFileSearchPanel' import { getNameFilterCollapsedPathsAfterExpand, @@ -39,6 +37,14 @@ import { useFileExplorerVisibleRowProjection } from './useFileExplorerVisibleRow import { useFileExplorerBackgroundMenu } from './use-file-explorer-background-menu' import { useFileExplorerNameFilter } from './use-file-explorer-name-filter' import { useFileExplorerTreePaneState } from './use-file-explorer-tree-pane-state' +import { + FileExplorerHostAwareToolbar, + FileExplorerHostInertBoundary, + FileExplorerHostModeProvider, + FileExplorerHostOverlay, + FileExplorerProjectOnly +} from './file-explorer-host-mode-context' +import { FileExplorerHostQueryRow } from './FileExplorerHostQueryRow' import { useWslHostUnavailableReason } from '../terminal-pane/use-wsl-host-unavailable-reason' import { translate } from '@/i18n/i18n' import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' @@ -240,7 +246,7 @@ function FileExplorerFiles(): React.JSX.Element { } return ( - <> +
- {activeWorktree?.isSparse && ( - + + + )} {/* Keep query rows mounted to preserve input selection when switching modes. */}
- - - + + + + + + + +
{/* Layer Files/Contents in one body slot to retain heavy virtualized panes at full height. */}
- -
- {searchPanel.activeWorktreeId ? ( - - ) : ( -
- {translate( - 'auto.components.right.sidebar.Search.98c8435e36', - 'Select a workspace to search' - )} -
- )} -
+ + {/* Why: Host mode overlays instead of unmounting so tree caches and listeners survive. */} + + + +
@@ -358,7 +361,7 @@ function FileExplorerFiles(): React.JSX.Element { displayDepth={displayDepth} onStartNew={inlineInputState.startNew} /> - +
) } diff --git a/src/renderer/src/components/right-sidebar/FileExplorerHostBar.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorerHostBar.test.tsx new file mode 100644 index 000000000000..818e6393a4d1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/FileExplorerHostBar.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { FileExplorerHostBar } from './FileExplorerHostBar' +import type { FileExplorerHostMode } from './use-file-explorer-host-mode' + +let root: Root +let container: HTMLDivElement + +function hostMode(listing: boolean, showLoading: boolean): FileExplorerHostMode { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bar reads only these fields. + return { + active: true, + hostLabel: 'Local Mac', + exit: vi.fn(), + browser: { + listing: listing ? { resolvedPath: '/home/allen', entries: [], pathFlavor: 'posix' } : null, + loading: true, + showLoading, + error: null, + canNavigateUp: listing, + navigate: vi.fn(), + navigateUp: vi.fn(), + refresh: vi.fn(), + activateEntry: vi.fn(), + reset: vi.fn() + } + } as unknown as FileExplorerHostMode +} + +async function render(mode: FileExplorerHostMode): Promise { + await act(async () => { + root.render( + + + + ) + }) + const bar = container.querySelector('[data-file-explorer-host-bar]') + if (!bar) { + throw new Error('host bar not rendered') + } + return bar +} + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + document.body.replaceChildren() +}) + +describe('FileExplorerHostBar', () => { + it('reserves the breadcrumb row so the first listing does not grow the bar', async () => { + const before = await render(hostMode(false, false)) + const rowsBefore = before.children.length + + const after = await render(hostMode(true, false)) + + expect(after.children.length).toBe(rowsBefore) + }) + + it('shows the refresh spinner only once loading has lasted', async () => { + expect((await render(hostMode(true, false))).querySelector('.animate-spin')).toBeNull() + expect((await render(hostMode(true, true))).querySelector('.animate-spin')).not.toBeNull() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/FileExplorerHostBar.tsx b/src/renderer/src/components/right-sidebar/FileExplorerHostBar.tsx new file mode 100644 index 000000000000..82cd3262fb86 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/FileExplorerHostBar.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import { FolderGit2, Loader2, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { RemoteFileBrowserBreadcrumbs } from '../sidebar/RemoteFileBrowserBreadcrumbs' +import type { FileExplorerHostMode } from './use-file-explorer-host-mode' + +export function FileExplorerHostBar({ + hostMode +}: { + hostMode: FileExplorerHostMode +}): React.JSX.Element { + const { browser, hostLabel, exit: onReturnToProject } = hostMode + const returnLabel = translate('fileExplorer.host.returnToProject', 'Return to workspace root') + const refreshLabel = translate('fileExplorer.host.refresh', 'Refresh folder') + return ( +
+
+ + + + + {returnLabel} + + + {translate('fileExplorer.host.browsing', 'Host: {{host}}', { host: hostLabel })} + + + + + + {refreshLabel} + +
+ {browser.listing ? ( + + ) : ( + // Why: reserve the breadcrumb row so the first listing does not grow the bar again. +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerHostList.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorerHostList.test.tsx new file mode 100644 index 000000000000..411118ff898f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/FileExplorerHostList.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DirEntry } from '../../../../shared/filesystem-entry-types' +import { FileExplorerHostList } from './FileExplorerHostList' +import { shouldIgnoreFileExplorerKeyTarget } from './useFileExplorerKeys' +import type { FileExplorerHostMode } from './use-file-explorer-host-mode' + +let root: Root +let container: HTMLDivElement + +function hostMode(entries: DirEntry[], overrides: Partial = {}) { + const browser = { + listing: { resolvedPath: '/home/allen', entries, pathFlavor: 'posix' as const }, + loading: false, + error: null, + canNavigateUp: true, + navigate: vi.fn(), + navigateUp: vi.fn(), + refresh: vi.fn(), + activateEntry: vi.fn(), + ...overrides + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the list reads only `browser` and `filterQuery`. + return { browser, filterQuery: '' } as unknown as FileExplorerHostMode +} + +async function render(mode: FileExplorerHostMode, showDotfiles = true): Promise { + await act(async () => { + root.render() + }) +} + +function rowLabels(): string[] { + return [...container.querySelectorAll('button')].map((button) => button.textContent ?? '') +} + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + document.body.replaceChildren() +}) + +const codes = { name: 'codes', isDirectory: true, isSymlink: false } +const documents = { name: 'Documents', isDirectory: true, isSymlink: false } +const bashrc = { name: '.bashrc', isDirectory: false, isSymlink: false } + +describe('FileExplorerHostList', () => { + it('lists a Parent row above sibling folders and activates entries', async () => { + const mode = hostMode([codes, documents, bashrc]) + await render(mode) + + expect(rowLabels()).toEqual(['Parent directory', 'codes', 'Documents', '.bashrc']) + act(() => container.querySelectorAll('button')[0]?.click()) + act(() => container.querySelectorAll('button')[2]?.click()) + expect(mode.browser.navigateUp).toHaveBeenCalledTimes(1) + expect(mode.browser.activateEntry).toHaveBeenCalledWith(documents) + }) + + it('omits the Parent row at a filesystem root and hides dotfiles when asked', async () => { + await render(hostMode([codes, bashrc], { canNavigateUp: false }), false) + + expect(rowLabels()).toEqual(['codes']) + }) + + it('keeps tree shortcuts from acting on the hidden tree selection', async () => { + await render(hostMode([codes])) + + const row = container.querySelectorAll('button')[1] + expect(shouldIgnoreFileExplorerKeyTarget(row ?? null)).toBe(true) + }) + + it('mounts only a window of rows for very large folders', async () => { + const many = Array.from({ length: 5_000 }, (_, index) => ({ + name: `f${index}`, + isDirectory: false, + isSymlink: false + })) + await render(hostMode(many)) + + const mounted = container.querySelectorAll('[data-file-explorer-host-list] button').length + expect(container.querySelector('[data-testid="virtualized-list"]')).not.toBeNull() + expect(mounted).toBeGreaterThan(0) + expect(mounted).toBeLessThan(200) + }) + + it('dims the folder on screen while a slow navigation is pending', async () => { + await render(hostMode([codes], { showLoading: true })) + expect(container.querySelector('[data-file-explorer-host-list]')?.className).toContain( + 'opacity-60' + ) + + await render(hostMode([codes], { showLoading: false })) + expect(container.querySelector('[data-file-explorer-host-list]')?.className).not.toContain( + 'opacity-60' + ) + }) + + it('starts a new folder or filter at the top but keeps the place on revalidation', async () => { + const at = ( + path: string, + entries: DirEntry[], + filterQuery = '', + error: string | null = null + ) => { + const mode = hostMode(entries, { + listing: { resolvedPath: path, entries, pathFlavor: 'posix' as const }, + error + }) + return { ...mode, filterQuery } + } + const scroller = () => container.querySelector('[data-file-explorer-host-list]') + const scrollTo = (top: number) => { + const el = scroller() + if (el) { + el.scrollTop = top + } + } + + await render(at('/home/allen', [codes, documents])) + scrollTo(500) + await render(at('/home/allen', [codes, documents, bashrc])) + expect(scroller()?.scrollTop).toBe(500) + + await render(at('/home/allen', [codes, documents, bashrc])) + expect(scroller()?.scrollTop).toBe(500) + + await render(at('/home/allen/Documents', [bashrc])) + expect(scroller()?.scrollTop).toBe(0) + + scrollTo(500) + await render(at('/home/allen/Documents', [bashrc], 'bash')) + expect(scroller()?.scrollTop).toBe(0) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/FileExplorerHostList.tsx b/src/renderer/src/components/right-sidebar/FileExplorerHostList.tsx new file mode 100644 index 000000000000..bcab9174457f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/FileExplorerHostList.tsx @@ -0,0 +1,103 @@ +import React, { useDeferredValue, useLayoutEffect, useMemo, useState } from 'react' +import { CornerLeftUp, Folder, Link } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { getFileTypeIcon } from '@/lib/file-type-icons' +import { VirtualizedList } from '@/components/virtualized-list' +import type { DirEntry } from '../../../../shared/filesystem-entry-types' +import { FileExplorerTreeStatus } from './FileExplorerTreeStatus' +import { filterHostEntries } from './file-explorer-host-mode' +import type { FileExplorerHostMode } from './use-file-explorer-host-mode' + +const ROW_CLASS = + 'flex w-full items-center gap-1 rounded-sm px-2 py-1 text-left text-xs transition-colors hover:bg-accent hover:text-foreground' + +function HostEntryIcon({ entry }: { entry: DirEntry }): React.JSX.Element { + if (entry.isDirectory) { + return + } + if (entry.isSymlink) { + return + } + return React.createElement(getFileTypeIcon(entry.name), { + className: 'size-3 shrink-0 text-muted-foreground' + }) +} + +export function FileExplorerHostList({ + hostMode, + showDotfiles +}: { + hostMode: FileExplorerHostMode + showDotfiles: boolean +}): React.JSX.Element { + const { browser, filterQuery } = hostMode + const deferredQuery = useDeferredValue(filterQuery) + const listingEntries = browser.listing?.entries + const entries = useMemo( + () => (listingEntries ? filterHostEntries(listingEntries, deferredQuery, showDotfiles) : []), + [listingEntries, deferredQuery, showDotfiles] + ) + // Why: state, not a ref, so the virtualizer observes the scroller once it attaches. + const [scrollElement, setScrollElement] = useState(null) + const resolvedPath = browser.listing?.resolvedPath ?? null + // Why: a new folder or filter starts at the top; reloading the same folder (refresh, a failed + // navigation) keeps the user's place. + useLayoutEffect(() => { + if (scrollElement) { + scrollElement.scrollTop = 0 + } + }, [scrollElement, resolvedPath, deferredQuery]) + const status = ( + + ) + return ( + + ) +} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerHostQueryRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerHostQueryRow.tsx new file mode 100644 index 000000000000..b3a2fcae4556 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/FileExplorerHostQueryRow.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' +import type { RightSidebarExplorerView } from '../../../../shared/ui-chrome-types' +import { FileExplorerNameFilter } from './FileExplorerNameFilter' +import { useFileExplorerHostModeContext } from './file-explorer-host-mode-context-value' + +/** + * Host mode filters only the listed folder; Contents search stays scoped to Project mode. It covers + * the Names/Contents slot in place so the Project query rows stay mounted and nothing shifts. + */ +export function FileExplorerHostQueryRow({ + view +}: { + view: RightSidebarExplorerView +}): React.JSX.Element | null { + const { active, filterQuery, setFilterQuery } = useFileExplorerHostModeContext() + if (!active) { + return null + } + return ( +
+ {view === 'search' ? ( +

+ {translate( + 'fileExplorer.host.contentsUnavailable', + 'Contents search covers the workspace. Return to the workspace root to use it.' + )} +

+ ) : ( + setFilterQuery('')} + /> + )} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerSearchResultsLayer.tsx b/src/renderer/src/components/right-sidebar/FileExplorerSearchResultsLayer.tsx new file mode 100644 index 000000000000..30b7d2dadd31 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/FileExplorerSearchResultsLayer.tsx @@ -0,0 +1,32 @@ +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { SearchResultsPane } from './SearchResultsPane' +import type { FileSearchPanelModel } from './useFileSearchPanel' + +export function FileExplorerSearchResultsLayer({ + explorerView, + searchPanel +}: { + explorerView: 'files' | 'search' + searchPanel: FileSearchPanelModel +}): React.JSX.Element { + return ( +
+ {searchPanel.activeWorktreeId ? ( + + ) : ( +
+ {translate( + 'auto.components.right.sidebar.Search.98c8435e36', + 'Select a workspace to search' + )} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerToolbar.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorerToolbar.test.tsx index 988d5cfc9a51..c395c981807b 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerToolbar.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerToolbar.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Ellipsis, ListCollapse, Loader2, RefreshCw } from 'lucide-react' +import { Ellipsis, HardDrive, ListCollapse, Loader2, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { DropdownMenuCheckboxItem } from '@/components/ui/dropdown-menu' import { WorktreeOpenInMenuItems } from '@/components/sidebar/WorktreeOpenInMenu' @@ -172,11 +172,60 @@ function makeToolbar(overrides: Partial[0 onToggleGitIgnoredFiles: vi.fn(), showDotfiles: true, onToggleDotfiles: vi.fn(), + hostMode: { active: false, available: true, onToggle: vi.fn() }, ...overrides }) } +function findButtonByLabel(node: unknown, label: string): { props: Record } { + let found: { props: Record } | null = null + visit(node, (entry) => { + if (entry.type === Button && entry.props['aria-label'] === label) { + found = entry + } + }) + if (!found) { + throw new Error(`${label} button not found`) + } + return found +} + describe('FileExplorerToolbar', () => { + it('enters Host mode from the host filesystem toggle', () => { + const onToggle = vi.fn() + const element = makeToolbar({ hostMode: { active: false, available: true, onToggle } }) + + const button = findButtonByLabel(element, 'Browse host filesystem') + ;(button.props.onClick as () => void)() + + expect(onToggle).toHaveBeenCalledTimes(1) + expect(button.props['aria-pressed']).toBe(false) + expect(button.props.disabled).toBe(false) + expect(hasIcon(button, HardDrive)).toBe(true) + }) + + it('offers a return to the workspace root while Host mode is active', () => { + const onToggle = vi.fn() + const element = makeToolbar({ hostMode: { active: true, available: true, onToggle } }) + + const button = findButtonByLabel(element, 'Return to workspace root') + ;(button.props.onClick as () => void)() + + expect(onToggle).toHaveBeenCalledTimes(1) + expect(button.props['aria-pressed']).toBe(true) + expect(button.props.variant).toBe('secondary') + }) + + it('disables an unavailable Host toggle and explains why', () => { + const element = makeToolbar({ + hostMode: { active: false, available: false, onToggle: vi.fn() } + }) + + expect( + findButtonByLabel(element, 'Host browsing is not available for this workspace').props.disabled + ).toBe(true) + }) + it('fires the refresh action from the icon button', () => { const onRefresh = vi.fn() const element = makeToolbar({ refresh: makeRefreshState({ handleRefresh: onRefresh }) }) @@ -317,6 +366,7 @@ describe('FileExplorerToolbar', () => { const element = makeToolbar() expect(getToolbarButtonLabels(element)).toEqual([ + 'Browse host filesystem', 'Collapse All', 'Refresh Explorer', 'More Explorer Actions' diff --git a/src/renderer/src/components/right-sidebar/FileExplorerToolbar.tsx b/src/renderer/src/components/right-sidebar/FileExplorerToolbar.tsx index c9ff01c897f8..dd99026889f1 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerToolbar.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerToolbar.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Ellipsis, ListCollapse, Loader2, RefreshCw } from 'lucide-react' +import { Ellipsis, HardDrive, ListCollapse, Loader2, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -33,6 +33,11 @@ type FileExplorerToolbarProps = { onToggleGitIgnoredFiles: () => void showDotfiles: boolean onToggleDotfiles: () => void + hostMode: { + active: boolean + available: boolean + onToggle: () => void + } } function formatOpenInAppLabel(appName: string): string { @@ -60,8 +65,18 @@ export function FileExplorerToolbar({ showGitIgnoredFiles, onToggleGitIgnoredFiles, showDotfiles, - onToggleDotfiles + onToggleDotfiles, + hostMode }: FileExplorerToolbarProps): React.JSX.Element { + const hostModeLabel = hostMode.active + ? translate('fileExplorer.host.returnToProject', 'Return to workspace root') + : hostMode.available + ? translate('fileExplorer.host.enter', 'Browse host filesystem') + : translate( + 'fileExplorer.host.unavailable', + 'Host browsing is not available for this workspace' + ) + const canToggleHostMode = hostMode.active || hostMode.available return (
{repoName} + + + {/* Why: disabled buttons swallow pointer events, so the span carries the tooltip. */} + + + + + + {hostModeLabel} + +
- - {browseParts.kind === 'drive' && ( + {browseParts.kind !== 'unc' && ( + + )} + {anchorRoot !== null && ( <> - + {browseParts.kind === 'drive' && ( + + )} )} diff --git a/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts b/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts index c33b79846b55..8315e6145111 100644 --- a/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts +++ b/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.test.ts @@ -6,8 +6,11 @@ import { isDriveRoot, joinDrivePath, parentOfDrivePath, - splitBrowsePath + parentOfUncPath, + splitBrowsePath, + uncRootOf } from './remote-file-browser-drive-paths' +import { joinPath, parentPath } from './remote-file-browser-helpers' describe('isDrivePath', () => { it('accepts drive anchors with either separator, bare colon, and any case', () => { @@ -84,3 +87,61 @@ describe('driveBreadcrumbPath', () => { expect(driveBreadcrumbPath('M:\\', [], -1)).toBe('M:\\') }) }) + +describe('UNC paths', () => { + it('anchors UNC, WSL and long-path prefixes at their share root', () => { + expect(uncRootOf('\\\\server\\share\\dir')).toBe('\\\\server\\share\\') + expect(uncRootOf('\\\\wsl.localhost\\Ubuntu\\home')).toBe('\\\\wsl.localhost\\Ubuntu\\') + expect(uncRootOf('//server/share/dir')).toBe('\\\\server\\share\\') + expect(uncRootOf('\\\\?\\UNC\\server\\share\\x')).toBe('\\\\?\\UNC\\server\\share\\') + expect(uncRootOf('\\\\?\\C:\\Users')).toBe('\\\\?\\C:\\') + expect(uncRootOf('\\\\wsl.localhost')).toBeNull() + expect(uncRootOf('C:/Users/allen')).toBeNull() + expect(uncRootOf('/home/allen')).toBeNull() + }) + + it('splits UNC paths only for the win32 flavor', () => { + expect(splitBrowsePath('\\\\wsl.localhost\\Ubuntu\\home\\allen', 'win32')).toEqual({ + kind: 'unc', + uncRoot: '\\\\wsl.localhost\\Ubuntu\\', + segments: ['home', 'allen'] + }) + expect(splitBrowsePath('//server/share/dir', 'posix')).toEqual({ + kind: 'posix', + segments: ['server', 'share', 'dir'] + }) + }) + + it('stops at the share root when walking up', () => { + expect(parentOfUncPath('\\\\wsl.localhost\\Ubuntu\\home\\allen')).toBe( + '\\\\wsl.localhost\\Ubuntu\\home' + ) + expect(parentPath('\\\\wsl.localhost\\Ubuntu\\home', 'win32')).toBe( + '\\\\wsl.localhost\\Ubuntu\\' + ) + expect(parentPath('\\\\wsl.localhost\\Ubuntu\\', 'win32')).toBe('\\\\wsl.localhost\\Ubuntu\\') + expect(parentPath('\\\\server\\share', 'win32')).toBe('\\\\server\\share') + expect(parentPath('\\\\server\\share\\', 'win32')).toBe('\\\\server\\share\\') + expect(parentPath('//server/share', 'win32')).toBe('//server/share') + expect(parentPath('\\\\?\\UNC\\server\\share', 'win32')).toBe('\\\\?\\UNC\\server\\share') + expect(parentPath('\\\\?\\UNC\\server\\share\\dir', 'win32')).toBe( + '\\\\?\\UNC\\server\\share\\' + ) + expect(parentPath('\\\\?\\C:\\Users\\allen', 'win32')).toBe('\\\\?\\C:\\Users') + expect(parentPath('\\\\?\\C:\\', 'win32')).toBe('\\\\?\\C:\\') + expect(parentPath('\\\\?\\C:', 'win32')).toBe('\\\\?\\C:') + }) + + it('joins below a UNC root with a backslash', () => { + expect(joinPath('\\\\wsl.localhost\\Ubuntu\\', 'home', 'win32')).toBe( + '\\\\wsl.localhost\\Ubuntu\\home' + ) + expect(joinPath('\\\\server\\share\\dir', 'x', 'win32')).toBe('\\\\server\\share\\dir\\x') + }) + + it('treats SSH-Windows forward-slash drive paths as drives, not POSIX or UNC', () => { + expect(parentPath('C:/Users/allen/codes', 'win32')).toBe('C:\\Users\\allen') + expect(parentPath('C:/', 'win32')).toBe('/') + expect(parentPath('//server/share/dir', 'posix')).toBe('//server/share') + }) +}) diff --git a/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.ts b/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.ts index 7e68874dfae7..5962cd6d62fc 100644 --- a/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.ts +++ b/src/renderer/src/components/sidebar/remote-file-browser-drive-paths.ts @@ -1,6 +1,7 @@ export type BrowsePathParts = | { kind: 'posix'; segments: string[] } | { kind: 'drive'; driveRoot: string; segments: string[] } + | { kind: 'unc'; uncRoot: string; segments: string[] } // Bare `M:` is a root here because forwarding it would be drive-relative. const DRIVE_ANCHOR_RE = /^[A-Za-z]:([\\/]|$)/ @@ -17,10 +18,30 @@ export function driveRootOf(p: string): string { return `${p[0].toUpperCase()}:\\` } +// `\\server\share`, `\\?\UNC\server\share` and `\\?\C:` roots; nothing is browsable above them. +const UNC_ROOT_RE = + /^(?:[\\/]{2}\?[\\/](?:UNC[\\/][^\\/]+[\\/][^\\/]+|[A-Za-z]:)|[\\/]{2}(?![?.][\\/])[^\\/]+[\\/][^\\/]+)/i + +export function uncRootOf(p: string): string | null { + const match = UNC_ROOT_RE.exec(p) + return match ? `${match[0].replace(/\//g, '\\')}\\` : null +} + export function splitBrowsePath( p: string, pathFlavor: FilesystemPathFlavor = 'posix' ): BrowsePathParts { + const uncRoot = pathFlavor === 'win32' ? uncRootOf(p) : null + if (uncRoot) { + return { + kind: 'unc', + uncRoot, + segments: p + .slice(uncRoot.length - 1) + .split(/[\\/]/) + .filter(Boolean) + } + } if (pathFlavor === 'win32' && isDrivePath(p)) { return { kind: 'drive', @@ -51,6 +72,17 @@ export function parentOfDrivePath(p: string): string { : `${parts.driveRoot}${parentSegments.join('\\')}` } +// UNC share roots have no browsable parent, so up stays put. +export function parentOfUncPath(p: string): string { + const parts = splitBrowsePath(p, 'win32') + if (parts.kind !== 'unc') { + return p + } + return parts.segments.length === 0 + ? p + : driveBreadcrumbPath(parts.uncRoot, parts.segments, parts.segments.length - 2) +} + export function driveBreadcrumbPath( driveRoot: string, segments: string[], diff --git a/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts b/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts index 34662f65ccfe..f881643c9611 100644 --- a/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts +++ b/src/renderer/src/components/sidebar/remote-file-browser-helpers.ts @@ -6,7 +6,9 @@ import { driveRootOf, isDrivePath, joinDrivePath, - parentOfDrivePath + parentOfDrivePath, + parentOfUncPath, + uncRootOf } from './remote-file-browser-drive-paths' export type DirEntry = { name: string @@ -22,7 +24,7 @@ export function isRemoteFileBrowserFilterQueryTooLarge( return isClipboardTextByteLengthOverLimit(query, maxBytes) } -export function filterEntries(entries: DirEntry[], filter: string): DirEntry[] { +export function filterEntries(entries: T[], filter: string): T[] { if (isRemoteFileBrowserFilterQueryTooLarge(filter)) { return [] } @@ -66,13 +68,16 @@ export function joinPath( if (pathFlavor === 'win32' && resolvedPath === '/' && isDrivePath(name)) { return driveRootOf(name) } - if (pathFlavor === 'win32' && isDrivePath(resolvedPath)) { + if (pathFlavor === 'win32' && (isDrivePath(resolvedPath) || uncRootOf(resolvedPath))) { return joinDrivePath(resolvedPath, name) } return resolvedPath === '/' ? `/${name}` : `${resolvedPath}/${name}` } export function parentPath(p: string, pathFlavor: FilesystemPathFlavor = 'posix'): string { + if (pathFlavor === 'win32' && uncRootOf(p)) { + return parentOfUncPath(p) + } if (pathFlavor === 'win32' && isDrivePath(p)) { return parentOfDrivePath(p) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 956f52dbefdd..30be58d4990e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -21386,6 +21386,20 @@ "folderLoadError": "Could not load this folder:", "emptyFolder": "No files in this folder" }, + "host": { + "returnToProject": "Return to workspace root", + "refresh": "Refresh folder", + "browsing": "Host: {{host}}", + "parentDirectory": "Parent directory", + "contentsUnavailable": "Contents search covers the workspace. Return to the workspace root to use it.", + "thisFolder": "this folder", + "enter": "Browse host filesystem", + "folderOpenFailed": "Could not open this folder: {{error}}", + "unsupportedEntry": "This item cannot be opened.", + "entryMissing": "This item no longer exists.", + "entryUnverifiable": "Could not reach this item: {{error}}", + "unavailable": "Host browsing is not available for this workspace" + }, "trashFailed": { "titleRecycleBin": "Couldn't move '{{name}}' to the Recycle Bin. Delete it permanently?", "titleTrash": "Couldn't move '{{name}}' to the Trash. Delete it permanently?", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 82d9ba97846d..bb3d0630acca 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -20779,6 +20779,20 @@ "folderLoadError": "No se pudo cargar esta carpeta:", "emptyFolder": "No hay archivos en esta carpeta" }, + "host": { + "returnToProject": "Volver a la raíz del espacio de trabajo", + "refresh": "Actualizar carpeta", + "browsing": "Host: {{host}}", + "parentDirectory": "Carpeta superior", + "contentsUnavailable": "La búsqueda de contenido abarca el espacio de trabajo. Vuelve a la raíz del espacio de trabajo para usarla.", + "thisFolder": "esta carpeta", + "enter": "Explorar el sistema de archivos del host", + "folderOpenFailed": "No se pudo abrir esta carpeta: {{error}}", + "unsupportedEntry": "Este elemento no se puede abrir.", + "entryMissing": "Este elemento ya no existe.", + "entryUnverifiable": "No se pudo acceder a este elemento: {{error}}", + "unavailable": "La exploración del host no está disponible para este espacio de trabajo" + }, "trashFailed": { "titleRecycleBin": "No se pudo mover '{{name}}' a la Papelera de reciclaje. ¿Eliminarlo permanentemente?", "titleTrash": "No se pudo mover '{{name}}' a la Papelera. ¿Eliminarlo permanentemente?", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index f30e0e077fce..fb83d42c1189 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -21420,6 +21420,20 @@ "folderLoadError": "Impossible de charger ce dossier :", "emptyFolder": "Aucun fichier dans ce dossier" }, + "host": { + "returnToProject": "Revenir à la racine de l’espace de travail", + "refresh": "Actualiser le dossier", + "browsing": "Hôte : {{host}}", + "parentDirectory": "Dossier parent", + "contentsUnavailable": "La recherche de contenu porte sur l’espace de travail. Revenez à la racine de l’espace de travail pour l’utiliser.", + "thisFolder": "ce dossier", + "enter": "Parcourir le système de fichiers de l’hôte", + "folderOpenFailed": "Impossible d’ouvrir ce dossier : {{error}}", + "unsupportedEntry": "Cet élément ne peut pas être ouvert.", + "entryMissing": "Cet élément n’existe plus.", + "entryUnverifiable": "Impossible d’accéder à cet élément : {{error}}", + "unavailable": "La navigation sur l’hôte n’est pas disponible pour cet espace de travail" + }, "trashFailed": { "titleRecycleBin": "Impossible de déplacer « {{name}} » vers la Corbeille. Le supprimer définitivement ?", "titleTrash": "Impossible de déplacer « {{name}} » vers la Corbeille. Le supprimer définitivement ?", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4e2da4d9f88a..281cfdf3c4ea 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -20768,6 +20768,20 @@ "folderLoadError": "このフォルダーを読み込めませんでした:", "emptyFolder": "このフォルダーにはファイルがありません" }, + "host": { + "returnToProject": "ワークスペースのルートに戻る", + "refresh": "フォルダーを更新", + "browsing": "ホスト: {{host}}", + "parentDirectory": "親フォルダー", + "contentsUnavailable": "内容検索はワークスペースが対象です。使用するにはワークスペースのルートに戻ってください。", + "thisFolder": "このフォルダー", + "enter": "ホストのファイルシステムを参照", + "folderOpenFailed": "このフォルダーを開けませんでした: {{error}}", + "unsupportedEntry": "この項目は開けません。", + "entryMissing": "この項目はもう存在しません。", + "entryUnverifiable": "この項目にアクセスできませんでした: {{error}}", + "unavailable": "このワークスペースではホストの参照は使用できません" + }, "trashFailed": { "titleRecycleBin": "「{{name}}」をごみ箱に移動できませんでした。完全に削除しますか?", "titleTrash": "「{{name}}」をごみ箱に移動できませんでした。完全に削除しますか?", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 05bb90b38db8..affd86ca818c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -20768,6 +20768,20 @@ "folderLoadError": "이 폴더를 불러오지 못했습니다:", "emptyFolder": "이 폴더에 파일이 없습니다" }, + "host": { + "returnToProject": "작업 공간 루트로 돌아가기", + "refresh": "폴더 새로 고침", + "browsing": "호스트: {{host}}", + "parentDirectory": "상위 폴더", + "contentsUnavailable": "내용 검색은 작업 공간을 대상으로 합니다. 사용하려면 작업 공간 루트로 돌아가세요.", + "thisFolder": "이 폴더", + "enter": "호스트 파일 시스템 탐색", + "folderOpenFailed": "이 폴더를 열 수 없습니다: {{error}}", + "unsupportedEntry": "이 항목은 열 수 없습니다.", + "entryMissing": "이 항목은 더 이상 존재하지 않습니다.", + "entryUnverifiable": "이 항목에 접근할 수 없습니다: {{error}}", + "unavailable": "이 작업 공간에서는 호스트 탐색을 사용할 수 없습니다" + }, "trashFailed": { "titleRecycleBin": "'{{name}}'을(를) 휴지통으로 이동하지 못했습니다. 영구적으로 삭제하시겠습니까?", "titleTrash": "'{{name}}'을(를) 휴지통으로 이동하지 못했습니다. 영구적으로 삭제하시겠습니까?", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 900f7ee00b6b..1b809bd6bda2 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -20769,6 +20769,20 @@ "folderLoadError": "无法加载此文件夹:", "emptyFolder": "此文件夹中没有文件" }, + "host": { + "returnToProject": "返回工作区根目录", + "refresh": "刷新文件夹", + "browsing": "主机:{{host}}", + "parentDirectory": "上级文件夹", + "contentsUnavailable": "内容搜索的范围是工作区。请返回工作区根目录后使用。", + "thisFolder": "此文件夹", + "enter": "浏览主机文件系统", + "folderOpenFailed": "无法打开此文件夹:{{error}}", + "unsupportedEntry": "无法打开此项目。", + "entryMissing": "此项目已不存在。", + "entryUnverifiable": "无法访问此项目:{{error}}", + "unavailable": "此工作区不支持浏览主机" + }, "trashFailed": { "titleRecycleBin": "无法将“{{name}}”移到回收站。是否永久删除?", "titleTrash": "无法将“{{name}}”移到废纸篓。是否永久删除?", diff --git a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts index 41d7669f1c84..1cf004a49242 100644 --- a/src/renderer/src/lib/workspace-session-editor-drafts.test.ts +++ b/src/renderer/src/lib/workspace-session-editor-drafts.test.ts @@ -207,4 +207,39 @@ describe('workspace session editor drafts', () => { expect(persisted).toEqual(expect.objectContaining({ filePath: '/tmp/clean.md' })) expect(persisted).not.toHaveProperty('readOnly') }) + + it('never persists Explorer Host mode tabs so restore cannot replay their grant', () => { + const payload = buildWorkspaceSessionPayload( + createSnapshot({ + openFiles: [ + { + id: '/home/user/.bashrc', + filePath: '/home/user/.bashrc', + relativePath: '/home/user/.bashrc', + worktreeId: 'wt-1', + language: 'shell', + mode: 'edit', + isDirty: false, + readOnly: true, + hostBrowse: true + }, + { + id: '/repo/src/a.ts', + filePath: '/repo/src/a.ts', + relativePath: 'src/a.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit', + isDirty: false + } + ], + activeFileIdByWorktree: { 'wt-1': '/home/user/.bashrc' } + }) + ) + + expect(payload.openFilesByWorktree?.['wt-1']?.map((file) => file.filePath)).toEqual([ + '/repo/src/a.ts' + ]) + expect(payload.activeFileIdByWorktree?.['wt-1']).not.toBe('/home/user/.bashrc') + }) }) diff --git a/src/renderer/src/lib/workspace-session-unified-tabs.test.ts b/src/renderer/src/lib/workspace-session-unified-tabs.test.ts index 16c3818cd8a2..36ba86125a00 100644 --- a/src/renderer/src/lib/workspace-session-unified-tabs.test.ts +++ b/src/renderer/src/lib/workspace-session-unified-tabs.test.ts @@ -37,7 +37,8 @@ describe('buildPersistedUnifiedTabSessionData', () => { second: { type: 'leaf', groupId: 'kept' } } }, - activeGroupIdByWorktree: { [WT]: 'emptied' } + activeGroupIdByWorktree: { [WT]: 'emptied' }, + openFiles: [] }) expect(saved.tabGroups?.[WT]?.map((group) => group.id)).toEqual(['kept']) @@ -50,7 +51,8 @@ describe('buildPersistedUnifiedTabSessionData', () => { unifiedTabsByWorktree: { [WT]: [] }, groupsByWorktree: { [WT]: [{ id: 'root', worktreeId: WT, activeTabId: null, tabOrder: [] }] }, layoutByWorktree: { [WT]: { type: 'leaf', groupId: 'root' } }, - activeGroupIdByWorktree: { [WT]: 'root' } + activeGroupIdByWorktree: { [WT]: 'root' }, + openFiles: [] }) expect(saved.tabGroups).toEqual({}) diff --git a/src/renderer/src/lib/workspace-session-unified-tabs.ts b/src/renderer/src/lib/workspace-session-unified-tabs.ts index 2af672abf657..4ec2bee9a042 100644 --- a/src/renderer/src/lib/workspace-session-unified-tabs.ts +++ b/src/renderer/src/lib/workspace-session-unified-tabs.ts @@ -65,7 +65,11 @@ function buildPersistedGroupsForWorktree( export function buildPersistedUnifiedTabSessionData( snapshot: Pick< WorkspaceSessionSnapshot, - 'activeGroupIdByWorktree' | 'groupsByWorktree' | 'layoutByWorktree' | 'unifiedTabsByWorktree' + | 'activeGroupIdByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'unifiedTabsByWorktree' + | 'openFiles' > ): PersistedUnifiedTabSessionData { const unifiedTabs: WorkspaceSessionState['unifiedTabs'] = {} @@ -76,6 +80,13 @@ export function buildPersistedUnifiedTabSessionData( const sourceGroups = snapshot.groupsByWorktree ?? {} const sourceLayouts = snapshot.layoutByWorktree ?? {} const sourceActiveGroups = snapshot.activeGroupIdByWorktree ?? {} + // Why: Host-mode files are not persisted, and hydration keeps editor chrome with no + // backing file, so their tabs must not be written either. Incremental patches rely on + // `hostBrowse` only ever clearing alongside a tab-state change; a path that sets it on + // an existing tab must also re-trigger this rebuild. + const sessionOnlyEditorIds = new Set( + (snapshot.openFiles ?? []).filter((file) => file.hostBrowse === true).map((file) => file.id) + ) const worktreeIds = new Set([ ...Object.keys(sourceTabs), ...Object.keys(sourceGroups), @@ -83,7 +94,9 @@ export function buildPersistedUnifiedTabSessionData( ]) for (const worktreeId of worktreeIds) { - const tabs = sourceTabs[worktreeId] ?? [] + const tabs = (sourceTabs[worktreeId] ?? []).filter( + (tab) => !(tab.contentType === 'editor' && sessionOnlyEditorIds.has(tab.entityId)) + ) if (tabs.length === 0 && structuredLaunchSlotIds(worktreeId).size === 0) { continue } diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index c97d74b51af0..cf21a21e3762 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -128,7 +128,8 @@ export function buildEditorSessionData( | 'activeTabTypeByWorktree' | 'markdownFrontmatterVisible' > { - const editFiles = openFiles.filter((f) => f.mode === 'edit') + // Why: Host-mode tabs hold a session-only grant; restoring them would read without the file-only check. + const editFiles = openFiles.filter((f) => f.mode === 'edit' && f.hostBrowse !== true) const byWorktree: Record = {} const editFileIdsByWorktree: Record> = {} for (const f of editFiles) { diff --git a/src/renderer/src/store/slices/editor-read-only-tabs.test.ts b/src/renderer/src/store/slices/editor-read-only-tabs.test.ts index 17d05d656038..fc38266dcbbe 100644 --- a/src/renderer/src/store/slices/editor-read-only-tabs.test.ts +++ b/src/renderer/src/store/slices/editor-read-only-tabs.test.ts @@ -167,4 +167,116 @@ describe('read-only editor tabs (AI Vault View Log)', () => { expect.objectContaining({ externalSshTargetId: 'ssh-1' }) ) }) + + describe('Explorer Host mode tabs', () => { + const HOST_PATH = '/home/user/.bashrc' + const openHostTab = (store: StoreApi): void => { + store.getState().openFile( + { + filePath: HOST_PATH, + relativePath: HOST_PATH, + worktreeId: 'wt-1', + language: 'shell', + mode: 'edit', + readOnly: true, + hostBrowse: true, + runtimeEnvironmentId: null + }, + { preview: true, forceContentReload: true, suppressActiveRuntimeFallback: true } + ) + } + const openWritable = (store: StoreApi): void => { + store.getState().openFile({ + filePath: HOST_PATH, + relativePath: HOST_PATH, + worktreeId: 'wt-1', + language: 'shell', + mode: 'edit', + runtimeEnvironmentId: null + }) + } + + it('opens a read-only tab that refuses edits', () => { + const store = createEditorStore() + openHostTab(store) + + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ readOnly: true, hostBrowse: true }) + ) + store.getState().markFileDirty(HOST_PATH, true) + store.getState().setEditorDraft(HOST_PATH, 'stray edit') + expect(store.getState().openFiles[0]?.isDirty).toBe(false) + expect(store.getState().editorDrafts[HOST_PATH]).toBeUndefined() + }) + + it('never downgrades an existing writable tab', () => { + const store = createEditorStore() + openWritable(store) + + openHostTab(store) + + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().openFiles[0]?.readOnly).toBeUndefined() + expect(store.getState().openFiles[0]?.hostBrowse).toBeUndefined() + }) + + it('only focuses an existing View Log tab without ending its live tail', () => { + const store = createEditorStore() + store.getState().openFile( + { + filePath: HOST_PATH, + relativePath: HOST_PATH, + worktreeId: 'wt-1', + language: 'shell', + mode: 'edit', + readOnly: true, + liveTail: true, + runtimeEnvironmentId: null + }, + { preview: false, forceContentReload: true, suppressActiveRuntimeFallback: true } + ) + + openHostTab(store) + + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().openFiles[0]).toEqual( + expect.objectContaining({ readOnly: true, liveTail: true }) + ) + expect(store.getState().openFiles[0]?.hostBrowse).toBeUndefined() + }) + + it('drops the Host marker and captures owner provenance once opened writable', () => { + const store = createEditorStore() + openHostTab(store) + expect( + store.getState().openFiles[0]?.operationProvenance, + 'read-only Host tab carries no provenance' + ).toBeUndefined() + + openWritable(store) + + const tab = store.getState().openFiles[0] + expect(tab?.readOnly, 'tab is writable once reopened for edit').toBeUndefined() + expect(tab?.hostBrowse, 'Host marker is dropped once opened writable').toBeUndefined() + expect( + tab?.operationProvenance, + 'provenance captured on the read-only -> writable transition' + ).toBeDefined() + }) + + it('keeps restored writable tabs resolving their owner lazily when reopened', () => { + const store = createEditorStore() + openWritable(store) + // Why: hydrated tabs carry no provenance; simulate one. + store.setState({ + openFiles: store + .getState() + .openFiles.map((file) => ({ ...file, operationProvenance: undefined })) + }) + + openWritable(store) + + expect(store.getState().openFiles[0]?.operationProvenance).toBeUndefined() + }) + }) }) diff --git a/src/renderer/src/store/slices/editor-session-host-browse-restore.test.ts b/src/renderer/src/store/slices/editor-session-host-browse-restore.test.ts new file mode 100644 index 000000000000..ed4367b09ace --- /dev/null +++ b/src/renderer/src/store/slices/editor-session-host-browse-restore.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { buildWorkspaceSessionPayload, SESSION_RELEVANT_FIELDS } from '../../lib/workspace-session' +import { buildWorkspaceSessionPatch } from '../../lib/workspace-session-patch' +import { createStoreSessionMockApi } from './store-session-test-harness' +import { createTestStore, makeWorktree } from './store-test-helpers' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +createStoreSessionMockApi() + +const WORKTREE_ID = 'repo-1::/workspace' +const WORKSPACE_FILE = '/workspace/src/a.ts' +const HOST_FILE = '/home/allen/.bashrc' + +function prepareStore() { + const store = createTestStore() + store.setState({ + repos: [ + { id: 'repo-1', path: '/workspace', displayName: 'Repo', badgeColor: '#000', addedAt: 0 } + ], + worktreesByRepo: { + 'repo-1': [makeWorktree({ id: WORKTREE_ID, repoId: 'repo-1', path: '/workspace' })] + }, + activeWorktreeId: WORKTREE_ID + }) + return store +} + +function openWorkspaceAndHostFiles(store: ReturnType): void { + store.getState().openFile( + { + filePath: WORKSPACE_FILE, + relativePath: 'src/a.ts', + worktreeId: WORKTREE_ID, + language: 'typescript', + mode: 'edit', + runtimeEnvironmentId: null + }, + { preview: false, suppressActiveRuntimeFallback: true } + ) + store.getState().openFile( + { + filePath: HOST_FILE, + relativePath: HOST_FILE, + worktreeId: WORKTREE_ID, + language: 'shell', + mode: 'edit', + readOnly: true, + hostBrowse: true, + runtimeEnvironmentId: null + }, + { preview: false, forceContentReload: true, suppressActiveRuntimeFallback: true } + ) +} + +describe('Explorer Host mode tabs across restart', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('drops the Host tab chrome with its file and keeps group references valid', () => { + const store = prepareStore() + openWorkspaceAndHostFiles(store) + const hostTab = store + .getState() + .unifiedTabsByWorktree[WORKTREE_ID]?.find( + (tab) => tab.contentType === 'editor' && tab.entityId === HOST_FILE + ) + expect(hostTab).toBeDefined() + expect(store.getState().activeFileIdByWorktree[WORKTREE_ID]).toBe(hostTab?.entityId) + + const persisted = buildWorkspaceSessionPayload(store.getState()) + const restarted = prepareStore() + restarted.getState().hydrateTabsSession(persisted) + restarted.getState().hydrateEditorSession(persisted) + const state = restarted.getState() + + const tabs = state.unifiedTabsByWorktree[WORKTREE_ID] ?? [] + expect(tabs.map((tab) => tab.entityId)).toEqual([WORKSPACE_FILE]) + expect(state.openFiles.map((file) => file.filePath)).toEqual([WORKSPACE_FILE]) + const tabIds = new Set(tabs.map((tab) => tab.id)) + for (const group of state.groupsByWorktree[WORKTREE_ID] ?? []) { + expect(group.activeTabId === null || tabIds.has(group.activeTabId)).toBe(true) + expect(group.tabOrder.every((tabId) => tabIds.has(tabId))).toBe(true) + } + expect(state.activeFileIdByWorktree[WORKTREE_ID]).not.toBe(hostTab?.entityId) + }) + + it('persists and patches the Host tab chrome once the file is reopened writable', () => { + const store = prepareStore() + openWorkspaceAndHostFiles(store) + const before = store.getState() + + store.getState().openFile({ + filePath: HOST_FILE, + relativePath: HOST_FILE, + worktreeId: WORKTREE_ID, + language: 'shell', + mode: 'edit', + runtimeEnvironmentId: null + }) + const after = store.getState() + const persisted = buildWorkspaceSessionPayload(after) + // Why: mirror the session writer, which patches only the fields whose references changed. + const changed = SESSION_RELEVANT_FIELDS.filter((field) => before[field] !== after[field]) + const patch = buildWorkspaceSessionPatch(after, changed) + + expect( + persisted.unifiedTabs?.[WORKTREE_ID]?.map((tab) => tab.entityId), + 'full payload persists the writable tab' + ).toContain(HOST_FILE) + expect( + persisted.openFilesByWorktree?.[WORKTREE_ID]?.map((file) => file.filePath), + 'full payload persists the writable file' + ).toContain(HOST_FILE) + expect(changed, 'tab chrome reference changes so the writer patches it').toContain( + 'unifiedTabsByWorktree' + ) + expect( + patch.unifiedTabs?.[WORKTREE_ID]?.map((tab) => tab.entityId), + 'incremental patch rewrites the tab chrome' + ).toEqual([WORKSPACE_FILE, HOST_FILE]) + }) +}) diff --git a/src/renderer/src/store/slices/editor/actions/open-file-apply.ts b/src/renderer/src/store/slices/editor/actions/open-file-apply.ts index 41523d0b44d4..166bcfd1b52d 100644 --- a/src/renderer/src/store/slices/editor/actions/open-file-apply.ts +++ b/src/renderer/src/store/slices/editor/actions/open-file-apply.ts @@ -117,9 +117,20 @@ export function applyOpenFileToState( ) ? (existing.fileContentReloadNonce ?? 0) + 1 : existing.fileContentReloadNonce - // Normal open explicitly enables editing of a View Log; View Log must never downgrade an already writable tab. + // Normal open explicitly enables editing of a View Log or Explorer Host tab; read-only opens must never downgrade an already writable tab. const nextReadOnly = existing.readOnly === true && file.readOnly === true ? true : undefined - const nextLiveTail = file.liveTail === true && nextReadOnly === true ? true : undefined + // Why: a Host-mode open only focuses an existing View Log tab; it must not end its + // live tail or make it session-only. + const hostOpenOfReadOnlyTab = file.hostBrowse === true && nextReadOnly === true + const nextLiveTail = hostOpenOfReadOnlyTab + ? existing.liveTail + : file.liveTail === true && nextReadOnly === true + ? true + : undefined + const nextHostBrowse = hostOpenOfReadOnlyTab && existing.hostBrowse === true ? true : undefined + // Why: read-only opens skip provenance capture; keep the one taken when the tab turns writable. + // Restored tabs without provenance keep resolving the owner lazily, as before. + const becameWritable = existing.readOnly === true && nextReadOnly !== true const needsExistingUpdate = existing.mode !== file.mode || existing.diffSource !== file.diffSource || @@ -139,7 +150,8 @@ export function applyOpenFileToState( refreshExternalSshProvenance || existing.fileContentReloadNonce !== fileContentReloadNonce || existing.readOnly !== nextReadOnly || - existing.liveTail !== nextLiveTail + existing.liveTail !== nextLiveTail || + existing.hostBrowse !== nextHostBrowse if (!needsExistingUpdate) { return activeResult } @@ -154,9 +166,10 @@ export function applyOpenFileToState( runtimeEnvironmentId, externalSshTargetId: nextExternalSshTargetId, terminalArtifactGrantId: nextGrantId, - operationProvenance: refreshExternalSshProvenance - ? operationProvenance - : f.operationProvenance, + operationProvenance: + refreshExternalSshProvenance || (becameWritable && !f.operationProvenance) + ? operationProvenance + : f.operationProvenance, mode: file.mode, diffSource: file.diffSource, branchCompare: file.branchCompare, @@ -171,7 +184,8 @@ export function applyOpenFileToState( isPreview: updatedPreview, fileContentReloadNonce, readOnly: nextReadOnly, - liveTail: nextLiveTail + liveTail: nextLiveTail, + hostBrowse: nextHostBrowse } : f ), diff --git a/src/renderer/src/store/slices/editor/types/open-file.ts b/src/renderer/src/store/slices/editor/types/open-file.ts index 5d03ce94f39e..620cfbe5fce6 100644 --- a/src/renderer/src/store/slices/editor/types/open-file.ts +++ b/src/renderer/src/store/slices/editor/types/open-file.ts @@ -90,6 +90,8 @@ export type OpenFile = { csvPreviewOnly?: boolean /** Why: explicit live tail, only meaningful for a read-only local log. */ liveTail?: boolean + /** Why: opened read-only from Explorer Host mode outside the workspace; never persisted, so restore cannot replay its path grant. */ + hostBrowse?: boolean mode: 'edit' | 'diff' | 'conflict-review' | 'markdown-preview' | 'check-details' } diff --git a/src/renderer/src/store/workspace-layout-frame-store-view.ts b/src/renderer/src/store/workspace-layout-frame-store-view.ts index e190a75f005f..5042a71115aa 100644 --- a/src/renderer/src/store/workspace-layout-frame-store-view.ts +++ b/src/renderer/src/store/workspace-layout-frame-store-view.ts @@ -91,7 +91,8 @@ export function layoutViewAndFactsOfStore( unifiedTabsByWorktree, groupsByWorktree, layoutByWorktree, - activeGroupIdByWorktree + activeGroupIdByWorktree, + openFiles: state.openFiles }) // The builders read only these workspaces' entries of the selection maps. const view: DesktopLayoutView = { diff --git a/src/shared/filesystem-entry-types.ts b/src/shared/filesystem-entry-types.ts index c1a470f04ab8..1199cbe23ca0 100644 --- a/src/shared/filesystem-entry-types.ts +++ b/src/shared/filesystem-entry-types.ts @@ -6,6 +6,20 @@ export type DirEntry = { isSymlink: boolean } +export type HostDirectoryListing = { + resolvedPath: string + entries: DirEntry[] + pathFlavor: FilesystemPathFlavor +} + +/** Host-mode Explorer clicks: classification only; external files open read-only as user-named files. */ +export type HostBrowseEntryResolution = { + kind: 'directory' | 'file' | 'unsupported' + realPath: string + /** Canonical path relative to the canonical workspace root, or null when outside it. */ + workspaceRelativePath: string | null +} + export type FileDocument = { filePath: string relativePath: string