Improve Floating Workspace notes (#2450)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-20 20:20:16 -04:00 committed by GitHub
parent cf3116bc4a
commit c86cf8a308
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 3343 additions and 594 deletions

View File

@ -1,47 +1,71 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { app, ipcMain } from 'electron'
import { app, BrowserWindow, dialog, ipcMain, type IpcMainInvokeEvent } from 'electron'
import { is } from '@electron-toolkit/utils'
import type { AppIdentity } from '../../shared/app-identity'
import type { FloatingTerminalCwdRequest } from '../../shared/types'
import type { FloatingTerminalCwdRequest, MarkdownDocument } from '../../shared/types'
import type { Store } from '../persistence'
import { getDevInstanceIdentity } from '../startup/dev-instance-identity'
import { isPwshAvailable } from '../pwsh'
import { isWslAvailable } from '../wsl'
import { setUnreadDockBadgeCount } from '../dock/unread-badge'
import { authorizeExternalPath } from './filesystem-auth'
import {
grantFloatingWorkspaceDirectory,
resolveFloatingTerminalCwd
} from './floating-workspace-directory'
import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown-documents'
const execFileAsync = promisify(execFile)
function expandHomePath(input: string, home: string): string {
if (input === '~') {
return home
async function pickFloatingMarkdownDocument(
event: IpcMainInvokeEvent,
store: Store,
args?: FloatingTerminalCwdRequest
): Promise<MarkdownDocument | null> {
const cwd = await resolveFloatingTerminalCwd(store, args)
const options = {
defaultPath: cwd,
properties: ['openFile'],
filters: [{ name: 'Markdown', extensions: ['md', 'mdx', 'markdown'] }]
} satisfies Electron.OpenDialogOptions
const parentWindow = BrowserWindow.fromWebContents(event.sender)
const result = parentWindow
? await dialog.showOpenDialog(parentWindow, options)
: await dialog.showOpenDialog(options)
if (result.canceled || result.filePaths.length === 0) {
return null
}
if (input.startsWith(`~${path.sep}`)) {
return path.join(home, input.slice(2))
const filePath = result.filePaths[0]
if (!isMarkdownDocumentName(filePath)) {
throw new Error('Selected file is not a markdown document.')
}
if (process.platform === 'win32' && input.startsWith('~/')) {
return path.join(home, input.slice(2))
}
return input
authorizeExternalPath(filePath)
return markdownDocumentFromFilePath(cwd, filePath, { outsideRootRelativePath: 'basename' })
}
async function resolveFloatingTerminalCwd(args?: FloatingTerminalCwdRequest): Promise<string> {
const home = app.getPath('home')
const configuredPath = args?.path?.trim()
if (!configuredPath) {
return home
}
const expanded = expandHomePath(configuredPath, home)
const cwd = path.isAbsolute(expanded) ? expanded : path.resolve(home, expanded)
try {
await mkdir(cwd, { recursive: true })
return cwd
} catch {
return home
async function pickFloatingWorkspaceDirectory(
event: IpcMainInvokeEvent,
store: Store
): Promise<string | null> {
const parentWindow = BrowserWindow.fromWebContents(event.sender)
const options = {
properties: ['openDirectory', 'createDirectory']
} satisfies Electron.OpenDialogOptions
const result = parentWindow
? await dialog.showOpenDialog(parentWindow, options)
: await dialog.showOpenDialog(options)
if (result.canceled || result.filePaths.length === 0) {
return null
}
const selectedDir = result.filePaths[0]
// Why: a user-approved picker selection is a trust grant for later Floating
// Workspace markdown creation, unlike arbitrary typed settings text.
await grantFloatingWorkspaceDirectory(store, selectedDir)
return selectedDir
}
function getFeatureWallAssetBaseUrl(): string {
@ -73,7 +97,7 @@ function resolveDevFeatureWallAssetDir(): string {
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]
}
export function registerAppHandlers(): void {
export function registerAppHandlers(store: Store): void {
ipcMain.handle('app:getFeatureWallAssetBaseUrl', (): string => getFeatureWallAssetBaseUrl())
ipcMain.handle('app:getIdentity', (): AppIdentity => {
@ -154,6 +178,14 @@ export function registerAppHandlers(): void {
})
ipcMain.handle('app:getFloatingTerminalCwd', (_event, args?: FloatingTerminalCwdRequest) =>
resolveFloatingTerminalCwd(args)
resolveFloatingTerminalCwd(store, args)
)
ipcMain.handle('app:pickFloatingMarkdownDocument', (event, args?: FloatingTerminalCwdRequest) =>
pickFloatingMarkdownDocument(event, store, args)
)
ipcMain.handle('app:pickFloatingWorkspaceDirectory', (event) =>
pickFloatingWorkspaceDirectory(event, store)
)
}

View File

@ -0,0 +1,176 @@
import { mkdtemp, mkdir, realpath, rm, symlink, unlink } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/types'
const { appGetPathMock, authorizeExternalPathMock } = vi.hoisted(() => ({
appGetPathMock: vi.fn(),
authorizeExternalPathMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: appGetPathMock
}
}))
vi.mock('./filesystem-auth', () => ({
authorizeExternalPath: authorizeExternalPathMock
}))
import {
grantFloatingWorkspaceDirectory,
resolveFloatingTerminalCwd,
sanitizeFloatingWorkspaceDirectorySetting
} from './floating-workspace-directory'
type TestStore = {
settings: GlobalSettings
getSettings: () => GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => GlobalSettings
}
function createStore(settings: Partial<GlobalSettings> = {}): TestStore {
const store: TestStore = {
settings: {
floatingTerminalCwd: '',
floatingTerminalTrustedCwds: [],
...settings
} as GlobalSettings,
getSettings: () => store.settings,
updateSettings: (updates) => {
store.settings = { ...store.settings, ...updates }
return store.settings
}
}
return store
}
describe('floating workspace directory authorization', () => {
let tempRoot: string
let homeDir: string
let userDataDir: string
beforeEach(async () => {
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'orca-floating-workspace-'))
homeDir = path.join(tempRoot, 'home')
userDataDir = path.join(tempRoot, 'user-data')
await mkdir(homeDir)
appGetPathMock.mockImplementation((name: string) => {
if (name === 'home') {
return homeDir
}
if (name === 'userData') {
return userDataDir
}
throw new Error(`unexpected app path: ${name}`)
})
authorizeExternalPathMock.mockClear()
})
afterEach(async () => {
await rm(tempRoot, { recursive: true, force: true })
})
async function symlinkDirectory(target: string, linkPath: string): Promise<void> {
await symlink(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
}
it('persists picker-approved directories and reauthorizes them on resolution', async () => {
const store = createStore()
const selectedDir = path.join(tempRoot, 'notes')
await mkdir(selectedDir)
const canonicalSelectedDir = await realpath(selectedDir)
await grantFloatingWorkspaceDirectory(store as never, selectedDir)
expect(store.settings.floatingTerminalTrustedCwds).toEqual([canonicalSelectedDir])
expect(authorizeExternalPathMock).toHaveBeenCalledWith(canonicalSelectedDir)
authorizeExternalPathMock.mockClear()
await expect(
resolveFloatingTerminalCwd(store as never, {
path: selectedDir,
requireTrusted: true
})
).resolves.toBe(canonicalSelectedDir)
expect(authorizeExternalPathMock).toHaveBeenCalledWith(canonicalSelectedDir)
})
it('stores symlink grants as canonical targets and rejects the link after retargeting', async () => {
const store = createStore()
const originalTarget = path.join(tempRoot, 'original-target')
const retargetedTarget = path.join(tempRoot, 'retargeted-target')
const selectedLink = path.join(tempRoot, 'selected-link')
await mkdir(originalTarget)
await mkdir(retargetedTarget)
await symlinkDirectory(originalTarget, selectedLink)
const canonicalOriginalTarget = await realpath(originalTarget)
await grantFloatingWorkspaceDirectory(store as never, selectedLink)
expect(store.settings.floatingTerminalTrustedCwds).toEqual([canonicalOriginalTarget])
expect(authorizeExternalPathMock).toHaveBeenCalledWith(canonicalOriginalTarget)
await unlink(selectedLink)
await symlinkDirectory(retargetedTarget, selectedLink)
const canonicalRetargetedTarget = await realpath(retargetedTarget)
authorizeExternalPathMock.mockClear()
await expect(
resolveFloatingTerminalCwd(store as never, {
path: selectedLink,
requireTrusted: true
})
).resolves.toBe(path.join(userDataDir, 'floating-workspace'))
await expect(
sanitizeFloatingWorkspaceDirectorySetting(store as never, selectedLink)
).resolves.toBe('')
expect(authorizeExternalPathMock).not.toHaveBeenCalledWith(canonicalRetargetedTarget)
})
it('keeps temporarily inaccessible trusted directories when adding a new grant', async () => {
const missingTrustedDir = path.join(tempRoot, 'offline-drive', 'notes')
const selectedDir = path.join(tempRoot, 'new-notes')
await mkdir(selectedDir)
const canonicalSelectedDir = await realpath(selectedDir)
const store = createStore({
floatingTerminalTrustedCwds: [missingTrustedDir]
})
await grantFloatingWorkspaceDirectory(store as never, selectedDir)
expect(store.settings.floatingTerminalTrustedCwds).toEqual([
missingTrustedDir,
canonicalSelectedDir
])
})
it('falls back to the app-owned workspace for untrusted settings paths', async () => {
const store = createStore()
const arbitraryDir = path.join(tempRoot, 'arbitrary')
await mkdir(arbitraryDir)
await expect(
resolveFloatingTerminalCwd(store as never, {
path: arbitraryDir,
requireTrusted: true
})
).resolves.toBe(path.join(userDataDir, 'floating-workspace'))
await expect(
sanitizeFloatingWorkspaceDirectorySetting(store as never, arbitraryDir)
).resolves.toBe('')
})
it('still resolves accessible ad hoc terminal directories when trust is not required', async () => {
const store = createStore()
const arbitraryDir = path.join(tempRoot, 'terminal-only')
await mkdir(arbitraryDir)
await expect(resolveFloatingTerminalCwd(store as never, { path: arbitraryDir })).resolves.toBe(
arbitraryDir
)
expect(authorizeExternalPathMock).not.toHaveBeenCalledWith(arbitraryDir)
})
})

View File

@ -0,0 +1,137 @@
import { constants as fsConstants } from 'node:fs'
import { access, mkdir, realpath, stat } from 'node:fs/promises'
import path from 'node:path'
import { app } from 'electron'
import type { GlobalSettings, FloatingTerminalCwdRequest } from '../../shared/types'
import type { Store } from '../persistence'
import { authorizeExternalPath } from './filesystem-auth'
const FLOATING_WORKSPACE_DIRNAME = 'floating-workspace'
function expandHomePath(input: string, home: string): string {
if (input === '~') {
return home
}
if (input.startsWith(`~${path.sep}`)) {
return path.join(home, input.slice(2))
}
if (process.platform === 'win32' && input.startsWith('~/')) {
return path.join(home, input.slice(2))
}
return input
}
function resolveFloatingWorkspaceInput(input: string): string {
const home = app.getPath('home')
const expanded = expandHomePath(input, home)
return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(home, expanded)
}
async function canonicalizeAccessibleDirectory(dirPath: string): Promise<string | null> {
try {
const dirStats = await stat(dirPath)
if (!dirStats.isDirectory()) {
return null
}
await access(dirPath, fsConstants.R_OK | fsConstants.X_OK)
return path.resolve(await realpath(dirPath))
} catch {
return null
}
}
function getTrustedFloatingWorkspaceDirectories(settings: GlobalSettings): Set<string> {
return new Set(
(settings.floatingTerminalTrustedCwds ?? [])
.map((trustedPath) => trustedPath.trim())
.filter((trustedPath) => trustedPath.length > 0)
.map(resolveFloatingWorkspaceInput)
)
}
async function getPreservedTrustedFloatingWorkspaceDirectories(
settings: GlobalSettings
): Promise<Set<string>> {
const trustedDirectories = new Set<string>()
for (const trustedDir of getTrustedFloatingWorkspaceDirectories(settings)) {
const canonicalDir = await canonicalizeAccessibleDirectory(trustedDir)
trustedDirectories.add(canonicalDir ?? trustedDir)
}
return trustedDirectories
}
function isTrustedFloatingWorkspaceDirectory(
canonicalDirPath: string,
settings: GlobalSettings
): boolean {
return getTrustedFloatingWorkspaceDirectories(settings).has(path.resolve(canonicalDirPath))
}
export async function ensureDefaultFloatingWorkspacePath(): Promise<string> {
const cwd = path.join(app.getPath('userData'), FLOATING_WORKSPACE_DIRNAME)
await mkdir(cwd, { recursive: true })
// Why: the default floating workspace lives outside repo roots by design;
// authorize only this app-owned directory instead of widening access to ~.
authorizeExternalPath(cwd)
return cwd
}
export async function resolveFloatingTerminalCwd(
store: Store,
args?: FloatingTerminalCwdRequest
): Promise<string> {
const configuredPath = typeof args?.path === 'string' ? args.path.trim() : ''
if (!configuredPath) {
return ensureDefaultFloatingWorkspacePath()
}
const cwd = resolveFloatingWorkspaceInput(configuredPath)
const canonicalCwd = await canonicalizeAccessibleDirectory(cwd)
if (!canonicalCwd) {
return ensureDefaultFloatingWorkspacePath()
}
if (isTrustedFloatingWorkspaceDirectory(canonicalCwd, store.getSettings())) {
// Why: picker-approved directories are persisted as explicit grants, so a
// restart can restore file creation access without trusting arbitrary text.
authorizeExternalPath(canonicalCwd)
return canonicalCwd
}
return args?.requireTrusted === true ? ensureDefaultFloatingWorkspacePath() : cwd
}
export async function grantFloatingWorkspaceDirectory(
store: Store,
dirPath: string
): Promise<void> {
const resolvedDir = resolveFloatingWorkspaceInput(dirPath)
const canonicalDir = await canonicalizeAccessibleDirectory(resolvedDir)
if (!canonicalDir) {
return
}
authorizeExternalPath(canonicalDir)
const trustedDirectories = await getPreservedTrustedFloatingWorkspaceDirectories(
store.getSettings()
)
trustedDirectories.add(canonicalDir)
store.updateSettings({
floatingTerminalTrustedCwds: [...trustedDirectories]
})
}
export async function sanitizeFloatingWorkspaceDirectorySetting(
store: Store,
dirPath: string
): Promise<string> {
const trimmed = dirPath.trim()
if (!trimmed) {
return ''
}
const resolvedDir = resolveFloatingWorkspaceInput(trimmed)
const canonicalDir = await canonicalizeAccessibleDirectory(resolvedDir)
if (!canonicalDir || !isTrustedFloatingWorkspaceDirectory(canonicalDir, store.getSettings())) {
return ''
}
return canonicalDir
}

View File

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { markdownDocumentFromFilePath } from './markdown-documents'
describe('markdownDocumentFromFilePath', () => {
it('keeps in-root path segments that merely start with parent traversal text', () => {
expect(markdownDocumentFromFilePath('/workspace', '/workspace/..notes/file.md')).toMatchObject({
filePath: '/workspace/..notes/file.md',
relativePath: '..notes/file.md',
basename: 'file.md',
name: 'file'
})
})
it('treats actual parent traversal as outside the root', () => {
expect(
markdownDocumentFromFilePath('/workspace', '/workspace-other/file.md', {
outsideRootRelativePath: 'basename'
})
).toMatchObject({
filePath: '/workspace-other/file.md',
relativePath: 'file.md',
basename: 'file.md',
name: 'file'
})
})
})

View File

@ -1,12 +1,12 @@
import { readdir } from 'fs/promises'
import { basename as pathBasename, extname, join, relative } from 'path'
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'path'
import type { MarkdownDocument } from '../../shared/types'
function normalizeRelativePath(path: string): string {
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
}
function isMarkdownDocumentName(name: string): boolean {
export function isMarkdownDocumentName(name: string): boolean {
const extension = extname(name).toLowerCase()
return extension === '.md' || extension === '.mdx' || extension === '.markdown'
}
@ -20,12 +20,35 @@ function isSafeRelativePath(relativePath: string): boolean {
return !relativePath.split('/').includes('..')
}
function toMarkdownDocument(rootPath: string, filePath: string): MarkdownDocument {
function hasParentTraversalSegment(relativePath: string): boolean {
return relativePath.split(/[\\/]+/).includes('..')
}
function rootRelativePath(rootPath: string, filePath: string): string | null {
const resolvedRoot = resolve(rootPath)
const resolvedFile = resolve(filePath)
const relativePath = relative(resolvedRoot, resolvedFile)
if (hasParentTraversalSegment(relativePath) || isAbsolute(relativePath)) {
return null
}
return normalizeRelativePath(relativePath)
}
export function markdownDocumentFromFilePath(
rootPath: string,
filePath: string,
options: { outsideRootRelativePath?: 'basename' | 'relative' } = {}
): MarkdownDocument {
const basename = pathBasename(filePath)
const extension = extname(basename)
const relativePath =
rootRelativePath(rootPath, filePath) ??
(options.outsideRootRelativePath === 'basename'
? basename
: normalizeRelativePath(relative(rootPath, filePath)))
return {
filePath,
relativePath: normalizeRelativePath(relative(rootPath, filePath)),
relativePath,
basename,
name: extension ? basename.slice(0, -extension.length) : basename
}
@ -88,7 +111,7 @@ export async function listMarkdownDocuments(rootPath: string): Promise<MarkdownD
}
if (entry.isFile() && isMarkdownDocumentName(entry.name)) {
documents.push(toMarkdownDocument(rootPath, entryPath))
documents.push(markdownDocumentFromFilePath(rootPath, entryPath))
}
}
}

View File

@ -337,6 +337,7 @@ describe('registerCoreHandlers', () => {
expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage)
expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage)
expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage)
expect(registerAppHandlersMock).toHaveBeenCalledWith(store)
expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts)
expect(registerAgentHookHandlersMock).toHaveBeenCalled()
expect(registerPetHandlersMock).toHaveBeenCalled()

View File

@ -87,7 +87,7 @@ export function registerCoreHandlers(
}
registered = true
registerAppHandlers()
registerAppHandlers(store)
registerCliHandlers()
registerPreflightHandlers()
registerClaudeUsageHandlers(claudeUsage)

View File

@ -82,4 +82,19 @@ describe('registerSettingsHandlers', () => {
expect(agentAwakeService.setEnabled).not.toHaveBeenCalled()
})
it('does not accept floating workspace trust grants from renderer settings IPC', async () => {
store.getSettings.mockReturnValue({ floatingTerminalTrustedCwds: [] })
store.updateSettings.mockReturnValue({ floatingTerminalTrustedCwds: [] })
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
_event: unknown,
args: unknown
) => Promise<unknown>
await handler(null, { floatingTerminalTrustedCwds: ['/tmp/notes'] })
expect(store.updateSettings).toHaveBeenCalledWith({})
})
})

View File

@ -7,6 +7,7 @@ import { rebuildAppMenu } from '../menu/register-app-menu'
import { track } from '../telemetry/client'
import { SETTINGS_CHANGED_WHITELIST, type SettingsChangedKey } from '../../shared/telemetry-events'
import type { AgentAwakeService } from '../agent-awake-service'
import { sanitizeFloatingWorkspaceDirectorySetting } from './floating-workspace-directory'
// Why: the whitelist is the source-of-truth for which keys we emit on. Casting
// to a Set once at module load lets the IPC handler's per-key membership
@ -30,7 +31,17 @@ export function registerSettingsHandlers(
return store.getSettings()
})
ipcMain.handle('settings:set', (_event, args: Partial<GlobalSettings>) => {
ipcMain.handle('settings:set', async (_event, args: Partial<GlobalSettings>) => {
const sanitizedArgs = { ...args }
// Why: Floating Workspace grants are trusted only when written by the
// main-process directory picker, never by renderer-provided settings IPC.
delete sanitizedArgs.floatingTerminalTrustedCwds
if (typeof args.floatingTerminalCwd === 'string') {
sanitizedArgs.floatingTerminalCwd = await sanitizeFloatingWorkspaceDirectorySetting(
store,
args.floatingTerminalCwd
)
}
if (args.theme) {
nativeTheme.themeSource = args.theme
}
@ -39,11 +50,11 @@ export function registerSettingsHandlers(
// (e.g. blur after a no-op edit), and a `settings_changed` event for a
// no-op flip would inflate the experimental-feature-adoption signal.
const before = store.getSettings()
const result = store.updateSettings(args)
if ('keepComputerAwakeWhileAgentsRun' in args) {
const result = store.updateSettings(sanitizedArgs)
if ('keepComputerAwakeWhileAgentsRun' in sanitizedArgs) {
agentAwakeService?.setEnabled(result.keepComputerAwakeWhileAgentsRun)
}
if (APPEARANCE_MENU_KEYS.some((key) => key in args)) {
if (APPEARANCE_MENU_KEYS.some((key) => key in sanitizedArgs)) {
rebuildAppMenu()
}
@ -55,7 +66,7 @@ export function registerSettingsHandlers(
// the path the v1 enum has a slot for. If a non-bool whitelisted
// setting is ever added, extend the discriminator here at the same
// time the schema's `value_kind` enum gains the new value.
for (const key of Object.keys(args)) {
for (const key of Object.keys(sanitizedArgs)) {
if (!SETTINGS_CHANGED_WHITELIST_SET.has(key)) {
continue
}

View File

@ -2,7 +2,16 @@
migration, mutation, and flush behavior in one file so schema changes are
reviewed against the full storage contract instead of being scattered. */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync, existsSync } from 'fs'
import {
writeFileSync,
readFileSync,
rmSync,
mkdtempSync,
mkdirSync,
existsSync,
realpathSync,
symlinkSync
} from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import type { Repo, TerminalTab, WorktreeLineage, WorkspaceSessionState } from '../shared/types'
@ -86,6 +95,10 @@ function readDataFile(): unknown {
return JSON.parse(readFileSync(dataFile(), 'utf-8'))
}
function symlinkDirectorySync(target: string, linkPath: string): void {
symlinkSync(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
}
function collectPropertyPaths(value: unknown, property: string, prefix = ''): string[] {
if (!value || typeof value !== 'object') {
return []
@ -740,6 +753,155 @@ describe('Store', () => {
expect(store.getSettings().floatingTerminalDefaultedForAllUsers).toBe(true)
})
it('seeds trusted floating workspace directories from legacy explicit cwd values', async () => {
const legacyFloatingCwd = join(testState.dir, 'legacy-floating-cwd')
mkdirSync(legacyFloatingCwd)
const canonicalLegacyFloatingCwd = realpathSync(legacyFloatingCwd)
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {
floatingTerminalCwd: legacyFloatingCwd
},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().floatingTerminalCwd).toBe(legacyFloatingCwd)
expect(store.getSettings().floatingTerminalTrustedCwds).toEqual([canonicalLegacyFloatingCwd])
store.flush()
expect(
(readDataFile() as { settings?: { floatingTerminalTrustedCwds?: string[] } }).settings
?.floatingTerminalTrustedCwds
).toEqual([canonicalLegacyFloatingCwd])
})
it('persists the floating cwd migration marker when a legacy explicit cwd is unavailable', async () => {
const unavailableLegacyFloatingCwd = join(testState.dir, 'missing-floating-cwd')
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {
floatingTerminalCwd: unavailableLegacyFloatingCwd
},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().floatingTerminalCwd).toBe(unavailableLegacyFloatingCwd)
expect(store.getSettings().floatingTerminalTrustedCwds).toEqual([])
store.flush()
expect(
(readDataFile() as { settings?: { floatingTerminalCwdMigratedToAppWorkspace?: boolean } })
.settings?.floatingTerminalCwdMigratedToAppWorkspace
).toBe(true)
})
it('does not seed trusted floating workspace directories after the cwd migration has run', async () => {
const postMigrationCwd = join(testState.dir, 'post-migration-cwd')
mkdirSync(postMigrationCwd)
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {
floatingTerminalCwd: postMigrationCwd,
floatingTerminalCwdMigratedToAppWorkspace: true
},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().floatingTerminalCwd).toBe(postMigrationCwd)
expect(store.getSettings().floatingTerminalTrustedCwds).toEqual([])
})
it('canonicalizes persisted floating workspace trust paths on load', async () => {
const trustedTarget = join(testState.dir, 'trusted-target')
const trustedLink = join(testState.dir, 'trusted-link')
mkdirSync(trustedTarget)
symlinkDirectorySync(trustedTarget, trustedLink)
const canonicalTrustedTarget = realpathSync(trustedTarget)
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {
floatingTerminalTrustedCwds: [trustedLink]
},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().floatingTerminalTrustedCwds).toEqual([canonicalTrustedTarget])
store.flush()
expect(
(readDataFile() as { settings?: { floatingTerminalTrustedCwds?: string[] } }).settings
?.floatingTerminalTrustedCwds
).toEqual([canonicalTrustedTarget])
})
it('preserves temporarily unavailable floating workspace trust paths on load', async () => {
const unavailableTrustedPath = join(testState.dir, 'offline-drive', 'notes')
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {
floatingTerminalTrustedCwds: [unavailableTrustedPath]
},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().floatingTerminalTrustedCwds).toEqual([unavailableTrustedPath])
store.flush()
expect(
(readDataFile() as { settings?: { floatingTerminalTrustedCwds?: string[] } }).settings
?.floatingTerminalTrustedCwds
).toEqual([unavailableTrustedPath])
})
it('drops blank floating workspace trust paths on load', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {
floatingTerminalTrustedCwds: ['', ' ']
},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().floatingTerminalTrustedCwds).toEqual([])
store.flush()
expect(
(readDataFile() as { settings?: { floatingTerminalTrustedCwds?: string[] } }).settings
?.floatingTerminalTrustedCwds
).toEqual([])
})
it('preserves custom notification sound paths from persisted settings', async () => {
writeDataFile({
schemaVersion: 1,

View File

@ -10,10 +10,11 @@ import {
renameSync,
unlinkSync,
copyFileSync,
statSync
statSync,
realpathSync
} from 'fs'
import { writeFile, rename, mkdir, rm, copyFile } from 'fs/promises'
import { join, dirname } from 'path'
import { join, dirname, isAbsolute, resolve, sep } from 'path'
import { homedir } from 'os'
import { randomUUID } from 'node:crypto'
import type {
@ -321,6 +322,76 @@ function readLegacySidekickFlag(parsed: PersistedState | undefined): boolean | u
return (parsed?.settings as { experimentalSidekick?: boolean } | undefined)?.experimentalSidekick
}
function expandFloatingWorkspaceHomePath(input: string, home: string): string {
if (input === '~') {
return home
}
if (input.startsWith(`~${sep}`) || (process.platform === 'win32' && input.startsWith('~/'))) {
return join(home, input.slice(2))
}
return input
}
function resolveFloatingWorkspacePath(input: string, home: string): string {
const expanded = expandFloatingWorkspaceHomePath(input, home)
return isAbsolute(expanded) ? resolve(expanded) : resolve(home, expanded)
}
function canonicalizePersistedFloatingWorkspaceDirectory(
input: string,
home: string
): string | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
try {
const canonicalPath = resolve(realpathSync(resolveFloatingWorkspacePath(trimmed, home)))
return statSync(canonicalPath).isDirectory() ? canonicalPath : null
} catch {
return null
}
}
function normalizeFloatingWorkspaceTrustedCwds(
input: unknown,
home: string
): { trustedCwds: string[]; changed: boolean } {
const rawTrustedCwds = Array.isArray(input) ? input : []
const trustedCwds: string[] = []
const seen = new Set<string>()
let changed = input !== undefined && !Array.isArray(input)
for (const rawTrustedCwd of rawTrustedCwds) {
if (typeof rawTrustedCwd !== 'string') {
changed = true
continue
}
const trimmedTrustedCwd = rawTrustedCwd.trim()
if (!trimmedTrustedCwd) {
changed = true
continue
}
const canonicalPath = canonicalizePersistedFloatingWorkspaceDirectory(trimmedTrustedCwd, home)
const normalizedPath = canonicalPath ?? resolveFloatingWorkspacePath(trimmedTrustedCwd, home)
if (!normalizedPath) {
changed = true
continue
}
if (seen.has(normalizedPath)) {
changed = true
continue
}
seen.add(normalizedPath)
trustedCwds.push(normalizedPath)
if (rawTrustedCwd !== normalizedPath) {
changed = true
}
}
return { trustedCwds, changed }
}
function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null {
if (!value || typeof value !== 'object') {
return null
@ -1206,7 +1277,8 @@ export class Store {
}
// Merge with defaults in case new fields were added
const defaults = getDefaultPersistedState(homedir())
const homeDir = homedir()
const defaults = getDefaultPersistedState(homeDir)
// Why: before the layout-aware 'auto' mode shipped (issue #903),
// terminalMacOptionAsAlt defaulted to 'true' globally. That silently
// broke Option-layer characters (@ on Turkish via Option+Q, @ on
@ -1235,6 +1307,51 @@ export class Store {
const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers
? (parsed.settings?.floatingTerminalEnabled ?? true)
: true
const floatingTerminalCwdMigrated =
parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true
// Why: the old inherited floating cwd was '~', which works for shells
// but not for app-managed markdown files. Migrate only once so users
// can still explicitly choose '~' after this release.
const migratedFloatingTerminalCwd = floatingTerminalCwdMigrated
? (parsed.settings?.floatingTerminalCwd ?? defaults.settings.floatingTerminalCwd)
: parsed.settings?.floatingTerminalCwd === undefined ||
parsed.settings.floatingTerminalCwd === '~'
? defaults.settings.floatingTerminalCwd
: parsed.settings.floatingTerminalCwd
const normalizedFloatingTerminalTrustedCwds = normalizeFloatingWorkspaceTrustedCwds(
parsed.settings?.floatingTerminalTrustedCwds,
homeDir
)
const migratedFloatingTerminalTrustedCwds = [
...normalizedFloatingTerminalTrustedCwds.trustedCwds
]
const rawLegacyFloatingTerminalCwd = parsed.settings?.floatingTerminalCwd
const shouldTrustLegacyFloatingTerminalCwd =
!floatingTerminalCwdMigrated &&
typeof rawLegacyFloatingTerminalCwd === 'string' &&
rawLegacyFloatingTerminalCwd.trim().length > 0 &&
rawLegacyFloatingTerminalCwd.trim() !== '~'
if (!floatingTerminalCwdMigrated) {
this.loadNeedsSave = true
}
if (shouldTrustLegacyFloatingTerminalCwd && rawLegacyFloatingTerminalCwd) {
const canonicalLegacyCwd = canonicalizePersistedFloatingWorkspaceDirectory(
rawLegacyFloatingTerminalCwd,
homeDir
)
if (
canonicalLegacyCwd &&
!migratedFloatingTerminalTrustedCwds.includes(canonicalLegacyCwd)
) {
// Why: pre-grant profiles with an explicit Floating Workspace cwd
// already represented user intent; migrate only that legacy value.
migratedFloatingTerminalTrustedCwds.push(canonicalLegacyCwd)
normalizedFloatingTerminalTrustedCwds.changed = true
}
}
if (normalizedFloatingTerminalTrustedCwds.changed) {
this.loadNeedsSave = true
}
const experimentalActivityDefaultedOffForAllUsers =
parsed.settings?.experimentalActivityDefaultedOffForAllUsers === true
// Why: the Agents view moved back behind Experimental. Flip every
@ -1259,6 +1376,9 @@ export class Store {
terminalMacOptionAsAltMigrated: true,
floatingTerminalEnabled: migratedFloatingTerminalEnabled,
floatingTerminalDefaultedForAllUsers: true,
floatingTerminalCwd: migratedFloatingTerminalCwd,
floatingTerminalTrustedCwds: migratedFloatingTerminalTrustedCwds,
floatingTerminalCwdMigratedToAppWorkspace: true,
terminalQuickCommands: normalizeTerminalQuickCommands(
parsed.settings?.terminalQuickCommands
),

View File

@ -558,6 +558,14 @@ export type AppApi = {
setUnreadDockBadgeCount: (count: number) => Promise<void>
/** Resolves the launch directory for global Floating Terminal tabs. */
getFloatingTerminalCwd: (args?: FloatingTerminalCwdRequest) => Promise<string>
/** Opens a native picker for markdown documents, rooted in the floating
* workspace, and authorizes the selected file for editor reads/writes. */
pickFloatingMarkdownDocument: (
args?: FloatingTerminalCwdRequest
) => Promise<MarkdownDocument | null>
/** Opens a native directory picker and authorizes the selected directory
* for Floating Workspace markdown file creation. */
pickFloatingWorkspaceDirectory: () => Promise<string | null>
}
export type PreloadApi = {

View File

@ -33,6 +33,7 @@ import type {
NotificationSoundResult,
OnboardingState,
FloatingTerminalCwdRequest,
MarkdownDocument,
SearchResult,
WorktreeBaseStatusEvent,
WorktreeRemoteBranchConflictEvent
@ -364,7 +365,13 @@ const api = {
setUnreadDockBadgeCount: (count: number): Promise<void> =>
ipcRenderer.invoke('app:setUnreadDockBadgeCount', count),
getFloatingTerminalCwd: (args?: FloatingTerminalCwdRequest): Promise<string> =>
ipcRenderer.invoke('app:getFloatingTerminalCwd', args)
ipcRenderer.invoke('app:getFloatingTerminalCwd', args),
pickFloatingMarkdownDocument: (
args?: FloatingTerminalCwdRequest
): Promise<MarkdownDocument | null> =>
ipcRenderer.invoke('app:pickFloatingMarkdownDocument', args),
pickFloatingWorkspaceDirectory: (): Promise<string | null> =>
ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory')
},
wsl: {

View File

@ -290,7 +290,7 @@ function App(): React.JSX.Element {
const showFloatingTerminalButton =
floatingTerminalEnabled &&
(floatingTerminalTriggerLocation === 'floating-button' || !statusBarVisible)
// Why: the floating terminal is a transient overlay; hotkey minimize should
// Why: the floating workspace is a transient overlay; hotkey minimize should
// return keyboard focus to the surface the user was working in before it.
const floatingTerminalReturnFocusRef = useRef<HTMLElement | null>(null)
@ -1522,7 +1522,7 @@ function App(): React.JSX.Element {
<FloatingTerminalToggleButton
// Why: anchor the floating trigger to the center surface so it
// cannot cover the worktree sidebar or right sidebar.
className="absolute bottom-8 right-3"
className="absolute bottom-3 right-3"
open={floatingTerminalOpen}
onToggle={() => setFloatingTerminalOpenWithFocus((open) => !open)}
/>

View File

@ -17,7 +17,7 @@ import {
ConflictReviewPanel,
getNextConflictNavigationIndex
} from './ConflictComponents'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import type { MarkdownViewMode, OpenFile, PendingEditorReveal } from '@/store/slices/editor'
import type { GitStatusEntry, GitDiffResult } from '../../../../shared/types'
import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants'
import { getMarkdownRenderMode } from './markdown-render-mode'
@ -55,6 +55,16 @@ type FileContent = {
loadError?: string
}
function matchesPendingEditorReveal(
reveal: PendingEditorReveal | null,
file: Pick<OpenFile, 'id' | 'filePath'>
): reveal is PendingEditorReveal {
if (!reveal) {
return false
}
return reveal.fileId ? reveal.fileId === file.id : reveal.filePath === file.filePath
}
function FileLoadErrorView({
message,
onRetry
@ -124,12 +134,7 @@ export function EditorContent({
showMarkdownTableOfContents?: boolean
markdownReviewToolsEnabled?: boolean
onCloseMarkdownTableOfContents?: () => void
pendingEditorReveal: {
filePath?: string
line?: number
column?: number
matchLength?: number
} | null
pendingEditorReveal: PendingEditorReveal | null
handleContentChange: (content: string) => void
handleContentChangeForFile: (file: OpenFile, content: string) => void
handleDirtyStateHint: (dirty: boolean) => void
@ -275,6 +280,7 @@ export function EditorContent({
// tab keeps its own viewport state even when the underlying file is shared.
<MonacoEditor
key={viewStateScopeId}
fileId={activeFile.id}
filePath={activeFile.filePath}
viewStateKey={editorViewStateKey}
relativePath={activeFile.relativePath}
@ -286,15 +292,17 @@ export function EditorContent({
markdownAnnotationsEnabled={false}
conflictDecorationsEnabled={activeFile.conflict?.conflictStatus === 'unresolved'}
revealLine={
pendingEditorReveal?.filePath === activeFile.filePath ? pendingEditorReveal.line : undefined
matchesPendingEditorReveal(pendingEditorReveal, activeFile)
? pendingEditorReveal.line
: undefined
}
revealColumn={
pendingEditorReveal?.filePath === activeFile.filePath
matchesPendingEditorReveal(pendingEditorReveal, activeFile)
? pendingEditorReveal.column
: undefined
}
revealMatchLength={
pendingEditorReveal?.filePath === activeFile.filePath
matchesPendingEditorReveal(pendingEditorReveal, activeFile)
? pendingEditorReveal.matchLength
: undefined
}
@ -413,6 +421,9 @@ export function EditorContent({
key={viewStateScopeId}
content={currentContent}
filePath={activeFile.filePath}
sourceFileId={activeFile.id}
sourceWorktreeId={activeFile.worktreeId}
sourceRuntimeEnvironmentId={activeFile.runtimeEnvironmentId}
scrollCacheKey={`${editorViewStateKey}:preview`}
showTableOfContents={showMarkdownTableOfContents}
onCloseTableOfContents={onCloseMarkdownTableOfContents}
@ -512,6 +523,7 @@ export function EditorContent({
<div className={autoHeight ? 'shrink-0' : 'min-h-0 flex-1'}>
<MonacoEditor
key={`${viewStateScopeId}:${contentFile.id}:${viewStateKeySuffix}`}
fileId={contentFile.id}
filePath={contentFile.filePath}
viewStateKey={selectedViewStateKey}
relativePath={contentFile.relativePath}
@ -527,17 +539,17 @@ export function EditorContent({
readOnly={readOnly}
autoHeight={autoHeight}
revealLine={
pendingEditorReveal?.filePath === contentFile.filePath
matchesPendingEditorReveal(pendingEditorReveal, contentFile)
? pendingEditorReveal.line
: undefined
}
revealColumn={
pendingEditorReveal?.filePath === contentFile.filePath
matchesPendingEditorReveal(pendingEditorReveal, contentFile)
? pendingEditorReveal.column
: undefined
}
revealMatchLength={
pendingEditorReveal?.filePath === contentFile.filePath
matchesPendingEditorReveal(pendingEditorReveal, contentFile)
? pendingEditorReveal.matchLength
: undefined
}
@ -657,6 +669,9 @@ export function EditorContent({
key={viewStateScopeId}
content={previewContent}
filePath={activeFile.filePath}
sourceFileId={previewSourceFileId}
sourceWorktreeId={activeFile.worktreeId}
sourceRuntimeEnvironmentId={activeFile.runtimeEnvironmentId}
scrollCacheKey={markdownPreviewViewStateKey}
initialAnchor={activeFile.markdownPreviewAnchor ?? null}
showTableOfContents={showMarkdownTableOfContents}
@ -811,6 +826,9 @@ export function EditorContent({
key={viewStateScopeId}
content={modifiedDiffContent}
filePath={activeFile.filePath}
sourceFileId={activeFile.id}
sourceWorktreeId={activeFile.worktreeId}
sourceRuntimeEnvironmentId={activeFile.runtimeEnvironmentId}
scrollCacheKey={`${diffViewStateKey}:preview`}
showTableOfContents={showMarkdownTableOfContents}
onCloseTableOfContents={onCloseMarkdownTableOfContents}

View File

@ -249,13 +249,16 @@ function EditorPanelInner({
}
}
const handleOpenMarkdownPreview = (): void => {
openMarkdownPreview({
filePath: activeFile.filePath,
relativePath: activeFile.relativePath,
worktreeId: activeFile.worktreeId,
runtimeEnvironmentId: activeFile.runtimeEnvironmentId,
language: model.resolvedLanguage
})
openMarkdownPreview(
{
filePath: activeFile.filePath,
relativePath: activeFile.relativePath,
worktreeId: activeFile.worktreeId,
runtimeEnvironmentId: activeFile.runtimeEnvironmentId,
language: model.resolvedLanguage
},
{ sourceFileId: activeFile.id }
)
}
const handleOpenContainingFolder = (): void => {
if (

View File

@ -8,6 +8,7 @@ import { UntitledFileRenameDialog } from './UntitledFileRenameDialog'
import type { getEditorPanelRenderModel } from './editor-panel-render-model'
import type { DiffContent, FileContent } from './editor-panel-content-types'
import type { EditorToggleValue } from './EditorViewToggle'
import { getUntitledFileRoot } from './untitled-file-rename-path'
type EditorPanelRenderModel = ReturnType<typeof getEditorPanelRenderModel>
@ -152,8 +153,13 @@ export function EditorPanelShell({
currentName={renameDialogFile?.relativePath ?? ''}
worktreePath={
renameDialogFile
? (findWorktreeById(useAppStore.getState().worktreesByRepo, renameDialogFile.worktreeId)
?.path ?? '')
? getUntitledFileRoot(
renameDialogFile,
findWorktreeById(
useAppStore.getState().worktreesByRepo,
renameDialogFile.worktreeId
)?.path
)
: ''
}
disableBrowse={disableRenameBrowse}

View File

@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest'
import type { Worktree } from '../../../../shared/types'
import {
deriveMarkdownPreviewSourceRoot,
findMarkdownPreviewOpenedEditFileId,
findMarkdownPreviewSourceOpenFile,
resolveMarkdownPreviewSourceWorktree
} from './MarkdownPreview'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
function makeWorktree(id: string, path: string): Worktree {
return {
id,
repoId: `repo-${id}`,
path,
branch: 'refs/heads/main',
head: 'abc',
isBare: false,
isMainWorktree: true,
displayName: id,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0
}
}
describe('MarkdownPreview source link routing', () => {
it('keeps the explicit source worktree when it exists', () => {
const source = makeWorktree('wt-source', '/repo')
const nested = makeWorktree('wt-nested', '/repo/packages/app')
expect(
resolveMarkdownPreviewSourceWorktree(
{ repo: [source, nested] },
'wt-source',
'/repo/packages/app/docs/note.md'
)
).toBe(source)
})
it('falls back to path-based repo ownership for repo-contained floating files', () => {
const repoWorktree = makeWorktree('wt-repo', '/repo')
expect(
resolveMarkdownPreviewSourceWorktree(
{ repo: [repoWorktree] },
FLOATING_TERMINAL_WORKTREE_ID,
'/repo/docs/note.md'
)
).toBe(repoWorktree)
})
it('derives a source root from floating file relative path', () => {
expect(deriveMarkdownPreviewSourceRoot('/tmp/orca/docs/note.md', 'docs/note.md')).toBe(
'/tmp/orca'
)
})
it('falls back to the source file directory when no relative path is available', () => {
expect(deriveMarkdownPreviewSourceRoot('/tmp/orca/docs/note.md', null)).toBe('/tmp/orca/docs')
})
it('derives Windows source roots without dropping the drive separator', () => {
expect(deriveMarkdownPreviewSourceRoot('C:\\orca\\docs\\note.md', 'docs\\note.md')).toBe(
'C:/orca'
)
})
it('falls back to the matching preview tab for preview-only source metadata', () => {
const otherOwnerEdit = {
id: '/tmp/orca/docs/note.md',
filePath: '/tmp/orca/docs/note.md',
relativePath: 'docs/note.md',
worktreeId: 'wt-1',
mode: 'edit'
}
const preview = {
id: 'markdown-preview::/tmp/orca/docs/note.md',
filePath: '/tmp/orca/docs/note.md',
relativePath: 'docs/note.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
runtimeEnvironmentId: null,
mode: 'markdown-preview',
markdownPreviewSourceFileId: '/tmp/orca/docs/note.md'
}
expect(
findMarkdownPreviewSourceOpenFile([otherOwnerEdit, preview], {
sourceFileId: '/tmp/orca/docs/note.md',
filePath: '/tmp/orca/docs/note.md',
sourceWorktreeId: FLOATING_TERMINAL_WORKTREE_ID,
sourceRuntimeEnvironmentId: null
})
).toBe(preview)
expect(deriveMarkdownPreviewSourceRoot(preview.filePath, preview.relativePath)).toBe(
'/tmp/orca'
)
})
it('uses the edit tab that openFile actually activated for line reveals', () => {
const localEdit = {
id: '/repo/docs/guide.md',
filePath: '/repo/docs/guide.md',
relativePath: 'docs/guide.md',
worktreeId: 'wt-1',
runtimeEnvironmentId: null,
mode: 'edit'
}
const activeRuntimeEdit = {
id: 'editor:wt-1:env-active:guide',
filePath: '/repo/docs/guide.md',
relativePath: 'docs/guide.md',
worktreeId: 'wt-1',
runtimeEnvironmentId: 'env-active',
mode: 'edit'
}
expect(
findMarkdownPreviewOpenedEditFileId(
[localEdit, activeRuntimeEdit],
{
'wt-1': activeRuntimeEdit.id
},
{
filePath: '/repo/docs/guide.md',
worktreeId: 'wt-1'
}
)
).toBe(activeRuntimeEdit.id)
})
})

View File

@ -80,10 +80,15 @@ import {
} from '@/lib/markdown-review-notes'
import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { dirname } from '@/lib/path'
type MarkdownPreviewProps = {
content: string
filePath: string
sourceFileId?: string | null
sourceWorktreeId?: string | null
sourceRuntimeEnvironmentId?: string | null
scrollCacheKey: string
initialAnchor?: string | null
showTableOfContents?: boolean
@ -102,6 +107,74 @@ type MarkdownPreviewPositionNode = {
children?: MarkdownPreviewPositionNode[]
}
type MarkdownPreviewSourceOpenFile = {
id: string
filePath: string
relativePath: string
worktreeId: string
runtimeEnvironmentId?: string | null
mode: string
markdownPreviewSourceFileId?: string
}
export function findMarkdownPreviewSourceOpenFile(
openFiles: MarkdownPreviewSourceOpenFile[],
params: {
sourceFileId: string | null
filePath: string
sourceWorktreeId: string | null
sourceRuntimeEnvironmentId: string | null | undefined
}
): MarkdownPreviewSourceOpenFile | undefined {
const ownerMatches = (file: MarkdownPreviewSourceOpenFile): boolean =>
(!params.sourceWorktreeId || file.worktreeId === params.sourceWorktreeId) &&
(params.sourceRuntimeEnvironmentId === undefined ||
(file.runtimeEnvironmentId ?? null) === (params.sourceRuntimeEnvironmentId ?? null))
if (params.sourceFileId) {
const idMatch = openFiles.find((file) => file.id === params.sourceFileId && ownerMatches(file))
return (
idMatch ??
openFiles.find(
(file) =>
file.mode === 'markdown-preview' &&
file.filePath === params.filePath &&
file.markdownPreviewSourceFileId === params.sourceFileId &&
ownerMatches(file)
) ??
openFiles.find((file) => file.id === params.sourceFileId)
)
}
return openFiles.find((file) => file.filePath === params.filePath && ownerMatches(file))
}
export function findMarkdownPreviewOpenedEditFileId(
openFiles: MarkdownPreviewSourceOpenFile[],
activeFileIdByWorktree: Record<string, string | null>,
params: { filePath: string; worktreeId: string }
): string {
const activeFileId = activeFileIdByWorktree[params.worktreeId]
const activeFile = openFiles.find(
(file) =>
file.id === activeFileId &&
file.filePath === params.filePath &&
file.worktreeId === params.worktreeId &&
file.mode === 'edit'
)
if (activeFile) {
return activeFile.id
}
return (
openFiles.find(
(file) =>
file.filePath === params.filePath &&
file.worktreeId === params.worktreeId &&
file.mode === 'edit'
)?.id ?? params.filePath
)
}
function getMarkdownPreviewBlockRange(
node: MarkdownPreviewPositionNode | undefined
): { startLine: number; endLine: number } | null {
@ -194,6 +267,44 @@ function normalizeMarkdownPreviewAbsolutePath(absolutePath: string): string {
return absolutePath.replaceAll('\\', '/')
}
function normalizeMarkdownPreviewRelativePath(relativePath: string): string {
return relativePath.replaceAll('\\', '/').replace(/^\/+/, '')
}
function isMarkdownPreviewAbsolutePathLike(path: string): boolean {
return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\')
}
function formatMarkdownPreviewRootPath(rootPath: string): string {
if (rootPath === '') {
return '/'
}
if (/^[A-Za-z]:$/.test(rootPath)) {
return `${rootPath}/`
}
return rootPath
}
export function deriveMarkdownPreviewSourceRoot(
filePath: string,
relativePath: string | null | undefined
): string {
const normalizedFilePath = normalizeMarkdownPreviewAbsolutePath(filePath)
const normalizedRelativePath =
relativePath && !isMarkdownPreviewAbsolutePathLike(relativePath)
? normalizeMarkdownPreviewRelativePath(relativePath)
: ''
if (normalizedRelativePath) {
const suffix = `/${normalizedRelativePath}`
if (normalizedFilePath.endsWith(suffix)) {
return formatMarkdownPreviewRootPath(normalizedFilePath.slice(0, -suffix.length))
}
}
return formatMarkdownPreviewRootPath(normalizeMarkdownPreviewAbsolutePath(dirname(filePath)))
}
function findWorktreeForMarkdownPreviewPath(
worktreesByRepo: Record<string, Worktree[]>,
absolutePath: string
@ -220,9 +331,24 @@ function findWorktreeForMarkdownPreviewPath(
return bestMatch
}
export function resolveMarkdownPreviewSourceWorktree(
worktreesByRepo: Record<string, Worktree[]>,
sourceWorktreeId: string | null | undefined,
filePath: string
): Worktree | null {
const sourceWorktree = sourceWorktreeId
? (findWorktreeById(worktreesByRepo, sourceWorktreeId) ?? null)
: null
return sourceWorktree ?? findWorktreeForMarkdownPreviewPath(worktreesByRepo, filePath)
}
export default function MarkdownPreview({
content,
filePath,
sourceFileId = null,
sourceWorktreeId = null,
sourceRuntimeEnvironmentId = undefined,
scrollCacheKey,
initialAnchor = null,
showTableOfContents = false,
@ -250,17 +376,35 @@ export default function MarkdownPreview({
const deleteDiffComment = useAppStore((s) => s.deleteDiffComment)
const updateDiffComment = useAppStore((s) => s.updateDiffComment)
const markDiffCommentsSent = useAppStore((s) => s.markDiffCommentsSent)
const allDiffComments = useAppStore((s): DiffComment[] | undefined => {
const worktree = findWorktreeForMarkdownPreviewPath(s.worktreesByRepo, filePath)
return worktree?.diffComments
})
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const sourceRuntimeEnvironmentId = useAppStore(
(s) => s.openFiles.find((file) => file.filePath === filePath)?.runtimeEnvironmentId
const sourceOpenFile = useAppStore((s) =>
findMarkdownPreviewSourceOpenFile(s.openFiles, {
sourceFileId,
filePath,
sourceWorktreeId,
sourceRuntimeEnvironmentId
})
)
const sourceWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, filePath)
const sourceConnectionId = sourceWorktree ? getConnectionId(sourceWorktree.id) : null
const worktreeRoot = sourceWorktree?.path ?? null
const resolvedSourceWorktreeId = sourceWorktreeId ?? sourceOpenFile?.worktreeId ?? null
const resolvedSourceRuntimeEnvironmentId =
sourceRuntimeEnvironmentId !== undefined
? sourceRuntimeEnvironmentId
: sourceOpenFile?.runtimeEnvironmentId
const sourceWorktree = resolveMarkdownPreviewSourceWorktree(
worktreesByRepo,
resolvedSourceWorktreeId,
filePath
)
const allDiffComments = sourceWorktree?.diffComments
const sourceRoutingWorktreeId = sourceWorktree?.id ?? resolvedSourceWorktreeId
const sourceConnectionId = sourceRoutingWorktreeId
? (getConnectionId(sourceRoutingWorktreeId) ?? null)
: null
const worktreeRoot =
sourceWorktree?.path ??
(sourceRoutingWorktreeId
? deriveMarkdownPreviewSourceRoot(filePath, sourceOpenFile?.relativePath)
: null)
const sourceRelativePath = useMemo(() => {
if (!sourceWorktree) {
return null
@ -285,15 +429,21 @@ export default function MarkdownPreview({
const settings = useAppStore((s) => s.settings)
const imageRuntimeContext = useMemo(
() =>
sourceWorktree
sourceRoutingWorktreeId && worktreeRoot
? {
settings: settingsForRuntimeOwner(settings, sourceRuntimeEnvironmentId),
worktreeId: sourceWorktree.id,
worktreePath: sourceWorktree.path,
settings: settingsForRuntimeOwner(settings, resolvedSourceRuntimeEnvironmentId),
worktreeId: sourceRoutingWorktreeId,
worktreePath: worktreeRoot,
connectionId: sourceConnectionId
}
: undefined,
[settings, sourceConnectionId, sourceRuntimeEnvironmentId, sourceWorktree]
[
settings,
sourceConnectionId,
resolvedSourceRuntimeEnvironmentId,
sourceRoutingWorktreeId,
worktreeRoot
]
)
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
const editorFontSize = computeEditorFontSize(14, editorFontZoomLevel)
@ -804,7 +954,7 @@ export default function MarkdownPreview({
isLocalPathOpenBlocked(
settingsForRuntimeOwner(
useAppStore.getState().settings,
sourceRuntimeEnvironmentId
resolvedSourceRuntimeEnvironmentId
),
{ connectionId: sourceConnectionId }
)
@ -871,12 +1021,15 @@ export default function MarkdownPreview({
const targetWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath)
if (!targetWorktree) {
if (sourceWorktree) {
if (sourceRoutingWorktreeId && worktreeRoot) {
// Why: floating markdown files are owned by a synthetic workspace,
// so there may be no repo worktree even though Orca can stat/open
// links relative to the source file root.
void activateMarkdownLink(href, {
sourceFilePath: filePath,
worktreeId: sourceWorktree.id,
worktreeRoot: sourceWorktree.path,
runtimeEnvironmentId: sourceRuntimeEnvironmentId
worktreeId: sourceRoutingWorktreeId,
worktreeRoot,
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId
})
return
}
@ -884,7 +1037,7 @@ export default function MarkdownPreview({
isLocalPathOpenBlocked(
settingsForRuntimeOwner(
useAppStore.getState().settings,
sourceRuntimeEnvironmentId
resolvedSourceRuntimeEnvironmentId
),
{ connectionId: sourceConnectionId }
)
@ -905,7 +1058,7 @@ export default function MarkdownPreview({
{
settings: settingsForRuntimeOwner(
useAppStore.getState().settings,
sourceRuntimeEnvironmentId
resolvedSourceRuntimeEnvironmentId
),
worktreeId: targetWorktree.id,
worktreePath: targetWorktree.path,
@ -925,22 +1078,29 @@ export default function MarkdownPreview({
// Why: line targets like #L10 and path.ts:10 should reveal in Monaco,
// not open a preview tab or a literal path with the suffix included.
if (lineTarget) {
if (language === 'markdown') {
setMarkdownViewMode(absolutePath, 'source')
}
openFile({
filePath: absolutePath,
relativePath,
worktreeId: targetWorktree.id,
runtimeEnvironmentId: sourceRuntimeEnvironmentId,
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
language,
mode: 'edit'
})
const openedState = useAppStore.getState()
const targetFileId = findMarkdownPreviewOpenedEditFileId(
openedState.openFiles,
openedState.activeFileIdByWorktree,
{ filePath: absolutePath, worktreeId: targetWorktree.id }
)
if (language === 'markdown') {
setMarkdownViewMode(targetFileId, 'source')
}
setPendingEditorReveal(null)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setPendingEditorReveal({
filePath: absolutePath,
fileId: targetFileId,
line: lineTarget.line,
column: lineTarget.column ?? 1,
matchLength: 0
@ -956,7 +1116,7 @@ export default function MarkdownPreview({
filePath: absolutePath,
relativePath,
worktreeId: targetWorktree.id,
runtimeEnvironmentId: sourceRuntimeEnvironmentId,
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
language
},
{ anchor: target.hash ? target.hash.slice(1) : null }
@ -968,7 +1128,7 @@ export default function MarkdownPreview({
filePath: absolutePath,
relativePath,
worktreeId: targetWorktree.id,
runtimeEnvironmentId: sourceRuntimeEnvironmentId,
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId,
language,
mode: 'edit'
})
@ -996,7 +1156,7 @@ export default function MarkdownPreview({
return
}
if (!src || !sourceWorktree) {
if (!src || !sourceRoutingWorktreeId || !worktreeRoot) {
return
}
@ -1004,9 +1164,9 @@ export default function MarkdownPreview({
event.stopPropagation()
void activateMarkdownLink(src, {
sourceFilePath: filePath,
worktreeId: sourceWorktree.id,
worktreeRoot: sourceWorktree.path,
runtimeEnvironmentId: sourceRuntimeEnvironmentId
worktreeId: sourceRoutingWorktreeId,
worktreeRoot,
runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId
})
}
@ -1157,8 +1317,8 @@ export default function MarkdownPreview({
setMarkdownViewMode,
setPendingEditorReveal,
sourceConnectionId,
sourceRuntimeEnvironmentId,
sourceWorktree,
resolvedSourceRuntimeEnvironmentId,
sourceRoutingWorktreeId,
worktreeRoot,
worktreesByRepo,
wrapAnnotatedBlock

View File

@ -39,6 +39,7 @@ import { getDiffCommentPopoverLeft } from '../diff-comments/diff-comment-popover
import { isLinuxUserAgent } from '../terminal-pane/pane-helpers'
type MonacoEditorProps = {
fileId: string
filePath: string
viewStateKey: string
relativePath: string
@ -58,6 +59,7 @@ type MonacoEditorProps = {
}
export default function MonacoEditor({
fileId,
filePath,
viewStateKey,
relativePath,
@ -418,7 +420,10 @@ export default function MonacoEditor({
// Why: search-result navigation sets the reveal before openFile switches
// the active tab. Without scoping consumption to the destination file,
// the previously mounted editor can clear the reveal on the first click.
if (reveal?.filePath === filePath) {
const revealMatchesEditor = reveal?.fileId
? reveal.fileId === fileId
: reveal?.filePath === filePath
if (reveal && revealMatchesEditor) {
queueReveal(editorInstance, reveal.line, reveal.column, reveal.matchLength, () => {
useAppStore.getState().setPendingEditorReveal(null)
})
@ -448,6 +453,7 @@ export default function MonacoEditor({
[
queueReveal,
setupCopy,
fileId,
filePath,
setEditorCursorLine,
updateMarkdownCompletionDocuments,

View File

@ -10,6 +10,7 @@ import {
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { getRelativePathInsideRoot } from '@/lib/path'
type UntitledFileRenameDialogProps = {
open: boolean
@ -69,21 +70,19 @@ export function UntitledFileRenameDialog({
return
}
const trimmedDir = dir.trim().replace(/\/+$/, '')
const trimmedDir = dir.trim().replace(/[\\/]+$/, '')
if (!trimmedDir) {
setError('Folder path cannot be empty')
return
}
// Why: strict prefix check with trailing '/' prevents partial directory
// name matches (e.g. "/project-backup" matching "/project").
if (trimmedDir !== worktreePath && !trimmedDir.startsWith(`${worktreePath}/`)) {
const relDir = getRelativePathInsideRoot(trimmedDir, worktreePath)
if (relDir === null) {
setError('Folder must be inside the current workspace')
return
}
const fileName = `${trimmedName}.md`
const relDir = trimmedDir.slice(worktreePath.length).replace(/^\/+/, '')
const relativePath = relDir ? `${relDir}/${fileName}` : fileName
onConfirm(relativePath)
}, [name, dir, worktreePath, onConfirm])

View File

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { getUntitledFileRoot } from './untitled-file-rename-path'
describe('getUntitledFileRoot', () => {
it('uses the real worktree path when one exists', () => {
expect(
getUntitledFileRoot(
{ filePath: '/tmp/floating/untitled.md', relativePath: 'untitled.md' },
'/repo/worktree'
)
).toBe('/repo/worktree')
})
it('falls back to the file root for floating markdown files', () => {
expect(
getUntitledFileRoot({
filePath: '/Users/alice/Library/Application Support/Orca/floating-workspace/untitled.md',
relativePath: 'untitled.md'
})
).toBe('/Users/alice/Library/Application Support/Orca/floating-workspace')
})
it('handles nested untitled relative paths', () => {
expect(
getUntitledFileRoot({
filePath: '/tmp/orca/floating-workspace/notes/untitled.md',
relativePath: 'notes/untitled.md'
})
).toBe('/tmp/orca/floating-workspace')
})
})

View File

@ -0,0 +1,21 @@
import { dirname } from '@/lib/path'
import type { OpenFile } from '@/store/slices/editor'
type UntitledPathFile = Pick<OpenFile, 'filePath' | 'relativePath'>
export function getUntitledFileRoot(file: UntitledPathFile, worktreePath?: string | null): string {
if (worktreePath) {
return worktreePath
}
if (!file.relativePath) {
return dirname(file.filePath)
}
const rootLength = file.filePath.length - file.relativePath.length - 1
if (rootLength <= 0) {
return dirname(file.filePath)
}
return file.filePath.slice(0, rootLength)
}

View File

@ -7,13 +7,16 @@ type UseMarkdownPreviewShortcutParams = {
activeFile: OpenFile | null
panelRef: RefObject<HTMLDivElement | null>
isMac: boolean
openMarkdownPreview: (file: {
filePath: string
relativePath: string
worktreeId: string
runtimeEnvironmentId?: string
language: string
}) => void
openMarkdownPreview: (
file: {
filePath: string
relativePath: string
worktreeId: string
runtimeEnvironmentId?: string | null
language: string
},
options?: { sourceFileId?: string }
) => void
}
export function useMarkdownPreviewShortcut({
@ -25,6 +28,7 @@ export function useMarkdownPreviewShortcut({
const activeFilePath = activeFile?.filePath ?? null
const activeFileRelativePath = activeFile?.relativePath ?? null
const activeFileWorktreeId = activeFile?.worktreeId ?? null
const activeFileId = activeFile?.id ?? null
const activeFileMode = activeFile?.mode ?? null
const activeFileDiffSource = activeFile?.diffSource
const activeFileRuntimeEnvironmentId = activeFile?.runtimeEnvironmentId
@ -56,13 +60,16 @@ export function useMarkdownPreviewShortcut({
}
event.preventDefault()
event.stopPropagation()
openMarkdownPreview({
filePath: activeFilePath,
relativePath: activeFileRelativePath,
worktreeId: activeFileWorktreeId,
runtimeEnvironmentId: activeFileRuntimeEnvironmentId,
language: shortcutLanguage
})
openMarkdownPreview(
{
filePath: activeFilePath,
relativePath: activeFileRelativePath,
worktreeId: activeFileWorktreeId,
runtimeEnvironmentId: activeFileRuntimeEnvironmentId,
language: shortcutLanguage
},
{ sourceFileId: activeFileId ?? undefined }
)
}
window.addEventListener('keydown', handleKeyDown, { capture: true })
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
@ -70,6 +77,7 @@ export function useMarkdownPreviewShortcut({
activeFileDiffSource,
activeFileMode,
activeFilePath,
activeFileId,
activeFileRelativePath,
activeFileRuntimeEnvironmentId,
activeFileWorktreeId,

View File

@ -11,6 +11,7 @@ import {
} from '@/runtime/runtime-file-client'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import { requestEditorFileSave, requestEditorSaveQuiesce } from './editor-autosave'
import { getUntitledFileRoot } from './untitled-file-rename-path'
type UseUntitledFileRenameParams = {
openFiles: OpenFile[]
@ -19,7 +20,7 @@ type UseUntitledFileRenameParams = {
filePath: string
relativePath: string
worktreeId: string
runtimeEnvironmentId?: string
runtimeEnvironmentId?: string | null
language: string
mode: 'edit'
}) => void
@ -58,12 +59,7 @@ export function useUntitledFileRename({
return
}
const oldPath = renameDialogFile.filePath
// Why: derive the worktree root from the old relative path so nested
// untitled saves resolve relative to the worktree, not the current folder.
const worktreeRoot = oldPath.slice(
0,
oldPath.length - renameDialogFile.relativePath.length - 1
)
const worktreeRoot = getUntitledFileRoot(renameDialogFile)
const newPath = joinPath(worktreeRoot, newRelPath)
const connectionId = getConnectionId(renameDialogFile.worktreeId) ?? undefined
const fileContext = {
@ -110,7 +106,7 @@ export function useUntitledFileRename({
return
}
closeFile(oldPath)
closeFile(renameDialogFile.id)
openFile({
filePath: newPath,
relativePath: newRelPath,

View File

@ -88,7 +88,7 @@ export function FloatingTerminalIconContextMenu({
}}
>
<EyeOff className="size-3.5" />
Hide
Hide Floating Workspace
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types'
import type { OpenFile } from '@/store/slices/editor'
import { createUntitledMarkdownFile } from '@/lib/create-untitled-markdown'
type EffectCallback = () => void | (() => void)
@ -82,6 +83,7 @@ const mocks = vi.hoisted(() => ({
isWebRuntimeSessionActive: vi.fn(),
markFileDirty: vi.fn(),
openFile: vi.fn(),
pickFloatingMarkdownDocument: vi.fn(),
pinFile: vi.fn(),
setActiveTab: vi.fn(),
setTabColor: vi.fn(),
@ -405,7 +407,7 @@ function resetStore(tabs: TerminalTab[] = []): void {
setTabPaneExpanded: mocks.setTabPaneExpanded,
browserDefaultUrl: 'about:blank',
tabBarOrderByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs.map((tab) => tab.id) },
settings: { floatingTerminalCwd: '~' }
settings: { floatingTerminalCwd: '' }
} satisfies FloatingPanelStoreState
}
@ -476,10 +478,14 @@ describe('FloatingTerminalPanel close behavior', () => {
mocks.getFloatingTerminalCwd.mockResolvedValue('/tmp/orca')
mocks.getInstallStatus.mockResolvedValue({ state: 'installed' })
mocks.isWebRuntimeSessionActive.mockReturnValue(false)
mocks.pickFloatingMarkdownDocument.mockResolvedValue(null)
vi.stubGlobal('window', {
addEventListener: vi.fn(),
api: {
app: { getFloatingTerminalCwd: mocks.getFloatingTerminalCwd },
app: {
getFloatingTerminalCwd: mocks.getFloatingTerminalCwd,
pickFloatingMarkdownDocument: mocks.pickFloatingMarkdownDocument
},
browser: { notifyActiveTabChanged: vi.fn() },
cli: { getInstallStatus: mocks.getInstallStatus }
},
@ -493,7 +499,7 @@ describe('FloatingTerminalPanel close behavior', () => {
vi.unstubAllGlobals()
})
it('bootstraps a terminal tab only when the panel opens', async () => {
it('does not bootstrap a terminal tab when the panel opens empty', async () => {
await renderPanel(false)
runEffects()
await flushAsyncWork()
@ -502,27 +508,32 @@ describe('FloatingTerminalPanel close behavior', () => {
await renderPanel(true)
runEffects()
await flushAsyncWork()
expect(mocks.createTab).toHaveBeenCalledTimes(1)
expect(mocks.createTab).toHaveBeenCalledWith(
FLOATING_TERMINAL_WORKTREE_ID,
undefined,
undefined,
{ activate: false }
)
expect(mocks.activateTab).toHaveBeenCalledWith('created-tab')
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('created-tab')
expect(mocks.createTab).not.toHaveBeenCalled()
await renderPanel(true)
runEffects()
await flushAsyncWork()
expect(mocks.createTab).toHaveBeenCalledTimes(1)
expect(mocks.createTab).not.toHaveBeenCalled()
await renderPanel(false)
runEffects()
await renderPanel(true)
runEffects()
await flushAsyncWork()
expect(mocks.createTab).toHaveBeenCalledTimes(2)
expect(mocks.createTab).not.toHaveBeenCalled()
})
it('minimizes the empty floating workspace from the empty state', async () => {
const onOpenChange = vi.fn()
const element = await renderPanel(true, onOpenChange)
const emptyState = findByTypeName(element, 'FloatingTerminalEmptyState')
;(emptyState.props.onClose as () => void)()
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(mocks.closeTab).not.toHaveBeenCalled()
expect(mocks.closeFile).not.toHaveBeenCalled()
expect(mocks.closeBrowserTab).not.toHaveBeenCalled()
})
it('creates new floating terminal tabs without globally activating createTab', async () => {
@ -543,7 +554,64 @@ describe('FloatingTerminalPanel close behavior', () => {
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('created-tab')
})
it('closes the panel when the explicit close action removes the last tab', async () => {
it('creates floating markdown files in local filesystem mode', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' })])
vi.mocked(createUntitledMarkdownFile).mockResolvedValue({
filePath: '/tmp/orca/untitled.md',
relativePath: 'untitled.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
language: 'markdown',
isUntitled: true,
mode: 'edit'
})
let element = await renderPanel(true)
runEffects()
await flushAsyncWork()
element = await renderPanel(true)
const tabBar = findByTypeName(element, 'TabBar')
;(tabBar.props.onNewFileTab as () => void)()
await flushAsyncWork()
expect(createUntitledMarkdownFile).toHaveBeenCalledWith(
'/tmp/orca',
FLOATING_TERMINAL_WORKTREE_ID,
undefined,
{ activeRuntimeEnvironmentId: null }
)
expect(mocks.openFile).toHaveBeenCalledWith(
expect.objectContaining({ filePath: '/tmp/orca/untitled.md' }),
expect.objectContaining({ suppressActiveRuntimeFallback: true })
)
})
it('opens existing markdown documents through the floating picker', async () => {
setFloatingTabs([makeTab({ id: 'tab-1' })])
mocks.pickFloatingMarkdownDocument.mockResolvedValue({
filePath: '/tmp/orca/notes.md',
relativePath: 'notes.md',
basename: 'notes.md',
name: 'notes'
})
const element = await renderPanel(true)
const tabBar = findByTypeName(element, 'TabBar')
;(tabBar.props.onOpenFileTab as () => void)()
await flushAsyncWork()
expect(mocks.pickFloatingMarkdownDocument).toHaveBeenCalledWith({ path: '' })
expect(mocks.openFile).toHaveBeenCalledWith(
expect.objectContaining({
filePath: '/tmp/orca/notes.md',
relativePath: 'notes.md',
runtimeEnvironmentId: null,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID
}),
expect.objectContaining({ suppressActiveRuntimeFallback: true })
)
})
it('keeps the panel open when the explicit close action removes the last tab', async () => {
const onOpenChange = vi.fn()
setFloatingTabs([makeTab({ id: 'tab-1' })])
@ -552,7 +620,7 @@ describe('FloatingTerminalPanel close behavior', () => {
;(tabBar.props.onClose as (tabId: string) => void)('tab-1')
expect(mocks.closeTab).toHaveBeenCalledWith('tab-1')
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(onOpenChange).not.toHaveBeenCalled()
})
it('keeps the panel open when the explicit close action leaves another tab', async () => {
@ -587,7 +655,7 @@ describe('FloatingTerminalPanel close behavior', () => {
mocks.closeTab.mockClear()
;(terminalPane.props.onCloseTab as () => void)()
expect(mocks.closeTab).toHaveBeenCalledWith('tab-1')
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(onOpenChange).not.toHaveBeenCalled()
})
it('routes floating terminal create and close through active web runtime sessions', async () => {

View File

@ -3,6 +3,7 @@
* handling in one surface so the floating worktree does not drift from the
* main tab model while still keeping the DOM-mounted panes local. */
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FileText, Globe, TerminalSquare } from 'lucide-react'
import { toast } from 'sonner'
import BrowserPane from '@/components/browser-pane/BrowserPane'
import TabBar from '@/components/tab-bar/TabBar'
@ -21,6 +22,7 @@ import { useTerminalSaveDialog } from '@/components/terminal/useTerminalSaveDial
import { appendUniqueOpenFileIds } from '@/components/terminal/unsaved-close-queue'
import { getConnectionId } from '@/lib/connection-context'
import { createUntitledMarkdownFile } from '@/lib/create-untitled-markdown'
import { detectLanguage } from '@/lib/language-detect'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import {
@ -61,6 +63,7 @@ const EMPTY_TERMINAL_TABS: TerminalTab[] = []
const EMPTY_BROWSER_TABS: BrowserTabState[] = []
const EMPTY_GROUPS: TabGroup[] = []
const EMPTY_UNIFIED_TABS: Tab[] = []
const LOCAL_RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: null } as const
const EditorPanel = lazy(() => import('@/components/editor/EditorPanel'))
@ -100,8 +103,7 @@ export function FloatingTerminalPanel({
const pinFile = useAppStore((s) => s.pinFile)
const openFile = useAppStore((s) => s.openFile)
const browserDefaultUrl = useAppStore((s) => s.browserDefaultUrl)
const settings = useAppStore((s) => s.settings)
const floatingTerminalCwd = useAppStore((s) => s.settings?.floatingTerminalCwd ?? '~')
const floatingTerminalCwd = useAppStore((s) => s.settings?.floatingTerminalCwd ?? '')
const [cwd, setCwd] = useState<string | null>(null)
const [bounds, setBounds] = useState(() => getDefaultFloatingTerminalBounds())
@ -112,8 +114,6 @@ export function FloatingTerminalPanel({
)
const restoreBoundsRef = useRef<FloatingTerminalPanelBounds | null>(null)
const normalizedInitialBoundsRef = useRef(false)
const previousOpenRef = useRef(false)
const pendingLastEditorCloseRef = useRef(false)
const pendingEditorCloseQueueRef = useRef<string[]>([])
const saveDialogFileIdRef = useRef<string | null>(null)
const panelRef = useRef<HTMLDivElement>(null)
@ -311,7 +311,6 @@ export function FloatingTerminalPanel({
const handleFloatingSaveDialogCancel = useCallback(() => {
pendingEditorCloseQueueRef.current = []
pendingLastEditorCloseRef.current = false
handleSaveDialogCancel()
}, [handleSaveDialogCancel])
@ -329,38 +328,12 @@ export function FloatingTerminalPanel({
useEffect(() => {
void window.api.app
.getFloatingTerminalCwd({
path: floatingTerminalCwd
path: floatingTerminalCwd,
requireTrusted: true
})
.then(setCwd)
}, [floatingTerminalCwd])
useEffect(() => {
const opened = open && !previousOpenRef.current
previousOpenRef.current = open
// Why: zero renderable tabs only means "bootstrap" when the panel is newly
// opened. Later zero-tab states are intentional closes or PTY exits.
if (!opened || unifiedTabs.length > 0) {
return
}
void (async () => {
if (
await createWebRuntimeSessionTerminal({
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
targetGroupId: activeGroup?.id,
activate: true,
selectWorktree: false
})
) {
return
}
const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, activeGroup?.id, undefined, {
activate: false
})
activateTab(tab.id)
focusTerminalTabSurface(tab.id)
})()
}, [activateTab, activeGroup, createTab, open, unifiedTabs.length])
useEffect(() => {
if (!open || !activeTerminalId) {
return
@ -368,16 +341,6 @@ export function FloatingTerminalPanel({
focusTerminalTabSurface(activeTerminalId)
}, [activeTerminalId, open])
useEffect(() => {
if (!open || saveDialogFileId !== null || !pendingLastEditorCloseRef.current) {
return
}
if (unifiedTabs.length === 0) {
pendingLastEditorCloseRef.current = false
onOpenChange(false)
}
}, [onOpenChange, open, saveDialogFileId, unifiedTabs.length])
const refreshOrchestrationSetupVisibility = useCallback(async (): Promise<void> => {
if (isOrchestrationSetupDismissed()) {
setShowOrchestrationSetup(false)
@ -507,7 +470,7 @@ export function FloatingTerminalPanel({
cwd,
FLOATING_TERMINAL_WORKTREE_ID,
getConnectionId(FLOATING_TERMINAL_WORKTREE_ID) ?? undefined,
settings
LOCAL_RUNTIME_SETTINGS
)
openFile(fileInfo, {
preview: false,
@ -518,7 +481,37 @@ export function FloatingTerminalPanel({
toast.error(extractIpcErrorMessage(err, 'Failed to create untitled markdown file.'))
}
})()
}, [activeGroup, cwd, openFile, settings])
}, [activeGroup, cwd, openFile])
const openFloatingMarkdownTab = useCallback(() => {
void (async () => {
try {
const document = await window.api.app.pickFloatingMarkdownDocument({
path: floatingTerminalCwd
})
if (!document) {
return
}
openFile(
{
filePath: document.filePath,
relativePath: document.relativePath,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
language: detectLanguage(document.relativePath),
mode: 'edit',
runtimeEnvironmentId: null
},
{
preview: false,
targetGroupId: activeGroup?.id,
suppressActiveRuntimeFallback: true
}
)
} catch (err) {
toast.error(extractIpcErrorMessage(err, 'Failed to open markdown file.'))
}
})()
}, [activeGroup, floatingTerminalCwd, openFile])
const closeFloatingItems = useCallback(
(visibleIds: string[]) => {
@ -534,10 +527,7 @@ export function FloatingTerminalPanel({
if (items.length === 0) {
return
}
const closingTabIds = new Set(items.map((item) => item.id))
const isClosingEveryVisibleTab = currentGroupTabs.every((tab) => closingTabIds.has(tab.id))
const dirtyEditorFileIds: string[] = []
let hasRuntimeSessionClose = false
const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim()
for (const item of items) {
if (
@ -546,7 +536,6 @@ export function FloatingTerminalPanel({
) {
// Why: paired web clients mirror host-owned tabs; ask the runtime to
// close the host tab instead of deleting the local mirror directly.
hasRuntimeSessionClose = true
void closeWebRuntimeSessionTab({
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
tabId: item.contentType === 'browser' ? item.id : item.entityId,
@ -569,15 +558,10 @@ export function FloatingTerminalPanel({
}
}
if (dirtyEditorFileIds.length > 0) {
pendingLastEditorCloseRef.current = isClosingEveryVisibleTab
queueEditorCloseRequests(dirtyEditorFileIds)
return
}
if (isClosingEveryVisibleTab && !hasRuntimeSessionClose) {
onOpenChange(false)
}
},
[activeGroup, closeBrowserTab, closeFile, closeTab, onOpenChange, queueEditorCloseRequests]
[activeGroup, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests]
)
const closeFloatingItem = useCallback(
@ -768,6 +752,7 @@ export function FloatingTerminalPanel({
onNewTerminalWithShell={createFloatingTerminalTab}
onNewBrowserTab={createFloatingBrowserTab}
onNewFileTab={createFloatingMarkdownTab}
onOpenFileTab={openFloatingMarkdownTab}
onSetCustomTitle={setTabCustomTitle}
onSetTabColor={setTabColor}
onTogglePaneExpand={(tabId) =>
@ -869,6 +854,15 @@ export function FloatingTerminalPanel({
</Suspense>
</div>
) : null}
{unifiedTabs.length === 0 ? (
<FloatingTerminalEmptyState
onNewTerminal={() => createFloatingTerminalTab()}
onNewMarkdown={createFloatingMarkdownTab}
onOpenMarkdown={openFloatingMarkdownTab}
onNewBrowser={createFloatingBrowserTab}
onClose={() => onOpenChange(false)}
/>
) : null}
</div>
</div>
{showOrchestrationSetup && activeTabType === 'terminal' ? (
@ -956,3 +950,68 @@ export function FloatingTerminalPanel({
</div>
)
}
function FloatingTerminalEmptyState({
onNewTerminal,
onNewMarkdown,
onOpenMarkdown,
onNewBrowser,
onClose
}: {
onNewTerminal: () => void
onNewMarkdown: () => void
onOpenMarkdown: () => void
onNewBrowser: () => void
onClose: () => void
}): React.JSX.Element {
return (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex w-[230px] flex-col items-center gap-1.5" data-floating-terminal-no-drag>
<Button
type="button"
variant="ghost"
className="h-8 justify-center gap-2.5 rounded-md px-3 text-sm font-normal text-muted-foreground hover:bg-muted/40 hover:text-foreground"
onClick={onNewTerminal}
>
<TerminalSquare className="size-3.5 opacity-80" />
New Terminal
</Button>
<Button
type="button"
variant="ghost"
className="h-8 justify-center gap-2.5 rounded-md px-3 text-sm font-normal text-muted-foreground hover:bg-muted/40 hover:text-foreground"
onClick={onNewBrowser}
>
<Globe className="size-3.5 opacity-80" />
New Browser
</Button>
<Button
type="button"
variant="ghost"
className="h-8 justify-center gap-2.5 rounded-md px-3 text-sm font-normal text-muted-foreground hover:bg-muted/40 hover:text-foreground"
onClick={onNewMarkdown}
>
<FileText className="size-3.5 opacity-80" />
New Markdown Note
</Button>
<Button
type="button"
variant="ghost"
className="h-8 justify-center gap-2.5 rounded-md px-3 text-sm font-normal text-muted-foreground hover:bg-muted/40 hover:text-foreground"
onClick={onOpenMarkdown}
>
<FileText className="size-3.5 opacity-80" />
Open Markdown Note
</Button>
<Button
type="button"
variant="ghost"
className="h-8 justify-center gap-2.5 rounded-md px-3 text-sm font-normal text-muted-foreground hover:bg-muted/40 hover:text-foreground"
onClick={onClose}
>
Minimize
</Button>
</div>
</div>
)
}

View File

@ -1,4 +1,4 @@
import { TerminalSquare } from 'lucide-react'
import { PanelsTopLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
@ -18,7 +18,7 @@ export function FloatingTerminalToggleButton({
return (
<FloatingTerminalIconContextMenu
currentLocation="floating-button"
className={cn('fixed bottom-8 right-3 z-40', className)}
className={cn('fixed bottom-3 right-3 z-40', className)}
>
<Tooltip>
<TooltipTrigger asChild>
@ -28,17 +28,17 @@ export function FloatingTerminalToggleButton({
size="icon-sm"
className="border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground"
data-floating-terminal-toggle
aria-label={open ? 'Minimize floating terminal' : 'Show floating terminal'}
aria-label={open ? 'Minimize floating workspace' : 'Show floating workspace'}
aria-pressed={open}
onClick={onToggle}
>
<TerminalSquare className="size-3.5" />
<PanelsTopLeft className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={6}
>{`${open ? 'Minimize' : 'Show'} floating terminal (${shortcutLabel})`}</TooltipContent>
>{`${open ? 'Minimize' : 'Show'} floating workspace (${shortcutLabel})`}</TooltipContent>
</Tooltip>
</FloatingTerminalIconContextMenu>
)

View File

@ -82,6 +82,25 @@ export function FloatingTerminalWindowControls({
return (
<div className="flex items-center gap-1 px-2" data-floating-terminal-no-drag>
{defaultAgent ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-xs"
className={controlButtonClassName}
aria-label={`Open ${defaultAgentLabel ?? defaultAgent} in floating workspace`}
onClick={launchDefaultAgent}
>
<AgentIcon agent={defaultAgent} size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Open {defaultAgentLabel ?? defaultAgent}
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -89,29 +108,7 @@ export function FloatingTerminalWindowControls({
variant="outline"
size="icon-xs"
className={controlButtonClassName}
aria-label={
defaultAgentLabel
? `Open ${defaultAgentLabel} in floating terminal`
: 'No default agent configured'
}
disabled={!defaultAgent}
onClick={launchDefaultAgent}
>
<AgentIcon agent={defaultAgent} size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{defaultAgentLabel ? `Open ${defaultAgentLabel}` : 'Choose a default agent first'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-xs"
className={controlButtonClassName}
aria-label={maximized ? 'Restore floating terminal' : 'Maximize floating terminal'}
aria-label={maximized ? 'Restore floating workspace' : 'Maximize floating workspace'}
aria-pressed={maximized}
onClick={onToggleMaximized}
>
@ -129,7 +126,7 @@ export function FloatingTerminalWindowControls({
variant="outline"
size="icon-xs"
className={controlButtonClassName}
aria-label="Minimize floating terminal"
aria-label="Minimize floating workspace"
onClick={onMinimize}
>
<Minus className="size-3.5" />

View File

@ -6,9 +6,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { basename, dirname, joinPath } from '@/lib/path'
import { detectLanguage } from '@/lib/language-detect'
import { getConnectionId } from '@/lib/connection-context'
import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag'
import { remapOpenEditorTabsForPathChange } from '@/lib/remap-open-editor-tabs-for-path-change'
import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave'
import { commitFileExplorerOp } from './fileExplorerUndoRedo'
import { renameRuntimePath } from '@/runtime/runtime-file-client'
@ -207,61 +207,13 @@ export function useFileExplorerDragDrop({
}
const newPath = joinPath(destDir, fileName)
const remapOpenTabsForMovedPath = (fromPath: string, toPath: string): void => {
const state = useAppStore.getState()
const filesToMove = state.openFiles.filter((file) => {
if (file.filePath === fromPath) {
return true
}
return (
file.filePath.startsWith(`${fromPath}/`) || file.filePath.startsWith(`${fromPath}\\`)
)
const remapOpenTabsForMovedPath = (fromPath: string, toPath: string): void =>
remapOpenEditorTabsForPathChange({
fromPath,
toPath,
worktreePath,
worktreeId: activeWorktreeId
})
// Why: OpenFile.id === absolute path, so moves must close/reopen tabs to migrate
// draft/dirty metadata to the new key (forward move and undo/redo parity).
for (const file of filesToMove) {
const oldFilePath = file.filePath
const suffix = oldFilePath.slice(fromPath.length)
const updatedPath = toPath + suffix
const updatedRelative = updatedPath.slice(worktreePath.length + 1)
const draft = state.editorDrafts[file.id]
const wasDirty = file.isDirty
// Why: markdown preview tabs use a synthetic tab id rather than the
// file path, so move remaps must close the actual tab id before
// reopening the file at its new path.
state.closeFile(file.id)
if (file.mode === 'edit') {
state.openFile({
filePath: updatedPath,
relativePath: updatedRelative,
worktreeId: file.worktreeId,
language: detectLanguage(basename(updatedPath)),
mode: 'edit'
})
} else if (file.mode === 'markdown-preview') {
state.openMarkdownPreview(
{
filePath: updatedPath,
relativePath: updatedRelative,
worktreeId: file.worktreeId,
language: 'markdown'
},
{ anchor: file.markdownPreviewAnchor ?? null }
)
} else {
continue
}
if (draft !== undefined) {
state.setEditorDraft(updatedPath, draft)
}
if (wasDirty) {
state.markFileDirty(updatedPath, true)
}
}
}
const run = async (): Promise<void> => {
const filesToMove = openFiles.filter((file) => {

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { getFloatingWorkspaceDirectoryInputValue } from './FloatingWorkspacePane'
describe('getFloatingWorkspaceDirectoryInputValue', () => {
it('shows the resolved app-owned default', () => {
expect(
getFloatingWorkspaceDirectoryInputValue({
resolvedFloatingWorkspacePath:
'/Users/example/Library/Application Support/Orca/floating-workspace'
})
).toBe('/Users/example/Library/Application Support/Orca/floating-workspace')
})
it('shows the main-resolved trusted custom directory', () => {
expect(
getFloatingWorkspaceDirectoryInputValue({
resolvedFloatingWorkspacePath: '/Users/example/notes'
})
).toBe('/Users/example/notes')
})
})

View File

@ -0,0 +1,167 @@
import { useEffect, useState } from 'react'
import { FolderOpen } from 'lucide-react'
import type { FloatingTerminalTriggerLocation, GlobalSettings } from '../../../../shared/types'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'
import { SearchableSetting } from './SearchableSetting'
import { FLOATING_WORKSPACE_SEARCH_ENTRIES } from './floating-workspace-search'
import { matchesSettingsSearch } from './settings-search'
import { useAppStore } from '../../store'
type FloatingWorkspacePaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}
export function getFloatingWorkspaceDirectoryInputValue({
resolvedFloatingWorkspacePath
}: {
resolvedFloatingWorkspacePath: string
}): string {
return resolvedFloatingWorkspacePath
}
export function FloatingWorkspacePane({
settings,
updateSettings
}: FloatingWorkspacePaneProps): React.JSX.Element | null {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const [resolvedFloatingWorkspacePath, setResolvedFloatingWorkspacePath] = useState('')
useEffect(() => {
let cancelled = false
void window.api.app
.getFloatingTerminalCwd({
path: settings.floatingTerminalCwd,
requireTrusted: true
})
.then((path) => {
if (!cancelled) {
setResolvedFloatingWorkspacePath(path)
}
})
.catch(() => {
if (!cancelled) {
setResolvedFloatingWorkspacePath('')
}
})
return () => {
cancelled = true
}
}, [settings.floatingTerminalCwd])
const pickFloatingWorkspaceDirectory = async (): Promise<void> => {
const path = await window.api.app.pickFloatingWorkspaceDirectory()
if (!path) {
return
}
updateSettings({ floatingTerminalCwd: path })
}
const directoryInputValue = getFloatingWorkspaceDirectoryInputValue({
resolvedFloatingWorkspacePath
})
if (!matchesSettingsSearch(searchQuery, FLOATING_WORKSPACE_SEARCH_ENTRIES)) {
return null
}
return (
<section className="space-y-4">
<SearchableSetting
title="Floating Workspace"
description="Enable the floating workspace and choose where new tabs start."
keywords={[
'floating workspace',
'floating terminal',
'terminal',
'browser',
'markdown',
'note',
'global',
'quick panel',
'launch directory'
]}
className="space-y-3"
>
<div className="flex items-center justify-between gap-4 px-1 py-2">
<div className="space-y-0.5">
<Label>Enable Floating Workspace</Label>
<p className="text-xs text-muted-foreground">
Shows the floating workspace button and panel.
</p>
</div>
<button
type="button"
role="switch"
aria-label="Enable Floating Workspace"
aria-checked={settings.floatingTerminalEnabled}
onClick={() =>
updateSettings({
floatingTerminalEnabled: !settings.floatingTerminalEnabled
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.floatingTerminalEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.floatingTerminalEnabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<div className="space-y-2">
<Label>Default Directory</Label>
<div className="flex max-w-xl gap-2">
<Input
value={directoryInputValue}
readOnly
placeholder="Orca floating workspace"
className="min-w-0 flex-1"
/>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Choose floating workspace directory"
onClick={() => void pickFloatingWorkspaceDirectory()}
>
<FolderOpen className="size-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
New floating workspace tabs start here. The default is the Orca app-owned workspace.
</p>
</div>
<div className="space-y-2">
<Label>Toggle Button Location</Label>
<ToggleGroup
type="single"
value={settings.floatingTerminalTriggerLocation ?? 'floating-button'}
onValueChange={(value) => {
if (!value) {
return
}
updateSettings({
floatingTerminalTriggerLocation: value as FloatingTerminalTriggerLocation
})
}}
className="justify-start"
>
<ToggleGroupItem value="floating-button">Floating Button</ToggleGroupItem>
<ToggleGroupItem value="status-bar">Status Bar</ToggleGroupItem>
</ToggleGroup>
<p className="text-xs text-muted-foreground">
The keyboard shortcut works regardless of where the toggle is shown.
</p>
</div>
</SearchableSetting>
</section>
)
}

View File

@ -14,6 +14,7 @@ import {
Lock,
MousePointerClick,
Network,
PanelsTopLeft,
ShieldCheck,
Palette,
Server,
@ -40,12 +41,14 @@ import { AppearancePane, APPEARANCE_PANE_SEARCH_ENTRIES } from './AppearancePane
import { InputPane, INPUT_PANE_SEARCH_ENTRIES } from './InputPane'
import { ShortcutsPane, SHORTCUTS_PANE_SEARCH_ENTRIES } from './ShortcutsPane'
import { TerminalPane } from './TerminalPane'
import { FloatingWorkspacePane } from './FloatingWorkspacePane'
import { useGhosttyImport } from './useGhosttyImport'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import ghosttyIcon from '../../../../../resources/ghostty.svg'
import { RepositoryPane, getRepositoryPaneSearchEntries } from './RepositoryPane'
import { getTerminalPaneSearchEntries } from './terminal-search'
import { FLOATING_WORKSPACE_SEARCH_ENTRIES } from './floating-workspace-search'
import { GitPane, GIT_PANE_SEARCH_ENTRIES } from './GitPane'
import { CommitMessageAiPane } from './CommitMessageAiPane'
import { COMMIT_MESSAGE_AI_PANE_SEARCH_ENTRIES } from './commit-message-ai-search'
@ -96,6 +99,7 @@ type SettingsNavTarget =
| 'tasks'
| 'appearance'
| 'input'
| 'floating-workspace'
| 'terminal'
| 'notifications'
| 'computer-use'
@ -486,6 +490,14 @@ function Settings(): React.JSX.Element {
searchEntries: TASKS_PANE_SEARCH_ENTRIES,
group: 'workflows'
},
{
id: 'floating-workspace',
title: 'Floating Workspace',
description: 'Global terminal, browser, and markdown tabs.',
icon: PanelsTopLeft,
searchEntries: FLOATING_WORKSPACE_SEARCH_ENTRIES,
group: 'workflows'
},
{
id: 'appearance',
title: 'Appearance',
@ -1033,6 +1045,17 @@ function Settings(): React.JSX.Element {
) : null}
</SettingsSection>
<SettingsSection
id="floating-workspace"
title="Floating Workspace"
description="Global terminal, browser, and markdown tabs."
searchEntries={FLOATING_WORKSPACE_SEARCH_ENTRIES}
>
{isSectionMounted('floating-workspace') ? (
<FloatingWorkspacePane settings={settings} updateSettings={updateSettings} />
) : null}
</SettingsSection>
<SettingsSection
id="terminal"
title="Terminal"

View File

@ -2,11 +2,7 @@
splitting individual settings into separate files would scatter related controls without a
meaningful abstraction boundary. Mirrors the same decision made for GeneralPane.tsx. */
import { useState } from 'react'
import type {
FloatingTerminalTriggerLocation,
GlobalSettings,
SetupScriptLaunchMode
} from '../../../../shared/types'
import type { GlobalSettings, SetupScriptLaunchMode } from '../../../../shared/types'
import {
DEFAULT_TERMINAL_FONT_WEIGHT,
TERMINAL_FONT_WEIGHT_MAX,
@ -23,7 +19,7 @@ import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'
import { FolderOpen, Minus, Plus } from 'lucide-react'
import { Minus, Plus } from 'lucide-react'
import {
clampNumber,
resolveEffectiveTerminalAppearance,
@ -42,7 +38,6 @@ import {
TERMINAL_DARK_THEME_SEARCH_ENTRIES,
TERMINAL_LIGHT_THEME_SEARCH_ENTRIES,
TERMINAL_MAC_OPTION_SEARCH_ENTRIES,
TERMINAL_FLOATING_SEARCH_ENTRIES,
TERMINAL_PANE_STYLE_SEARCH_ENTRIES,
TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES,
TERMINAL_RENDERING_SEARCH_ENTRIES,
@ -128,13 +123,6 @@ export function TerminalPane({
const windowsShell = settings.terminalWindowsShell ?? 'powershell.exe'
const powerShellImplementation = settings.terminalWindowsPowerShellImplementation ?? 'auto'
const showWindowsPowerShellImplementation = isWindows && windowsShell === 'powershell.exe'
const pickFloatingTerminalDirectory = async (): Promise<void> => {
const path = await window.api.repos.pickFolder()
if (!path) {
return
}
updateSettings({ floatingTerminalCwd: path })
}
const visibleSections = [
isWindows && matchesSettingsSearch(searchQuery, TERMINAL_WINDOWS_SHELL_SEARCH_ENTRY) ? (
@ -179,102 +167,6 @@ export function TerminalPane({
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_FLOATING_SEARCH_ENTRIES) ? (
<section key="floating-terminal" className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Floating Terminal</h3>
<p className="text-xs text-muted-foreground">
Global floating terminal tabs outside any repo or worktree.
</p>
</div>
<SearchableSetting
title="Floating Terminal"
description="Enable the global floating terminal and choose where new tabs start."
keywords={['terminal', 'global', 'floating', 'quick terminal', 'launch directory']}
className="space-y-3"
>
<div className="flex items-center justify-between gap-4 px-1 py-2">
<div className="space-y-0.5">
<Label>Enable Floating Terminal</Label>
<p className="text-xs text-muted-foreground">
Shows the global terminal button and floating terminal panel.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={settings.floatingTerminalEnabled}
onClick={() =>
updateSettings({
floatingTerminalEnabled: !settings.floatingTerminalEnabled
})
}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
settings.floatingTerminalEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
settings.floatingTerminalEnabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
<div className="space-y-2">
<Label>Default Directory</Label>
<div className="flex max-w-xl gap-2">
<Input
value={settings.floatingTerminalCwd || '~'}
onChange={(event) =>
updateSettings({
floatingTerminalCwd: event.target.value
})
}
placeholder="~"
className="min-w-0 flex-1"
/>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Choose floating terminal directory"
onClick={() => void pickFloatingTerminalDirectory()}
>
<FolderOpen className="size-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Takes effect for new Floating Terminal tabs. Use ~ for your home directory.
</p>
</div>
<div className="space-y-2">
<Label>Toggle Button Location</Label>
<ToggleGroup
type="single"
value={settings.floatingTerminalTriggerLocation ?? 'floating-button'}
onValueChange={(value) => {
if (!value) {
return
}
updateSettings({
floatingTerminalTriggerLocation: value as FloatingTerminalTriggerLocation
})
}}
className="justify-start"
>
<ToggleGroupItem value="floating-button">Floating Button</ToggleGroupItem>
<ToggleGroupItem value="status-bar">Status Bar</ToggleGroupItem>
</ToggleGroup>
<p className="text-xs text-muted-foreground">
The keyboard shortcut works regardless of where the toggle is shown.
</p>
</div>
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES) ? (
<section key="quick-commands" className="space-y-4">
<div className="space-y-1">

View File

@ -0,0 +1,24 @@
import type { SettingsSearchEntry } from './settings-search'
export const FLOATING_WORKSPACE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Floating Workspace',
description:
'Enable the floating workspace, choose where new tabs start, and choose where the toggle button appears.',
keywords: [
'floating workspace',
'floating terminal',
'quick terminal',
'global',
'terminal',
'browser',
'markdown',
'note',
'notes',
'quick panel',
'launch directory',
'toggle button',
'status bar'
]
}
]

View File

@ -78,23 +78,6 @@ export const TERMINAL_CURSOR_SEARCH_ENTRIES: SettingsSearchEntry[] = [
}
]
export const TERMINAL_FLOATING_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Floating Terminal',
description:
'Enable the global floating terminal, choose where new tabs start, and choose where the toggle button appears.',
keywords: [
'terminal',
'global',
'floating',
'quick terminal',
'launch directory',
'toggle button',
'status bar'
]
}
]
export const TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Quick Commands',
@ -300,7 +283,6 @@ export function getTerminalPaneSearchEntries(platform: {
// users from landing on an option the UI intentionally hides.
return [
...TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES,
...TERMINAL_FLOATING_SEARCH_ENTRIES,
...TERMINAL_QUICK_COMMANDS_SEARCH_ENTRIES,
...TERMINAL_RENDERING_SEARCH_ENTRIES,
...TERMINAL_CURSOR_SEARCH_ENTRIES,

View File

@ -6,9 +6,9 @@ import {
Activity,
ChevronDown,
ChevronRight,
PanelsTopLeft,
RefreshCw,
Server,
TerminalSquare
Server
} from 'lucide-react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@ -829,7 +829,9 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
const compact = containerWidth < 900
const iconOnly = containerWidth < 500
const floatingTerminalActionLabel = floatingTerminalOpen ? 'Minimize Terminal' : 'Show Terminal'
const floatingTerminalActionLabel = floatingTerminalOpen
? 'Minimize Floating Workspace'
: 'Show Floating Workspace'
return (
<div
@ -912,7 +914,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT))
}}
>
<TerminalSquare className="size-3.5" />
<PanelsTopLeft className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>

View File

@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: editor tab rendering, drag behavior, rename handling, and its context menu share one tightly-coupled tab surface. */
import { useEffect, useRef, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable'
import {
@ -379,13 +380,16 @@ export default function EditorFileTab({
<DropdownMenuItem
onSelect={() => {
onActivate()
openMarkdownPreview({
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
language: resolvedLanguage
})
openMarkdownPreview(
{
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
language: resolvedLanguage
},
{ sourceFileId: file.id }
)
}}
>
Open Markdown Preview

View File

@ -5,7 +5,7 @@
* more clarity than the ~5 lines of bloat is worth. */
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { SortableContext } from '@dnd-kit/sortable'
import { FilePlus, Globe, Plus, TerminalSquare } from 'lucide-react'
import { FilePlus, FileText, Globe, Plus, TerminalSquare } from 'lucide-react'
import type {
BrowserTab as BrowserTabState,
TerminalTab,
@ -59,6 +59,7 @@ type TabBarProps = {
terminalOnly?: boolean
showAgentLaunchItems?: boolean
onNewFileTab?: () => void
onOpenFileTab?: () => void
onSetCustomTitle: (tabId: string, title: string | null) => void
onSetTabColor: (tabId: string, color: string | null) => void
onTogglePaneExpand: (tabId: string) => void
@ -123,6 +124,7 @@ function TabBarInner({
terminalOnly = false,
showAgentLaunchItems = true,
onNewFileTab,
onOpenFileTab,
onSetCustomTitle,
onSetTabColor,
onTogglePaneExpand,
@ -558,6 +560,15 @@ function TabBarInner({
<DropdownMenuShortcut>{NEW_FILE_SHORTCUT}</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{!terminalOnly && onOpenFileTab && (
<DropdownMenuItem
onSelect={onOpenFileTab}
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
>
<FileText className="size-4 text-muted-foreground" />
Open Markdown...
</DropdownMenuItem>
)}
{showAgentLaunchItems ? (
<>
<DropdownMenuSeparator />

View File

@ -162,7 +162,7 @@ export function openDetectedFilePath(
worktreeId: worktreeId || '',
language: detectLanguage(filePath),
mode: 'edit',
runtimeEnvironmentId: runtimeEnvironmentId ?? undefined
runtimeEnvironmentId
})
if (line !== null) {

View File

@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useAppStore } from '@/store'
import { remapOpenEditorTabsForPathChange } from './remap-open-editor-tabs-for-path-change'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
function ownedEditorFileId(
filePath: string,
worktreeId: string,
runtimeEnvironmentId: string | null | undefined
): string {
const runtimeKey = runtimeEnvironmentId?.trim() || 'local'
return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}`
}
describe('remapOpenEditorTabsForPathChange', () => {
beforeEach(() => {
useAppStore.setState(useAppStore.getInitialState(), true)
})
it('preserves runtime owners, drafts, dirty state, and markdown preview sources', () => {
const state = useAppStore.getState()
const worktreeId = 'wt-1'
const worktreePath = '/repo'
const oldPath = '/repo/docs/readme.md'
const newPath = '/repo/notes/readme.md'
useAppStore.setState({
settings: { activeRuntimeEnvironmentId: 'env-active' } as NonNullable<
ReturnType<typeof useAppStore.getState>['settings']
>
})
state.openFile(
{
filePath: oldPath,
relativePath: 'docs/readme.md',
worktreeId,
runtimeEnvironmentId: null,
language: 'markdown',
mode: 'edit'
},
{ suppressActiveRuntimeFallback: true }
)
const localEditId = useAppStore.getState().openFiles[0]?.id
expect(localEditId).toBeTruthy()
state.setEditorDraft(localEditId!, 'local draft')
state.markFileDirty(localEditId!, true)
state.openFile({
filePath: oldPath,
relativePath: 'docs/readme.md',
worktreeId,
runtimeEnvironmentId: 'env-remote',
language: 'markdown',
mode: 'edit'
})
const remoteEdit = useAppStore
.getState()
.openFiles.find((file) => file.mode === 'edit' && file.runtimeEnvironmentId === 'env-remote')
expect(remoteEdit).toBeTruthy()
state.setEditorDraft(remoteEdit!.id, 'remote draft')
state.markFileDirty(remoteEdit!.id, true)
state.openMarkdownPreview(
{
filePath: oldPath,
relativePath: 'docs/readme.md',
worktreeId,
runtimeEnvironmentId: 'env-remote',
language: 'markdown'
},
{ anchor: 'heading', sourceFileId: remoteEdit!.id }
)
remapOpenEditorTabsForPathChange({
fromPath: '/repo/docs',
toPath: '/repo/notes',
worktreePath,
worktreeId
})
const nextState = useAppStore.getState()
expect(nextState.openFiles.some((file) => file.filePath === oldPath)).toBe(false)
const localRemapped = nextState.openFiles.find(
(file) =>
file.filePath === newPath && file.mode === 'edit' && file.runtimeEnvironmentId === null
)
const remoteRemapped = nextState.openFiles.find(
(file) =>
file.filePath === newPath &&
file.mode === 'edit' &&
file.runtimeEnvironmentId === 'env-remote'
)
expect(localRemapped).toMatchObject({
relativePath: 'notes/readme.md',
isDirty: true,
runtimeEnvironmentId: null
})
expect(remoteRemapped).toMatchObject({
relativePath: 'notes/readme.md',
isDirty: true,
runtimeEnvironmentId: 'env-remote'
})
expect(nextState.editorDrafts[localRemapped!.id]).toBe('local draft')
expect(nextState.editorDrafts[remoteRemapped!.id]).toBe('remote draft')
expect(nextState.editorDrafts[localEditId!]).toBeUndefined()
expect(nextState.editorDrafts[remoteEdit!.id]).toBeUndefined()
const remotePreview = nextState.openFiles.find(
(file) => file.mode === 'markdown-preview' && file.runtimeEnvironmentId === 'env-remote'
)
expect(remotePreview).toMatchObject({
filePath: newPath,
relativePath: 'notes/readme.md',
markdownPreviewAnchor: 'heading',
markdownPreviewSourceFileId: remoteRemapped!.id
})
})
it('retargets preview-only markdown source ids to the moved owner path', () => {
const state = useAppStore.getState()
const worktreePath = '/repo'
const oldPath = '/repo/docs/readme.md'
const newPath = '/repo/notes/readme.md'
const floatingOldSourceId = ownedEditorFileId(oldPath, FLOATING_TERMINAL_WORKTREE_ID, null)
const floatingNewSourceId = ownedEditorFileId(newPath, FLOATING_TERMINAL_WORKTREE_ID, null)
state.openMarkdownPreview({
filePath: oldPath,
relativePath: 'docs/readme.md',
worktreeId: 'wt-1',
language: 'markdown'
})
state.openMarkdownPreview({
filePath: oldPath,
relativePath: 'readme.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
runtimeEnvironmentId: null,
language: 'markdown'
})
expect(
useAppStore
.getState()
.openFiles.find((file) => file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID)
?.markdownPreviewSourceFileId
).toBe(floatingOldSourceId)
remapOpenEditorTabsForPathChange({
fromPath: '/repo/docs',
toPath: '/repo/notes',
worktreePath,
worktreeId: 'wt-1'
})
const floatingPreview = useAppStore
.getState()
.openFiles.find((file) => file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID)
expect(floatingPreview).toMatchObject({
id: `markdown-preview::${floatingNewSourceId}`,
filePath: newPath,
relativePath: '../notes/readme.md',
markdownPreviewSourceFileId: floatingNewSourceId
})
})
})

View File

@ -0,0 +1,219 @@
import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import { basename } from '@/lib/path'
import {
normalizeRuntimePathSeparators,
relativePathInsideRoot
} from '../../../shared/cross-platform-path'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
function isPathInsideOrEqual(rootPath: string, candidatePath: string): boolean {
if (candidatePath === rootPath) {
return true
}
return candidatePath.startsWith(`${rootPath}/`) || candidatePath.startsWith(`${rootPath}\\`)
}
function isAbsolutePathLike(path: string): boolean {
return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\')
}
function stripTrailingSeparators(path: string): string {
if (path === '/' || /^[A-Za-z]:[\\/]?$/.test(path)) {
return normalizeRuntimePathSeparators(path)
}
return normalizeRuntimePathSeparators(path).replace(/\/+$/, '')
}
function deriveRelativeRootFromOpenFile(filePath: string, relativePath: string): string {
const normalizedFilePath = stripTrailingSeparators(filePath)
const normalizedRelativePath = normalizeRuntimePathSeparators(relativePath).replace(/^\/+/, '')
if (!normalizedRelativePath || isAbsolutePathLike(relativePath)) {
const separatorIndex = normalizedFilePath.lastIndexOf('/')
return separatorIndex <= 0 ? '/' : normalizedFilePath.slice(0, separatorIndex)
}
const suffix = `/${normalizedRelativePath}`
if (normalizedFilePath.endsWith(suffix)) {
return stripTrailingSeparators(normalizedFilePath.slice(0, -suffix.length) || '/')
}
const base = basename(normalizedFilePath)
if (base && normalizedRelativePath === base) {
const separatorIndex = normalizedFilePath.lastIndexOf('/')
return separatorIndex <= 0 ? '/' : normalizedFilePath.slice(0, separatorIndex)
}
const separatorIndex = normalizedFilePath.lastIndexOf('/')
return separatorIndex <= 0 ? '/' : normalizedFilePath.slice(0, separatorIndex)
}
function splitAbsolutePath(path: string): { prefix: string; segments: string[] } {
const normalized = stripTrailingSeparators(path)
const driveMatch = /^([A-Za-z]:)(?:\/(.*))?$/.exec(normalized)
if (driveMatch) {
return {
prefix: driveMatch[1].toLowerCase(),
segments: (driveMatch[2] ?? '').split('/').filter(Boolean)
}
}
if (normalized.startsWith('//')) {
const segments = normalized.slice(2).split('/').filter(Boolean)
return {
prefix: `//${segments.slice(0, 2).join('/').toLowerCase()}`,
segments: segments.slice(2)
}
}
if (normalized.startsWith('/')) {
return { prefix: '/', segments: normalized.slice(1).split('/').filter(Boolean) }
}
return { prefix: '', segments: normalized.split('/').filter(Boolean) }
}
function getRelativePathFromRoot(rootPath: string, candidatePath: string): string {
const insideRoot = relativePathInsideRoot(rootPath, candidatePath)
if (insideRoot !== null) {
return insideRoot
}
const root = splitAbsolutePath(rootPath)
const candidate = splitAbsolutePath(candidatePath)
if (root.prefix !== candidate.prefix) {
return normalizeRuntimePathSeparators(candidatePath)
}
let commonSegmentCount = 0
while (
commonSegmentCount < root.segments.length &&
commonSegmentCount < candidate.segments.length &&
root.segments[commonSegmentCount] === candidate.segments[commonSegmentCount]
) {
commonSegmentCount += 1
}
return [
...Array.from({ length: root.segments.length - commonSegmentCount }, () => '..'),
...candidate.segments.slice(commonSegmentCount)
].join('/')
}
function getUpdatedRelativePath({
filePath,
relativePath,
worktreeId,
updatedPath,
initiatingWorktreeId,
initiatingWorktreePath
}: {
filePath: string
relativePath: string
worktreeId: string
updatedPath: string
initiatingWorktreeId: string | undefined
initiatingWorktreePath: string
}): string {
const worktreeRelative = relativePathInsideRoot(initiatingWorktreePath, filePath)
const normalizedRelativePath = normalizeRuntimePathSeparators(relativePath).replace(/^\/+/, '')
const usesInitiatingWorktreeRoot =
initiatingWorktreeId !== undefined
? worktreeId === initiatingWorktreeId
: worktreeId !== FLOATING_TERMINAL_WORKTREE_ID &&
worktreeRelative !== null &&
normalizeRuntimePathSeparators(worktreeRelative) === normalizedRelativePath
const relativeRoot = usesInitiatingWorktreeRoot
? initiatingWorktreePath
: deriveRelativeRootFromOpenFile(filePath, relativePath)
return getRelativePathFromRoot(relativeRoot, updatedPath)
}
export function remapOpenEditorTabsForPathChange({
fromPath,
toPath,
worktreePath,
worktreeId
}: {
fromPath: string
toPath: string
worktreePath: string
worktreeId?: string
}): void {
const state = useAppStore.getState()
const filesToMove = state.openFiles.filter((file) => isPathInsideOrEqual(fromPath, file.filePath))
// Why: preview tabs refer to edit tab ids as their source, so edits must be
// remapped first before reopening markdown previews with updated source ids.
const remappedFileIds = new Map<string, string>()
const orderedFilesToMove = [...filesToMove].sort(
(a, b) => Number(a.mode === 'markdown-preview') - Number(b.mode === 'markdown-preview')
)
for (const file of orderedFilesToMove) {
const oldFilePath = file.filePath
const suffix = oldFilePath.slice(fromPath.length)
const updatedPath = toPath + suffix
const updatedRelative = getUpdatedRelativePath({
filePath: oldFilePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
updatedPath,
initiatingWorktreeId: worktreeId,
initiatingWorktreePath: worktreePath
})
const draft = state.editorDrafts[file.id]
const wasDirty = file.isDirty
// Why: preview tabs use synthetic ids (`markdown-preview::...`) instead of
// filePath, so close the real tab id before reopening at the new path.
state.closeFile(file.id)
if (file.mode === 'edit') {
state.openFile(
{
filePath: updatedPath,
relativePath: updatedRelative,
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
language: detectLanguage(basename(updatedPath)),
mode: 'edit'
},
{ suppressActiveRuntimeFallback: file.runtimeEnvironmentId === null }
)
} else if (file.mode === 'markdown-preview') {
const remappedSourceFileId = file.markdownPreviewSourceFileId
? remappedFileIds.get(file.markdownPreviewSourceFileId)
: undefined
state.openMarkdownPreview(
{
filePath: updatedPath,
relativePath: updatedRelative,
worktreeId: file.worktreeId,
runtimeEnvironmentId: file.runtimeEnvironmentId,
language: 'markdown'
},
{
anchor: file.markdownPreviewAnchor ?? null,
// Why: preview-only tabs may point at an owner-qualified source id
// whose edit tab is not open. Let the store resolve that id for the
// renamed path instead of preserving the old path in the preview id.
sourceFileId: remappedSourceFileId
}
)
} else {
continue
}
const freshState = useAppStore.getState()
const reopenedFile = freshState.openFiles.find(
(entry) =>
entry.filePath === updatedPath &&
entry.worktreeId === file.worktreeId &&
entry.mode === file.mode &&
(entry.runtimeEnvironmentId ?? null) === (file.runtimeEnvironmentId ?? null)
)
const reopenedFileId = reopenedFile?.id ?? updatedPath
remappedFileIds.set(file.id, reopenedFileId)
if (draft !== undefined) {
freshState.setEditorDraft(reopenedFileId, draft)
}
if (wasDirty) {
freshState.markFileDirty(reopenedFileId, true)
}
}
}

View File

@ -1,11 +1,11 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import { basename, dirname, joinPath } from '@/lib/path'
import { getConnectionId } from '@/lib/connection-context'
import { requestEditorSaveQuiesce } from '@/components/editor/editor-autosave'
import { commitFileExplorerOp } from '@/components/right-sidebar/fileExplorerUndoRedo'
import { renameRuntimePath } from '@/runtime/runtime-file-client'
import { remapOpenEditorTabsForPathChange } from '@/lib/remap-open-editor-tabs-for-path-change'
/**
* Electron's ipcRenderer.invoke wraps errors as:
@ -20,64 +20,6 @@ export function extractIpcErrorMessage(err: unknown, fallback: string): string {
return match ? match[1] : err.message
}
/**
* Walk every open file whose path is `fromPath` or a descendant of it
* and rehome it to `toPath`. Closes and re-opens each tab to preserve
* drafts and dirty state under the new path. Directory renames remap
* all descendants, which is why we check both `/` and `\` separators.
*/
function remapOpenTabsForRenamedPath(fromPath: string, toPath: string, worktreePath: string): void {
const state = useAppStore.getState()
const filesToMove = state.openFiles.filter((file) => {
if (file.filePath === fromPath) {
return true
}
return file.filePath.startsWith(`${fromPath}/`) || file.filePath.startsWith(`${fromPath}\\`)
})
for (const file of filesToMove) {
const oldFilePath = file.filePath
const suffix = oldFilePath.slice(fromPath.length)
const updatedPath = toPath + suffix
const updatedRelative = updatedPath.slice(worktreePath.length + 1)
const draft = state.editorDrafts[file.id]
const wasDirty = file.isDirty
// Why: preview tabs use a synthetic tab id (`markdown-preview::...`) that
// does not equal filePath. Closing by the real tab id keeps rename/move
// remaps correct for both editable and read-only markdown preview tabs.
state.closeFile(file.id)
if (file.mode === 'edit') {
state.openFile({
filePath: updatedPath,
relativePath: updatedRelative,
worktreeId: file.worktreeId,
language: detectLanguage(basename(updatedPath)),
mode: 'edit'
})
} else if (file.mode === 'markdown-preview') {
state.openMarkdownPreview(
{
filePath: updatedPath,
relativePath: updatedRelative,
worktreeId: file.worktreeId,
language: 'markdown'
},
{ anchor: file.markdownPreviewAnchor ?? null }
)
} else {
continue
}
if (draft !== undefined) {
state.setEditorDraft(updatedPath, draft)
}
if (wasDirty) {
state.markFileDirty(updatedPath, true)
}
}
}
type RenameFileArgs = {
oldPath: string
/** just the new filename (no directory) */
@ -134,21 +76,21 @@ export async function renameFileOnDisk(args: RenameFileArgs): Promise<void> {
try {
await renameRuntimePath(fileContext, oldPath, newPath)
remapOpenTabsForRenamedPath(oldPath, newPath, worktreePath)
remapOpenEditorTabsForPathChange({ fromPath: oldPath, toPath: newPath, worktreePath })
commitFileExplorerOp({
undo: async () => {
await renameRuntimePath(fileContext, newPath, oldPath)
if (refreshDir) {
await refreshDir(parentDir)
}
remapOpenTabsForRenamedPath(newPath, oldPath, worktreePath)
remapOpenEditorTabsForPathChange({ fromPath: newPath, toPath: oldPath, worktreePath })
},
redo: async () => {
await renameRuntimePath(fileContext, oldPath, newPath)
if (refreshDir) {
await refreshDir(parentDir)
}
remapOpenTabsForRenamedPath(oldPath, newPath, worktreePath)
remapOpenEditorTabsForPathChange({ fromPath: oldPath, toPath: newPath, worktreePath })
}
})
} catch (err) {

View File

@ -33,6 +33,9 @@ export function settingsForRuntimeOwner(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
runtimeEnvironmentId: string | null | undefined
): Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined {
if (runtimeEnvironmentId === null) {
return { activeRuntimeEnvironmentId: null }
}
const ownerId = runtimeEnvironmentId?.trim()
return ownerId ? { activeRuntimeEnvironmentId: ownerId } : settings
}

View File

@ -1288,6 +1288,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
.flat()
.map((worktree) => worktree.id)
)
validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID)
// Why: mirror closeBrowserTab's contract — reducers are pure, imperative
// side effects bracket them. Compute dropped workspaces first, destroy
@ -1316,6 +1317,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
.flat()
.map((worktree) => worktree.id)
)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
const browserTabsByWorktree: Record<string, BrowserWorkspace[]> = {}
const browserPagesByWorkspace: Record<string, BrowserPage[]> = {}

View File

@ -3,6 +3,7 @@
import { createStore, type StoreApi } from 'zustand/vanilla'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { createEditorSlice } from './editor'
import { createTabsSlice } from './tabs'
import type { AppState } from '../types'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
@ -37,6 +38,28 @@ function createEditorStore(): StoreApi<AppState> {
})) as unknown as StoreApi<AppState>
}
function createEditorTabsStore(): StoreApi<AppState> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return createStore<any>()((...args: any[]) => ({
activeWorktreeId: 'wt-1',
tabsByWorktree: {},
browserTabsByWorktree: {},
activeBrowserTabId: null,
activeBrowserTabIdByWorktree: {},
...createTabsSlice(...(args as Parameters<typeof createTabsSlice>)),
...createEditorSlice(...(args as Parameters<typeof createEditorSlice>))
})) as unknown as StoreApi<AppState>
}
function ownedEditorFileId(
filePath: string,
worktreeId: string,
runtimeEnvironmentId: string | null | undefined
): string {
const runtimeKey = runtimeEnvironmentId?.trim() || 'local'
return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}`
}
describe('createEditorSlice right sidebar state', () => {
it('right sidebar is closed by default', () => {
const store = createEditorStore()
@ -253,6 +276,37 @@ describe('createEditorSlice openDiff', () => {
})
describe('createEditorSlice floating editor activation', () => {
it('creates a visible floating editor tab when the floating workspace is empty', () => {
const store = createEditorTabsStore()
store.getState().openFile(
{
filePath: '/tmp/orca/notes.md',
relativePath: 'notes.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
runtimeEnvironmentId: null,
language: 'markdown',
mode: 'edit'
},
{ suppressActiveRuntimeFallback: true }
)
const tab = store.getState().unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.[0]
expect(tab).toMatchObject({
contentType: 'editor',
entityId: '/tmp/orca/notes.md',
label: 'notes.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID
})
expect(store.getState().groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.[0]).toMatchObject({
activeTabId: tab?.id,
tabOrder: [tab?.id]
})
expect(store.getState().activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(
'/tmp/orca/notes.md'
)
})
it('opens floating markdown tabs without changing the main active editor surface', () => {
const store = createEditorStore()
store.setState({
@ -278,6 +332,52 @@ describe('createEditorSlice floating editor activation', () => {
)
expect(store.getState().activeTabTypeByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe('editor')
})
it('opens same-path floating markdown as a separate owner-qualified tab', () => {
const store = createEditorStore()
store.setState({
openFiles: [
{
id: '/repo/README.md',
filePath: '/repo/README.md',
relativePath: 'README.md',
worktreeId: 'wt-1',
language: 'markdown',
isDirty: false,
mode: 'edit'
}
],
activeFileIdByWorktree: { 'wt-1': '/repo/README.md' },
activeTabTypeByWorktree: { 'wt-1': 'editor' }
} as Partial<AppState>)
store.getState().openFile(
{
filePath: '/repo/README.md',
relativePath: 'README.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
runtimeEnvironmentId: null,
language: 'markdown',
mode: 'edit'
},
{ suppressActiveRuntimeFallback: true }
)
expect(store.getState().openFiles).toHaveLength(2)
expect(store.getState().openFiles[0]).toMatchObject({
filePath: '/repo/README.md',
worktreeId: 'wt-1'
})
expect(store.getState().openFiles[1]).toMatchObject({
filePath: '/repo/README.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
runtimeEnvironmentId: null
})
expect(store.getState().openFiles[1]?.id).not.toBe('/repo/README.md')
expect(store.getState().activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(
store.getState().openFiles[1]?.id
)
})
})
describe('createEditorSlice untitled cleanup routing', () => {
@ -644,6 +744,135 @@ describe('createEditorSlice openMarkdownPreview', () => {
})
])
})
it('keeps preview-only same-path markdown previews separate by owner', () => {
const store = createEditorStore()
const floatingSourceId = ownedEditorFileId(
'/repo/docs/README.md',
FLOATING_TERMINAL_WORKTREE_ID,
null
)
store.getState().openMarkdownPreview({
filePath: '/repo/docs/README.md',
relativePath: 'docs/README.md',
worktreeId: 'wt-1',
language: 'markdown'
})
store.getState().openMarkdownPreview({
filePath: '/repo/docs/README.md',
relativePath: 'README.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
runtimeEnvironmentId: null,
language: 'markdown'
})
const previews = store.getState().openFiles.filter((file) => file.mode === 'markdown-preview')
expect(previews).toEqual([
expect.objectContaining({
id: 'markdown-preview::/repo/docs/README.md',
markdownPreviewSourceFileId: '/repo/docs/README.md',
worktreeId: 'wt-1'
}),
expect.objectContaining({
id: `markdown-preview::${floatingSourceId}`,
markdownPreviewSourceFileId: floatingSourceId,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID
})
])
})
it('keeps same-path markdown previews separate by source owner', () => {
const store = createEditorStore()
store.getState().openFile({
filePath: '/repo/docs/README.md',
relativePath: 'docs/README.md',
worktreeId: 'wt-1',
language: 'markdown',
mode: 'edit'
})
store.getState().openFile(
{
filePath: '/repo/docs/README.md',
relativePath: 'README.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
runtimeEnvironmentId: null,
language: 'markdown',
mode: 'edit'
},
{ suppressActiveRuntimeFallback: true }
)
const floatingFile = store
.getState()
.openFiles.find((file) => file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID)
expect(floatingFile).toBeDefined()
store.getState().openMarkdownPreview({
filePath: '/repo/docs/README.md',
relativePath: 'docs/README.md',
worktreeId: 'wt-1',
language: 'markdown'
})
store.getState().openMarkdownPreview(
{
filePath: '/repo/docs/README.md',
relativePath: 'README.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
runtimeEnvironmentId: null,
language: 'markdown'
},
{ sourceFileId: floatingFile?.id }
)
const previews = store.getState().openFiles.filter((file) => file.mode === 'markdown-preview')
expect(previews).toHaveLength(2)
expect(previews.map((file) => file.markdownPreviewSourceFileId)).toEqual([
'/repo/docs/README.md',
floatingFile?.id
])
})
it('uses the resolved active runtime owner when opening markdown previews', () => {
const store = createEditorStore()
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-active' } as AppState['settings'],
openFiles: [
{
id: '/repo/docs/README.md',
filePath: '/repo/docs/README.md',
relativePath: 'docs/README.md',
worktreeId: 'wt-1',
language: 'markdown',
isDirty: false,
mode: 'edit'
},
{
id: 'editor:wt-1:env-active:readme',
filePath: '/repo/docs/README.md',
relativePath: 'docs/README.md',
worktreeId: 'wt-1',
runtimeEnvironmentId: 'env-active',
language: 'markdown',
isDirty: false,
mode: 'edit'
}
]
} as Partial<AppState>)
store.getState().openMarkdownPreview({
filePath: '/repo/docs/README.md',
relativePath: 'docs/README.md',
worktreeId: 'wt-1',
language: 'markdown'
})
expect(store.getState().openFiles.at(-1)).toMatchObject({
mode: 'markdown-preview',
runtimeEnvironmentId: 'env-active',
markdownPreviewSourceFileId: 'editor:wt-1:env-active:readme'
})
})
})
describe('createEditorSlice pending editor reveal', () => {
@ -1723,7 +1952,7 @@ describe('createEditorSlice activateMarkdownLink', () => {
expect(toastErrorMock).toHaveBeenCalledWith('Cannot open directory: docs/guide.md')
})
it('can open a file without adopting the currently active runtime owner', () => {
it('can open a local file without adopting the currently active runtime owner', () => {
const store = createEditorStore()
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-active' } as AppState['settings']
@ -1743,7 +1972,7 @@ describe('createEditorSlice activateMarkdownLink', () => {
expect(store.getState().openFiles[0]).toMatchObject({
filePath: '/remote/.orca/drops/log.txt'
})
expect(store.getState().openFiles[0]?.runtimeEnvironmentId).toBeUndefined()
expect(store.getState().openFiles[0]?.runtimeEnvironmentId).toBeNull()
})
it('toasts when the markdown target is missing', async () => {
@ -1761,7 +1990,7 @@ describe('createEditorSlice activateMarkdownLink', () => {
expect(openFileUriMock).not.toHaveBeenCalled()
})
it('sets source view mode before opening when the link has a line anchor', async () => {
it('sets source view mode when the link has a line anchor', async () => {
const store = createEditorStore()
pathExistsMock.mockResolvedValue(true)
@ -1774,12 +2003,95 @@ describe('createEditorSlice activateMarkdownLink', () => {
expect(store.getState().markdownViewMode['/repo/docs/guide.md']).toBe('source')
expect(store.getState().pendingEditorReveal).toEqual({
filePath: '/repo/docs/guide.md',
fileId: '/repo/docs/guide.md',
line: 10,
column: 1,
matchLength: 0
})
})
it('reveals active-runtime markdown line anchors on the owner-qualified tab id', async () => {
const store = createEditorStore()
pathExistsMock.mockResolvedValue(true)
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-active' } as AppState['settings'],
openFiles: [
{
id: '/repo/docs/guide.md',
filePath: '/repo/docs/guide.md',
relativePath: 'docs/guide.md',
worktreeId: 'wt-1',
runtimeEnvironmentId: null,
language: 'markdown',
isDirty: false,
mode: 'edit'
}
]
} as Partial<AppState>)
const activeRuntimeFileId = ownedEditorFileId('/repo/docs/guide.md', 'wt-1', 'env-active')
await store.getState().activateMarkdownLink('./guide.md#L10', {
sourceFilePath: '/repo/docs/note.md',
worktreeId: 'wt-1',
worktreeRoot: '/repo'
})
expect(store.getState().markdownViewMode[activeRuntimeFileId]).toBe('source')
expect(store.getState().markdownViewMode['/repo/docs/guide.md']).toBeUndefined()
expect(store.getState().pendingEditorReveal).toEqual({
filePath: '/repo/docs/guide.md',
fileId: activeRuntimeFileId,
line: 10,
column: 1,
matchLength: 0
})
})
it('sets line-anchor source mode on the owner-qualified target id', async () => {
const store = createEditorStore()
pathExistsMock.mockResolvedValue(true)
store.getState().openFile({
filePath: '/repo/docs/note.md',
relativePath: 'docs/note.md',
worktreeId: 'wt-1',
language: 'markdown',
mode: 'edit'
})
store.getState().openFile(
{
filePath: '/repo/docs/note.md',
relativePath: 'docs/note.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
runtimeEnvironmentId: null,
language: 'markdown',
mode: 'edit'
},
{ suppressActiveRuntimeFallback: true }
)
const floatingFileId = ownedEditorFileId(
'/repo/docs/note.md',
FLOATING_TERMINAL_WORKTREE_ID,
null
)
await store.getState().activateMarkdownLink('./note.md#L3', {
sourceFilePath: '/repo/docs/note.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
worktreeRoot: '/repo',
runtimeEnvironmentId: null
})
expect(store.getState().markdownViewMode[floatingFileId]).toBe('source')
expect(store.getState().markdownViewMode['/repo/docs/note.md']).toBeUndefined()
expect(store.getState().pendingEditorReveal).toEqual({
filePath: '/repo/docs/note.md',
fileId: floatingFileId,
line: 3,
column: 1,
matchLength: 0
})
})
it('delegates external links to openHttpLink with the ctx worktreeId', async () => {
const store = createEditorStore()
await store.getState().activateMarkdownLink('https://example.com', {
@ -1828,6 +2140,7 @@ describe('createEditorSlice activateMarkdownLink', () => {
])
expect(store.getState().pendingEditorReveal).toEqual({
filePath: '/repo/src/PdfViewer.tsx',
fileId: '/repo/src/PdfViewer.tsx',
line: 142,
column: 7,
matchLength: 0

View File

@ -19,6 +19,9 @@ import type {
GitPushTarget,
GitStatusEntry,
GitStatusResult,
PersistedOpenFile,
Tab,
TabGroup,
GitUpstreamStatus,
SearchResult,
WorkspaceSessionState,
@ -135,7 +138,7 @@ export type OpenFile = {
isDirty: boolean
// Why: remote untitled cleanup must target the environment that created the
// file, even if the user switches to Local or another runtime before closing.
runtimeEnvironmentId?: string
runtimeEnvironmentId?: string | null
/** Why: markdown preview tabs are separate editor tabs that mirror a source
* markdown file's live draft. Storing the source file ID lets the preview
* follow unsaved edits from the normal editor without becoming editable
@ -188,11 +191,20 @@ export type ClosedEditorTabSnapshot = Omit<OpenFile, 'id' | 'isDirty'>
const MAX_RECENT_CLOSED_EDITOR_TABS = 10
export type PendingEditorReveal = {
filePath: string
fileId?: string
line: number
column: number
matchLength: number
}
function scheduleEditorLineReveal(
get: () => AppState,
filePath: string,
line: number,
column?: number
column?: number,
fileId?: string
): void {
// Why: openFile can replace a preview and remount Monaco asynchronously; the
// reveal must land after that remount or the old editor can clear it.
@ -201,6 +213,7 @@ function scheduleEditorLineReveal(
requestAnimationFrame(() => {
get().setPendingEditorReveal({
filePath,
fileId,
line,
column: column ?? 1,
matchLength: 0
@ -289,7 +302,7 @@ export type EditorSlice = {
OpenFile,
'filePath' | 'relativePath' | 'worktreeId' | 'language' | 'runtimeEnvironmentId'
>,
options?: { anchor?: string | null; targetGroupId?: string }
options?: { anchor?: string | null; targetGroupId?: string; sourceFileId?: string }
) => void
pinFile: (fileId: string, tabId?: string) => void
closeFile: (fileId: string) => void
@ -451,15 +464,8 @@ export type EditorSlice = {
clearFileSearch: (worktreeId: string) => void
// Editor navigation (for search result → go-to-line)
pendingEditorReveal: {
filePath: string
line: number
column: number
matchLength: number
} | null
setPendingEditorReveal: (
reveal: { filePath: string; line: number; column: number; matchLength: number } | null
) => void
pendingEditorReveal: PendingEditorReveal | null
setPendingEditorReveal: (reveal: PendingEditorReveal | null) => void
// Session hydration — restore editor files from persisted workspace session
hydrateEditorSession: (session: WorkspaceSessionState) => void
@ -478,19 +484,23 @@ function openWorkspaceEditorItem(
targetGroupId ??
state.activeGroupIdByWorktree?.[worktreeId] ??
state.groupsByWorktree?.[worktreeId]?.[0]?.id
if (!resolvedGroupId) {
return fileId
}
const existing = state.findTabForEntityInGroup?.(worktreeId, resolvedGroupId, fileId, contentType)
if (existing) {
state.activateTab?.(existing.id)
return existing.id
if (resolvedGroupId) {
const existing = state.findTabForEntityInGroup?.(
worktreeId,
resolvedGroupId,
fileId,
contentType
)
if (existing) {
state.activateTab?.(existing.id)
return existing.id
}
}
const created = state.createUnifiedTab?.(worktreeId, contentType, {
entityId: fileId,
label,
isPreview,
targetGroupId: resolvedGroupId
...(resolvedGroupId ? { targetGroupId: resolvedGroupId } : {})
})
return created?.id ?? fileId
}
@ -517,6 +527,271 @@ function buildEditorActiveResult(
}
}
function runtimeOwnerKey(runtimeEnvironmentId: string | null | undefined): string | null {
return runtimeEnvironmentId?.trim() || null
}
function isSameEditorOwner(
file: Pick<OpenFile, 'worktreeId' | 'runtimeEnvironmentId'>,
worktreeId: string,
runtimeEnvironmentId: string | null | undefined
): boolean {
return (
file.worktreeId === worktreeId &&
runtimeOwnerKey(file.runtimeEnvironmentId) === runtimeOwnerKey(runtimeEnvironmentId)
)
}
function buildOwnedEditorFileId(
filePath: string,
worktreeId: string,
runtimeEnvironmentId: string | null | undefined
): string {
const runtimeKey = runtimeOwnerKey(runtimeEnvironmentId) ?? 'local'
return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}`
}
function isEditorFileIdOccupiedByOtherOwner(
file: Pick<
OpenFile,
'id' | 'worktreeId' | 'runtimeEnvironmentId' | 'markdownPreviewSourceFileId'
>,
filePath: string,
worktreeId: string,
runtimeEnvironmentId: string | null | undefined
): boolean {
if (isSameEditorOwner(file, worktreeId, runtimeEnvironmentId)) {
return false
}
return file.id === filePath || file.markdownPreviewSourceFileId === filePath
}
function matchesEditorMode(
file: OpenFile,
modes: readonly OpenFile['mode'][] | undefined
): boolean {
return !modes || modes.includes(file.mode)
}
function resolveEditorFileIdForOwner(
state: Pick<EditorSlice, 'openFiles'>,
filePath: string,
worktreeId: string,
runtimeEnvironmentId: string | null | undefined,
modes?: readonly OpenFile['mode'][]
): string {
const existing = state.openFiles.find(
(file) =>
file.filePath === filePath &&
matchesEditorMode(file, modes) &&
isSameEditorOwner(file, worktreeId, runtimeEnvironmentId)
)
if (existing) {
return existing.id
}
// Why: preview-only markdown tabs also reserve their source id. Treat those
// source ids like open editor ids so same-path owners do not collapse.
return state.openFiles.some((file) =>
isEditorFileIdOccupiedByOtherOwner(file, filePath, worktreeId, runtimeEnvironmentId)
)
? buildOwnedEditorFileId(filePath, worktreeId, runtimeEnvironmentId)
: filePath
}
function getOpenedEditFileIdAfterOpen(
state: Pick<EditorSlice, 'openFiles' | 'activeFileIdByWorktree'>,
filePath: string,
worktreeId: string
): string {
const activeFileId = state.activeFileIdByWorktree[worktreeId]
const activeFile = state.openFiles.find(
(file) =>
file.id === activeFileId &&
file.filePath === filePath &&
file.worktreeId === worktreeId &&
file.mode === 'edit'
)
if (activeFile) {
return activeFile.id
}
return (
state.openFiles.find(
(file) => file.filePath === filePath && file.worktreeId === worktreeId && file.mode === 'edit'
)?.id ?? filePath
)
}
function shouldHydrateWithOwnedEditorFileId(
worktreeId: string,
runtimeEnvironmentId: string | null | undefined
): boolean {
return (
worktreeId === FLOATING_TERMINAL_WORKTREE_ID || runtimeOwnerKey(runtimeEnvironmentId) !== null
)
}
function addEditorFileIdMigration(
migrationsByWorktree: Record<string, Map<string, string>>,
worktreeId: string,
from: string,
to: string
): void {
if (from === to) {
return
}
const migrations =
migrationsByWorktree[worktreeId] ?? (migrationsByWorktree[worktreeId] = new Map())
migrations.set(from, to)
}
type LegacyHydratedEditorFile = Pick<
OpenFile,
'id' | 'filePath' | 'worktreeId' | 'runtimeEnvironmentId' | 'markdownPreviewSourceFileId'
>
function resolveLegacyHydratedEditorFileId(
files: readonly LegacyHydratedEditorFile[],
persistedFile: PersistedOpenFile,
worktreeId: string
): string {
const existing = files.find(
(file) =>
file.filePath === persistedFile.filePath &&
isSameEditorOwner(file, worktreeId, persistedFile.runtimeEnvironmentId)
)
if (existing) {
return existing.id
}
return files.some((file) =>
isEditorFileIdOccupiedByOtherOwner(
file,
persistedFile.filePath,
worktreeId,
persistedFile.runtimeEnvironmentId
)
)
? buildOwnedEditorFileId(persistedFile.filePath, worktreeId, persistedFile.runtimeEnvironmentId)
: persistedFile.filePath
}
function migrateEditorFileId(
migrationsByWorktree: Record<string, Map<string, string>>,
worktreeId: string,
fileId: string | null | undefined
): string | null {
if (!fileId) {
return null
}
return migrationsByWorktree[worktreeId]?.get(fileId) ?? fileId
}
function dedupeEditorTabOrder(tabIds: string[], validTabIds: Set<string>): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const tabId of tabIds) {
if (!validTabIds.has(tabId) || seen.has(tabId)) {
continue
}
seen.add(tabId)
result.push(tabId)
}
return result
}
function areStringArraysEqual(
a: readonly string[] | undefined,
b: readonly string[] | undefined
): boolean {
if (a === b) {
return true
}
if (!a || !b || a.length !== b.length) {
return false
}
return a.every((value, index) => value === b[index])
}
function migrateHydratedEditorTabsAndGroups(
state: Pick<AppState, 'unifiedTabsByWorktree' | 'groupsByWorktree'>,
migrationsByWorktree: Record<string, Map<string, string>>
): Partial<Pick<AppState, 'unifiedTabsByWorktree' | 'groupsByWorktree'>> {
let tabsChanged = false
let groupsChanged = false
const nextUnifiedTabsByWorktree: Record<string, Tab[]> = { ...state.unifiedTabsByWorktree }
const tabIdMigrationsByWorktree: Record<string, Map<string, string>> = {}
for (const [worktreeId, idMigrations] of Object.entries(migrationsByWorktree)) {
const tabs = state.unifiedTabsByWorktree[worktreeId]
if (!tabs) {
continue
}
const tabIdMigrations = new Map<string, string>()
const nextTabs = tabs.map((tab) => {
if (tab.contentType !== 'editor') {
return tab
}
const nextId = idMigrations.get(tab.id) ?? tab.id
const nextEntityId = idMigrations.get(tab.entityId) ?? tab.entityId
if (nextId === tab.id && nextEntityId === tab.entityId) {
return tab
}
tabsChanged = true
if (nextId !== tab.id) {
tabIdMigrations.set(tab.id, nextId)
}
return { ...tab, id: nextId, entityId: nextEntityId }
})
if (tabIdMigrations.size > 0) {
tabIdMigrationsByWorktree[worktreeId] = tabIdMigrations
}
nextUnifiedTabsByWorktree[worktreeId] = nextTabs
}
const nextGroupsByWorktree: Record<string, TabGroup[]> = { ...state.groupsByWorktree }
for (const [worktreeId, tabIdMigrations] of Object.entries(tabIdMigrationsByWorktree)) {
const groups = state.groupsByWorktree[worktreeId]
if (!groups) {
continue
}
const validTabIds = new Set((nextUnifiedTabsByWorktree[worktreeId] ?? []).map((tab) => tab.id))
nextGroupsByWorktree[worktreeId] = groups.map((group) => {
const tabOrder = dedupeEditorTabOrder(
group.tabOrder.map((tabId) => tabIdMigrations.get(tabId) ?? tabId),
validTabIds
)
const activeTabId = group.activeTabId
? (tabIdMigrations.get(group.activeTabId) ?? group.activeTabId)
: null
const validActiveTabId = activeTabId && validTabIds.has(activeTabId) ? activeTabId : null
const recentTabIds = group.recentTabIds
? dedupeEditorTabOrder(
group.recentTabIds.map((tabId) => tabIdMigrations.get(tabId) ?? tabId),
validTabIds
)
: group.recentTabIds
if (
validActiveTabId === group.activeTabId &&
areStringArraysEqual(tabOrder, group.tabOrder) &&
areStringArraysEqual(recentTabIds, group.recentTabIds)
) {
return group
}
groupsChanged = true
return {
...group,
activeTabId: validActiveTabId,
tabOrder,
recentTabIds
}
})
}
return {
...(tabsChanged ? { unifiedTabsByWorktree: nextUnifiedTabsByWorktree } : {}),
...(groupsChanged ? { groupsByWorktree: nextGroupsByWorktree } : {})
}
}
const REMOTE_OPERATION_FAILED_MESSAGE = 'Remote operation failed'
const REMOTE_OPERATION_DETAIL_MAX_LENGTH = 200
@ -647,9 +922,7 @@ function deleteUntouchedUntitledFile(state: AppState, file: OpenFile): void {
// Why: untitled placeholders may live on a remote runtime or SSH target.
// Route through the runtime-aware client instead of assuming client-local FS.
const context = {
settings: owningRuntimeEnvironmentId
? { activeRuntimeEnvironmentId: owningRuntimeEnvironmentId }
: state.settings,
settings: settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId),
worktreeId: file.worktreeId,
worktreePath: worktree?.path ?? null,
connectionId: repo?.connectionId ?? undefined
@ -817,15 +1090,27 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
}),
openFile: (file, options) => {
let editorItemWorktreeId = file.worktreeId
let editorItemFileId = file.filePath
let editorItemLabel = file.relativePath
let editorItemContentType: 'editor' | 'diff' | 'conflict-review' =
file.mode === 'conflict-review' ? 'conflict-review' : file.mode === 'diff' ? 'diff' : 'editor'
let editorItemTargetGroupId = options?.targetGroupId
set((s) => {
const id = file.filePath
const existing = s.openFiles.find((f) => f.id === id)
const worktreeId = file.worktreeId
const runtimeEnvironmentId =
file.runtimeEnvironmentId ??
(options?.suppressActiveRuntimeFallback
? undefined
: (s.settings?.activeRuntimeEnvironmentId?.trim() ?? undefined))
file.runtimeEnvironmentId === null
? null
: (file.runtimeEnvironmentId ??
(options?.suppressActiveRuntimeFallback
? null
: (s.settings?.activeRuntimeEnvironmentId?.trim() ?? undefined)))
const existing = s.openFiles.find(
(f) =>
f.filePath === file.filePath && isSameEditorOwner(f, worktreeId, runtimeEnvironmentId)
)
const id = resolveEditorFileIdForOwner(s, file.filePath, worktreeId, runtimeEnvironmentId)
editorItemFileId = id
const isPreview = options?.preview ?? false
const recordReplacedPreview = options?.recordReplacedPreview ?? false
// Why: resolve the target group up-front so preview replacement can be
@ -1035,27 +1320,38 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
})
void openWorkspaceEditorItem(
get(),
file.filePath,
file.worktreeId,
file.relativePath,
file.mode === 'conflict-review'
? 'conflict-review'
: file.mode === 'diff'
? 'diff'
: 'editor',
editorItemFileId,
editorItemWorktreeId,
editorItemLabel,
editorItemContentType,
options?.preview ?? false,
options?.targetGroupId
editorItemTargetGroupId
)
},
openMarkdownPreview: (file, options) => {
const id = `markdown-preview::${file.filePath}`
const initialState = get()
const resolvedRuntimeEnvironmentId =
file.runtimeEnvironmentId === null
? null
: (file.runtimeEnvironmentId ??
initialState.settings?.activeRuntimeEnvironmentId?.trim() ??
undefined)
const sourceFileId =
options?.sourceFileId ??
resolveEditorFileIdForOwner(
initialState,
file.filePath,
file.worktreeId,
resolvedRuntimeEnvironmentId,
['edit']
)
const id = `markdown-preview::${sourceFileId}`
const anchor = options?.anchor || undefined
set((s) => {
const existing = s.openFiles.find((openFile) => openFile.id === id)
const worktreeId = file.worktreeId
const runtimeEnvironmentId =
file.runtimeEnvironmentId ?? s.settings?.activeRuntimeEnvironmentId?.trim() ?? undefined
const runtimeEnvironmentId = resolvedRuntimeEnvironmentId
const activeResult = buildEditorActiveResult(s, worktreeId, id)
if (existing) {
@ -1063,7 +1359,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
existing.relativePath !== file.relativePath ||
existing.filePath !== file.filePath ||
existing.language !== file.language ||
existing.markdownPreviewSourceFileId !== file.filePath ||
existing.markdownPreviewSourceFileId !== sourceFileId ||
existing.markdownPreviewAnchor !== anchor ||
existing.mode !== 'markdown-preview'
return needsUpdate
@ -1077,7 +1373,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
worktreeId: file.worktreeId,
language: file.language,
runtimeEnvironmentId,
markdownPreviewSourceFileId: file.filePath,
markdownPreviewSourceFileId: sourceFileId,
markdownPreviewAnchor: anchor,
mode: 'markdown-preview' as const
}
@ -1096,7 +1392,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
language: file.language,
isDirty: false,
runtimeEnvironmentId,
markdownPreviewSourceFileId: file.filePath,
markdownPreviewSourceFileId: sourceFileId,
markdownPreviewAnchor: anchor,
mode: 'markdown-preview'
}
@ -2658,10 +2954,10 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
activateMarkdownLink: async (rawHref, ctx) => {
const initialState = get()
const sourceRuntimeEnvironmentId =
ctx.runtimeEnvironmentId ??
initialState.openFiles.find((file) => file.filePath === ctx.sourceFilePath)
?.runtimeEnvironmentId ??
null
ctx.runtimeEnvironmentId !== undefined
? ctx.runtimeEnvironmentId
: initialState.openFiles.find((file) => file.filePath === ctx.sourceFilePath)
?.runtimeEnvironmentId
const sourceSettings = settingsForRuntimeOwner(
initialState.settings,
sourceRuntimeEnvironmentId
@ -2716,7 +3012,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
filePath: target.absolutePath,
relativePath: target.relativePath ?? target.absolutePath,
worktreeId: ctx.worktreeId,
runtimeEnvironmentId: sourceRuntimeEnvironmentId ?? undefined,
runtimeEnvironmentId: sourceRuntimeEnvironmentId,
language: detectLanguage(target.absolutePath),
mode: 'edit'
},
@ -2727,7 +3023,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
}
)
if (line !== undefined) {
scheduleEditorLineReveal(get, target.absolutePath, line, column)
const fileId = getOpenedEditFileIdAfterOpen(get(), target.absolutePath, ctx.worktreeId)
scheduleEditorLineReveal(get, target.absolutePath, line, column, fileId)
}
return
}
@ -2746,45 +3043,29 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
return
}
const state = get()
const existing = state.openFiles.find(
(f) =>
f.filePath === absolutePath &&
(f.runtimeEnvironmentId ?? null) === sourceRuntimeEnvironmentId
get().openFile(
{
filePath: absolutePath,
relativePath,
worktreeId: ctx.worktreeId,
runtimeEnvironmentId: sourceRuntimeEnvironmentId,
language: 'markdown',
mode: 'edit'
},
{
preview: true,
targetGroupId: get().activeGroupIdByWorktree?.[ctx.worktreeId],
recordReplacedPreview: true
}
)
const fileId = existing?.id ?? absolutePath
// Why: pendingEditorReveal is consumed by MonacoEditor on mount. If the
// file opens/stays in rich mode, the reveal is silently dropped. Flip to
// source before openFile/setActiveFile so Monaco is the surface that
// mounts or is already mounted when the reveal lands. Rich-mode line
// reveal is tracked as a follow-up (design doc §open-q 1).
if (line !== undefined) {
const fileId = getOpenedEditFileIdAfterOpen(get(), absolutePath, ctx.worktreeId)
// Why: pendingEditorReveal is consumed by MonacoEditor on mount. If the
// file stays in rich mode, the reveal is silently dropped; use the final
// owner-qualified id after openFile has resolved the tab identity.
get().setMarkdownViewMode(fileId, 'source')
}
if (!existing) {
get().openFile(
{
filePath: absolutePath,
relativePath,
worktreeId: ctx.worktreeId,
runtimeEnvironmentId: sourceRuntimeEnvironmentId ?? undefined,
language: 'markdown',
mode: 'edit'
},
{
preview: true,
targetGroupId: get().activeGroupIdByWorktree?.[ctx.worktreeId],
recordReplacedPreview: true
}
)
} else {
get().setActiveFile(existing.id)
}
if (line !== undefined) {
scheduleEditorLineReveal(get, absolutePath, line, column)
scheduleEditorLineReveal(get, absolutePath, line, column, fileId)
}
},
@ -2805,15 +3086,44 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
.flat()
.map((w) => w.id)
)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
const openFiles: OpenFile[] = []
const usedOpenFileIds = new Set<string>()
const legacyHydratedOpenFiles: LegacyHydratedEditorFile[] = []
const editorFileIdMigrationsByWorktree: Record<string, Map<string, string>> = {}
for (const [worktreeId, files] of Object.entries(openFilesByWorktree)) {
if (!validWorktreeIds.has(worktreeId)) {
continue
}
for (const pf of files) {
const legacyId = resolveLegacyHydratedEditorFileId(
legacyHydratedOpenFiles,
pf,
worktreeId
)
// Why: floating/runtime-owned files need IDs that survive peers
// disappearing between restarts; collision-based IDs drift when the
// same path is no longer open in another owner.
const ownedId = buildOwnedEditorFileId(pf.filePath, worktreeId, pf.runtimeEnvironmentId)
const id =
shouldHydrateWithOwnedEditorFileId(worktreeId, pf.runtimeEnvironmentId) ||
usedOpenFileIds.has(pf.filePath)
? ownedId
: pf.filePath
usedOpenFileIds.add(id)
// Why: legacy sessions used the collision-derived id for each
// persisted entry. Mapping every filePath would collapse same-path
// local/runtime tabs onto whichever owner hydrates last.
addEditorFileIdMigration(editorFileIdMigrationsByWorktree, worktreeId, legacyId, id)
legacyHydratedOpenFiles.push({
id: legacyId,
filePath: pf.filePath,
worktreeId,
runtimeEnvironmentId: pf.runtimeEnvironmentId
})
openFiles.push({
id: pf.filePath,
id,
filePath: pf.filePath,
relativePath: pf.relativePath,
worktreeId,
@ -2837,13 +3147,17 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
? (openFiles.find((f) => f.worktreeId === activeWorktreeId)?.id ?? null)
: null
const persistedActiveFileId = activeWorktreeId
? (persistedActiveFileIdByWorktree[activeWorktreeId] ?? null)
? migrateEditorFileId(
editorFileIdMigrationsByWorktree,
activeWorktreeId,
persistedActiveFileIdByWorktree[activeWorktreeId]
)
: null
// Why: verify the persisted active file still exists in the restored set.
// The file may have been removed due to worktree validation or the
// persisted data may reference a stale path.
const activeFileExists = persistedActiveFileId
? openFiles.some((f) => f.id === persistedActiveFileId)
? openFiles.some((f) => f.id === persistedActiveFileId && f.worktreeId === activeWorktreeId)
: false
// Why: if the previously active editor surface pointed at a transient
// diff/conflict tab, restart still restores any normal edit tabs for the
@ -2858,8 +3172,15 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// Filter per-worktree maps to only valid worktrees with valid file references
const filteredActiveFileIdByWorktree = Object.fromEntries(
[...validWorktreeIds].flatMap((wId) => {
const persistedFileId = persistedActiveFileIdByWorktree[wId]
if (persistedFileId && openFiles.some((f) => f.id === persistedFileId)) {
const persistedFileId = migrateEditorFileId(
editorFileIdMigrationsByWorktree,
wId,
persistedActiveFileIdByWorktree[wId]
)
if (
persistedFileId &&
openFiles.some((f) => f.id === persistedFileId && f.worktreeId === wId)
) {
return [[wId, persistedFileId]]
}
const fallbackFileId = openFiles.find((f) => f.worktreeId === wId)?.id
@ -2894,7 +3215,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
activeFileId: nextActiveFileId,
activeFileIdByWorktree: filteredActiveFileIdByWorktree,
activeTabType: nextActiveTabType,
activeTabTypeByWorktree: filteredActiveTabTypeByWorktree
activeTabTypeByWorktree: filteredActiveTabTypeByWorktree,
...migrateHydratedEditorTabsAndGroups(s, editorFileIdMigrationsByWorktree)
}
})
}

View File

@ -10,6 +10,7 @@ import type {
Worktree
} from '../../../../shared/types'
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
// Mock sonner (imported by repos.ts)
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
@ -224,6 +225,15 @@ function makeBrowserTab(
}
}
function ownedEditorFileId(
filePath: string,
worktreeId: string,
runtimeEnvironmentId: string | null | undefined
): string {
const runtimeKey = runtimeEnvironmentId?.trim() || 'local'
return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}`
}
// ─── Tests ────────────────────────────────────────────────────────────
describe('removeRepo cascade', () => {
@ -469,6 +479,37 @@ describe('hydrateBrowserSession', () => {
expect(s.activeBrowserTabId).toBe('browser-1')
})
it('restores floating workspace browser tabs without a repo worktree', () => {
const store = createTestStore()
store.setState({ activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID })
store.getState().hydrateBrowserSession({
activeRepoId: null,
activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
browserTabsByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
makeBrowserTab({
id: 'floating-browser-1',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
url: 'https://example.com'
})
]
},
activeBrowserTabIdByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: 'floating-browser-1'
},
activeTabTypeByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'browser' }
})
const s = store.getState()
expect(s.browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toHaveLength(1)
expect(s.activeBrowserTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe('floating-browser-1')
})
it('restores activeTabTypeByWorktree for browser worktrees when hydrateEditorSession was a no-op', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
@ -1561,6 +1602,390 @@ describe('hydrateEditorSession', () => {
expect(s.activeTabType).toBe('editor')
})
it('restores floating workspace markdown files without a repo worktree', () => {
const store = createTestStore()
const filePath = '/orca/userData/floating-workspace/note.md'
const fileId = ownedEditorFileId(filePath, FLOATING_TERMINAL_WORKTREE_ID, null)
store.setState({ activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID })
store.getState().hydrateEditorSession({
activeRepoId: null,
activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
openFilesByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
filePath,
relativePath: 'note.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
language: 'markdown',
runtimeEnvironmentId: null
}
]
},
activeFileIdByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: '/orca/userData/floating-workspace/note.md'
},
activeTabTypeByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'editor' }
})
const s = store.getState()
expect(s.openFiles).toEqual([
expect.objectContaining({
id: fileId,
filePath,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
runtimeEnvironmentId: null
})
])
expect(s.activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(fileId)
})
it('falls back to the floating workspace file id when duplicate paths are owner-qualified', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const sharedPath = '/path/wt1/README.md'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID
})
store.getState().hydrateEditorSession({
activeRepoId: 'repo1',
activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
openFilesByWorktree: {
[wt]: [
{
filePath: sharedPath,
relativePath: 'README.md',
worktreeId: wt,
language: 'markdown'
}
],
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
filePath: sharedPath,
relativePath: 'README.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
language: 'markdown',
runtimeEnvironmentId: null
}
]
},
activeFileIdByWorktree: {
[wt]: sharedPath,
[FLOATING_TERMINAL_WORKTREE_ID]: sharedPath
},
activeTabTypeByWorktree: {
[wt]: 'editor',
[FLOATING_TERMINAL_WORKTREE_ID]: 'editor'
}
})
const floatingActiveFileId =
store.getState().activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]
expect(floatingActiveFileId).not.toBe(sharedPath)
expect(
store
.getState()
.openFiles.some(
(file) =>
file.id === floatingActiveFileId && file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID
)
).toBe(true)
})
it('keeps same-path local and runtime legacy references on their original owners', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const filePath = '/path/wt1/src/app.ts'
const runtimeEnvironmentId = 'runtime-1'
const runtimeFileId = ownedEditorFileId(filePath, wt, runtimeEnvironmentId)
const groupId = 'group-same-path-owners'
store.setState({
repos: [
{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }
],
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
activeWorktreeId: wt
})
const session = {
activeRepoId: 'repo1',
activeWorktreeId: wt,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
openFilesByWorktree: {
[wt]: [
{
filePath,
relativePath: 'src/app.ts',
worktreeId: wt,
language: 'typescript'
},
{
filePath,
relativePath: 'src/app.ts',
worktreeId: wt,
language: 'typescript',
runtimeEnvironmentId
}
]
},
activeFileIdByWorktree: { [wt]: filePath },
activeTabTypeByWorktree: { [wt]: 'editor' as const },
unifiedTabs: {
[wt]: [
{
id: filePath,
entityId: filePath,
groupId,
worktreeId: wt,
contentType: 'editor' as const,
label: 'app.ts',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
},
{
id: runtimeFileId,
entityId: runtimeFileId,
groupId,
worktreeId: wt,
contentType: 'editor' as const,
label: 'app.ts',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: 2
}
]
},
tabGroups: {
[wt]: [
{
id: groupId,
worktreeId: wt,
activeTabId: filePath,
tabOrder: [filePath, runtimeFileId],
recentTabIds: [runtimeFileId, filePath]
}
]
},
activeGroupIdByWorktree: { [wt]: groupId }
}
store.getState().hydrateTabsSession(session)
store.getState().hydrateEditorSession(session)
const s = store.getState()
expect(s.openFiles).toEqual([
expect.objectContaining({
id: filePath,
filePath,
worktreeId: wt,
runtimeEnvironmentId: undefined
}),
expect.objectContaining({
id: runtimeFileId,
filePath,
worktreeId: wt,
runtimeEnvironmentId
})
])
expect(s.activeFileIdByWorktree[wt]).toBe(filePath)
expect(s.unifiedTabsByWorktree[wt]?.map((tab) => tab.id)).toEqual([filePath, runtimeFileId])
expect(s.unifiedTabsByWorktree[wt]?.map((tab) => tab.entityId)).toEqual([
filePath,
runtimeFileId
])
expect(s.groupsByWorktree[wt]?.[0]).toEqual(
expect.objectContaining({
activeTabId: filePath,
tabOrder: [filePath, runtimeFileId],
recentTabIds: [runtimeFileId, filePath]
})
)
})
it('keeps floating owner-qualified editor ids aligned with restored unified tabs', () => {
const store = createTestStore()
const sharedPath = '/path/wt1/README.md'
const floatingFileId = ownedEditorFileId(sharedPath, FLOATING_TERMINAL_WORKTREE_ID, null)
const groupId = 'floating-group-1'
store.setState({ activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID })
const session = {
activeRepoId: null,
activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
openFilesByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
filePath: sharedPath,
relativePath: 'README.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
language: 'markdown',
runtimeEnvironmentId: null
}
]
},
activeFileIdByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: floatingFileId
},
activeTabTypeByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'editor' as const },
unifiedTabs: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
id: floatingFileId,
entityId: floatingFileId,
groupId,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
contentType: 'editor' as const,
label: 'README.md',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
tabGroups: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
id: groupId,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: floatingFileId,
tabOrder: [floatingFileId],
recentTabIds: [floatingFileId]
}
]
},
activeGroupIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: groupId }
}
store.getState().hydrateTabsSession(session)
store.getState().hydrateEditorSession(session)
const s = store.getState()
expect(s.openFiles).toEqual([
expect.objectContaining({
id: floatingFileId,
filePath: sharedPath,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID
})
])
expect(s.activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(floatingFileId)
expect(s.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toEqual([
expect.objectContaining({
id: floatingFileId,
entityId: floatingFileId
})
])
expect(s.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toEqual([
expect.objectContaining({
activeTabId: floatingFileId,
tabOrder: [floatingFileId],
recentTabIds: [floatingFileId]
})
])
})
it('migrates legacy floating unified tab file-path references to the hydrated owner id', () => {
const store = createTestStore()
const filePath = '/orca/userData/floating-workspace/README.md'
const fileId = ownedEditorFileId(filePath, FLOATING_TERMINAL_WORKTREE_ID, null)
const groupId = 'floating-group-legacy'
store.setState({ activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID })
const session = {
activeRepoId: null,
activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
openFilesByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
filePath,
relativePath: 'README.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
language: 'markdown',
runtimeEnvironmentId: null
}
]
},
activeFileIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: filePath },
activeTabTypeByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'editor' as const },
unifiedTabs: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
id: filePath,
entityId: filePath,
groupId,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
contentType: 'editor' as const,
label: 'README.md',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
tabGroups: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
id: groupId,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: filePath,
tabOrder: [filePath],
recentTabIds: [filePath]
}
]
},
activeGroupIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: groupId }
}
store.getState().hydrateTabsSession(session)
store.getState().hydrateEditorSession(session)
const s = store.getState()
expect(s.openFiles[0]?.id).toBe(fileId)
expect(s.activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(fileId)
expect(s.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.[0]).toEqual(
expect.objectContaining({ id: fileId, entityId: fileId })
)
expect(s.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.[0]).toEqual(
expect.objectContaining({
activeTabId: fileId,
tabOrder: [fileId],
recentTabIds: [fileId]
})
)
})
it('re-detects restored file languages instead of trusting stale session data', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'

View File

@ -4,6 +4,7 @@ import { create } from 'zustand'
import type { AppState } from '../types'
import type { Tab, TabGroup } from '../../../../shared/types'
import type * as AgentStatusModule from '@/lib/agent-status'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
// Mock sonner (imported by repos.ts)
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
@ -1206,6 +1207,44 @@ describe('TabsSlice', () => {
expect(groups[0].tabOrder).toEqual(['term-1', 'term-2', '/tmp/feature/src/main.ts'])
})
it('hydrates floating workspace unified tabs without a repo worktree', () => {
store.getState().hydrateTabsSession({
activeRepoId: null,
activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
unifiedTabs: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
id: 'floating-browser-1',
entityId: 'floating-browser-1',
groupId: 'floating-group-1',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
contentType: 'browser',
label: 'Browser',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
tabGroups: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
id: 'floating-group-1',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: 'floating-browser-1',
tabOrder: ['floating-browser-1']
}
]
}
})
expect(store.getState().unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toHaveLength(1)
})
it('hydrates from unified format', () => {
store.setState({
worktreesByRepo: {

View File

@ -28,6 +28,7 @@ import {
import { buildHydratedTabState } from './tabs-hydration'
import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
export type TabSplitDirection = 'left' | 'right' | 'up' | 'down'
@ -1485,6 +1486,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
.flat()
.map((w) => w.id)
)
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
set(buildHydratedTabState(session, validWorktreeIds))
}
})

View File

@ -1592,7 +1592,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
// Only SSH repos need this: local worktrees are persisted and a missing
// local worktree genuinely means it was deleted.
const sshRepoIds = new Set(s.repos.filter((r) => r.connectionId).map((r) => r.id))
// Why: the Floating Terminal is intentionally not a repo worktree, but
// Why: the Floating Workspace is intentionally not a repo worktree, but
// its tabs still use the normal terminal session pipeline so daemon PTYs
// can survive app restart just like workspace terminals.
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)

View File

@ -85,7 +85,9 @@ function createWebPreloadApi(): Partial<PreloadApi> {
reload: () => Promise.resolve(window.location.reload()),
getKeyboardInputSourceId: () => Promise.resolve(null),
setUnreadDockBadgeCount: () => Promise.resolve(),
getFloatingTerminalCwd: () => Promise.resolve('~')
getFloatingTerminalCwd: () => Promise.resolve(''),
pickFloatingMarkdownDocument: () => Promise.resolve(null),
pickFloatingWorkspaceDirectory: () => Promise.resolve(null)
},
e2e: {
getConfig: () => createE2EConfig({})

View File

@ -94,7 +94,7 @@ export const STAR_NAG_INITIAL_THRESHOLD = 35
* the collector and the status-bar popover agree on the sentinel. */
export const ORPHAN_WORKTREE_ID = '__orphan__'
// Why: the floating terminal is a local synthetic workspace, so persistence
// Why: the floating workspace is a local synthetic workspace, so persistence
// pruning must classify it without consulting the repo catalog.
export const FLOATING_TERMINAL_WORKTREE_ID = 'global-floating-terminal'
@ -212,7 +212,9 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
ctrlTabOrderMode: 'mru',
floatingTerminalEnabled: true,
floatingTerminalDefaultedForAllUsers: true,
floatingTerminalCwd: '~',
floatingTerminalCwd: '',
floatingTerminalTrustedCwds: [],
floatingTerminalCwdMigratedToAppWorkspace: true,
floatingTerminalTriggerLocation: 'floating-button',
notifications: getDefaultNotificationSettings(),
diffDefaultView: 'inline',

View File

@ -521,7 +521,7 @@ export type PersistedOpenFile = {
worktreeId: string
language: string
isPreview?: boolean
runtimeEnvironmentId?: string
runtimeEnvironmentId?: string | null
}
export type WorkspaceSessionState = {
@ -1496,6 +1496,7 @@ export type SourceControlViewMode = 'list' | 'tree'
export type FloatingTerminalCwdRequest = {
path?: string
requireTrusted?: boolean
}
export type GlobalSettings = {
@ -1604,17 +1605,23 @@ export type GlobalSettings = {
/** Controls how Ctrl+Tab chooses the next visible tab. Optional for
* profiles saved before this setting existed; readers default to MRU. */
ctrlTabOrderMode?: CtrlTabOrderMode
/** Why: Floating Terminal is the default global shell surface so users can
* reach a terminal outside repo/worktree context immediately. */
/** Why: Floating Workspace is the default global surface so users can
* reach terminal, browser, and markdown tabs outside repo/worktree context. */
floatingTerminalEnabled: boolean
/** One-shot migration flag for the default-on rollout. Before this field
* landed, the floating terminal defaulted off and many profiles persisted
* landed, the floating workspace defaulted off and many profiles persisted
* that inherited false. Once migrated, an explicit off choice sticks. */
floatingTerminalDefaultedForAllUsers?: boolean
/** Where new Floating Terminal tabs start. Defaults to '~' so the visible
* setting matches the shell-oriented directory users expect. */
/** Where new Floating Workspace tabs start. Empty means Orca's app-owned
* floating workspace under Electron userData. */
floatingTerminalCwd: string
/** Where the Floating Terminal toggle is shown. Defaults to the floating
/** Picker-approved Floating Workspace directories that may be reauthorized
* across restarts. Renderer-provided text alone must not populate this. */
floatingTerminalTrustedCwds?: string[]
/** One-shot migration from the old implicit '~' default to the app-owned
* floating workspace. Explicit future '~' choices are preserved. */
floatingTerminalCwdMigratedToAppWorkspace?: boolean
/** Where the Floating Workspace toggle is shown. Defaults to the floating
* button for discoverability. */
floatingTerminalTriggerLocation: FloatingTerminalTriggerLocation
diffDefaultView: 'inline' | 'side-by-side'

View File

@ -123,7 +123,7 @@ const persistedOpenFileSchema = z.object({
worktreeId: z.string(),
language: z.string(),
isPreview: z.boolean().optional(),
runtimeEnvironmentId: z.string().optional()
runtimeEnvironmentId: z.string().nullable().optional()
})
// ─── Browser ────────────────────────────────────────────────────────