Improve Floating Workspace notes (#2450)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
cf3116bc4a
commit
c86cf8a308
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export function registerCoreHandlers(
|
|||
}
|
||||
registered = true
|
||||
|
||||
registerAppHandlers()
|
||||
registerAppHandlers(store)
|
||||
registerCliHandlers()
|
||||
registerPreflightHandlers()
|
||||
registerClaudeUsageHandlers(claudeUsage)
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export function FloatingTerminalIconContextMenu({
|
|||
}}
|
||||
>
|
||||
<EyeOff className="size-3.5" />
|
||||
Hide
|
||||
Hide Floating Workspace
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ export function openDetectedFilePath(
|
|||
worktreeId: worktreeId || '',
|
||||
language: detectLanguage(filePath),
|
||||
mode: 'edit',
|
||||
runtimeEnvironmentId: runtimeEnvironmentId ?? undefined
|
||||
runtimeEnvironmentId
|
||||
})
|
||||
|
||||
if (line !== null) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]> = {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in New Issue