mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
This reverts commit c73fd2c90b.
This commit is contained in:
+1
-2
@@ -93,8 +93,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
|
||||
'storage',
|
||||
'orchestration',
|
||||
'computer',
|
||||
'environment',
|
||||
'note'
|
||||
'environment'
|
||||
].includes(commandPath[0])) ||
|
||||
(commandPath.length === 2 &&
|
||||
commandPath[0] === 'storage' &&
|
||||
|
||||
+1
-3
@@ -15,7 +15,6 @@ import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage'
|
||||
import { ORCHESTRATION_HANDLERS } from './handlers/orchestration'
|
||||
import { COMPUTER_HANDLERS } from './handlers/computer'
|
||||
import { ENVIRONMENT_HANDLERS } from './handlers/environment'
|
||||
import { NOTE_HANDLERS } from './handlers/note'
|
||||
|
||||
export type HandlerContext = {
|
||||
flags: Map<string, string | boolean>
|
||||
@@ -43,8 +42,7 @@ function buildHandlers(): Map<string, CommandHandler> {
|
||||
BROWSER_STORAGE_HANDLERS,
|
||||
ORCHESTRATION_HANDLERS,
|
||||
COMPUTER_HANDLERS,
|
||||
ENVIRONMENT_HANDLERS,
|
||||
NOTE_HANDLERS
|
||||
ENVIRONMENT_HANDLERS
|
||||
]
|
||||
for (const group of groups) {
|
||||
for (const [key, handler] of Object.entries(group)) {
|
||||
|
||||
@@ -34,7 +34,6 @@ import type {
|
||||
RuntimeWorktreeRecord
|
||||
} from '../shared/runtime-types'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
|
||||
import type { NoteListResult, NoteMutationResult, NoteShowResult } from '../shared/notes-types'
|
||||
import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
|
||||
|
||||
@@ -208,39 +207,6 @@ export function formatTerminalWait(result: { wait: RuntimeTerminalWait }): strin
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function formatNoteList(result: NoteListResult): string {
|
||||
if (result.notes.length === 0) {
|
||||
return 'No notes.'
|
||||
}
|
||||
const body = result.notes
|
||||
.map((note) => {
|
||||
const link = note.linkKind ? ` ${note.linkKind}` : ''
|
||||
const preview = note.preview ? `\n${note.preview}` : ''
|
||||
return `${note.id} ${note.title}${link}\npath: ${note.relativePath}\nupdated: ${note.updatedAt}${preview}`
|
||||
})
|
||||
.join('\n\n')
|
||||
return result.truncated
|
||||
? `${body}\n\ntruncated: showing ${result.notes.length} of ${result.totalCount}`
|
||||
: body
|
||||
}
|
||||
|
||||
export function formatNoteShow(result: NoteShowResult): string {
|
||||
const link = result.linkKind ? `link: ${result.linkKind}` : 'link: none'
|
||||
return [
|
||||
`id: ${result.note.id}`,
|
||||
`path: ${result.note.relativePath}`,
|
||||
`title: ${result.note.title}`,
|
||||
`revision: ${result.note.revision}`,
|
||||
link,
|
||||
'',
|
||||
result.note.bodyMarkdown
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function formatNoteMutation(result: NoteMutationResult): string {
|
||||
return `Saved note ${result.note.id} (${result.note.title}) revision ${result.note.revision}.`
|
||||
}
|
||||
|
||||
export function formatWorktreePs(result: RuntimeWorktreePsResult): string {
|
||||
if (result.worktrees.length === 0) {
|
||||
return 'No worktrees found.'
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import type { NoteListResult, NoteMutationResult, NoteShowResult } from '../../shared/notes-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { formatNoteList, formatNoteMutation, formatNoteShow, printResult } from '../format'
|
||||
import {
|
||||
getOptionalPositiveIntegerFlag,
|
||||
getOptionalStringFlag,
|
||||
getRequiredStringFlag
|
||||
} from '../flags'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { getBrowserWorktreeSelector } from '../selectors'
|
||||
|
||||
async function getNoteWorktreeSelector(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
client: Parameters<CommandHandler>[0]['client']
|
||||
): Promise<string> {
|
||||
const worktree = await getBrowserWorktreeSelector(flags, cwd, client)
|
||||
if (!worktree) {
|
||||
throw new RuntimeClientError(
|
||||
'selector_not_found',
|
||||
'No Orca-managed worktree contains the current directory. Pass --worktree.'
|
||||
)
|
||||
}
|
||||
return worktree
|
||||
}
|
||||
|
||||
function readBody(flags: Map<string, string | boolean>, required: boolean): string | undefined {
|
||||
const body = getOptionalStringFlag(flags, 'body')
|
||||
const bodyFile = getOptionalStringFlag(flags, 'body-file')
|
||||
const bodyStdin = flags.get('body-stdin') === true
|
||||
const specified = [body !== undefined, bodyFile !== undefined, bodyStdin].filter(Boolean).length
|
||||
if (specified > 1) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Pass only one of --body, --body-file, or --body-stdin'
|
||||
)
|
||||
}
|
||||
if (body !== undefined) {
|
||||
return body
|
||||
}
|
||||
if (bodyFile) {
|
||||
return readFileSync(bodyFile, 'utf8')
|
||||
}
|
||||
if (bodyStdin) {
|
||||
return readFileSync(0, 'utf8')
|
||||
}
|
||||
if (required) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing note body')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const NOTE_HANDLERS: Record<string, CommandHandler> = {
|
||||
'note list': async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<NoteListResult>('note.list', {
|
||||
worktree: await getNoteWorktreeSelector(flags, cwd, client),
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
|
||||
})
|
||||
printResult(result, json, formatNoteList)
|
||||
},
|
||||
'note show': async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<NoteShowResult>('note.show', {
|
||||
worktree: await getNoteWorktreeSelector(flags, cwd, client),
|
||||
note: getRequiredStringFlag(flags, 'note')
|
||||
})
|
||||
printResult(result, json, formatNoteShow)
|
||||
},
|
||||
'note create': async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<NoteMutationResult>('note.create', {
|
||||
worktree: await getNoteWorktreeSelector(flags, cwd, client),
|
||||
title: getRequiredStringFlag(flags, 'title'),
|
||||
bodyMarkdown: readBody(flags, false),
|
||||
makeActive: true
|
||||
})
|
||||
printResult(result, json, formatNoteMutation)
|
||||
},
|
||||
'note append': async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<NoteMutationResult>('note.append', {
|
||||
worktree: await getNoteWorktreeSelector(flags, cwd, client),
|
||||
note: getRequiredStringFlag(flags, 'note'),
|
||||
bodyMarkdown: readBody(flags, true),
|
||||
makeActive: true
|
||||
})
|
||||
printResult(result, json, formatNoteMutation)
|
||||
},
|
||||
'note search': async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<NoteListResult>('note.search', {
|
||||
worktree: await getNoteWorktreeSelector(flags, cwd, client),
|
||||
query: getRequiredStringFlag(flags, 'query'),
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
|
||||
})
|
||||
printResult(result, json, formatNoteList)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { CORE_COMMAND_SPECS } from './core'
|
||||
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
|
||||
import { COMPUTER_COMMAND_SPECS } from './computer'
|
||||
import { ENVIRONMENT_COMMAND_SPECS } from './environment'
|
||||
import { NOTE_COMMAND_SPECS } from './note'
|
||||
|
||||
export const COMMAND_SPECS: CommandSpec[] = [
|
||||
...CORE_COMMAND_SPECS,
|
||||
@@ -13,6 +12,5 @@ export const COMMAND_SPECS: CommandSpec[] = [
|
||||
...BROWSER_ADVANCED_COMMAND_SPECS,
|
||||
...ORCHESTRATION_COMMAND_SPECS,
|
||||
...COMPUTER_COMMAND_SPECS,
|
||||
...ENVIRONMENT_COMMAND_SPECS,
|
||||
...NOTE_COMMAND_SPECS
|
||||
...ENVIRONMENT_COMMAND_SPECS
|
||||
]
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
export const NOTE_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['note', 'list'],
|
||||
summary: 'List project notes for the current Orca worktree',
|
||||
usage: 'orca note list [--worktree <selector>] [--limit <n>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'limit']
|
||||
},
|
||||
{
|
||||
path: ['note', 'show'],
|
||||
summary: 'Show a project note',
|
||||
usage: 'orca note show --note <selector> [--worktree <selector>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'note', 'worktree']
|
||||
},
|
||||
{
|
||||
path: ['note', 'create'],
|
||||
summary: 'Create a project note',
|
||||
usage:
|
||||
'orca note create --title <title> [--body <text>|--body-file <path>|--body-stdin] [--worktree <selector>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'title', 'body', 'body-file', 'body-stdin', 'worktree']
|
||||
},
|
||||
{
|
||||
path: ['note', 'append'],
|
||||
summary: 'Append Markdown to a project note',
|
||||
usage:
|
||||
'orca note append --note <selector> [--body <text>|--body-file <path>|--body-stdin] [--worktree <selector>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'note', 'body', 'body-file', 'body-stdin', 'worktree']
|
||||
},
|
||||
{
|
||||
path: ['note', 'search'],
|
||||
summary: 'Search project notes',
|
||||
usage: 'orca note search --query <text> [--worktree <selector>] [--limit <n>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'query', 'worktree', 'limit']
|
||||
}
|
||||
]
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type {
|
||||
NoteAppendArgs,
|
||||
NoteCreateArgs,
|
||||
NoteDeleteArgs,
|
||||
NoteLinkArgs,
|
||||
NoteListArgs,
|
||||
NoteRenameArgs,
|
||||
NoteSaveArgs,
|
||||
NoteSearchArgs,
|
||||
NoteShowArgs,
|
||||
NotesPanelStateArgs
|
||||
} from '../../shared/notes-types'
|
||||
|
||||
export function registerNotesHandlers(runtime: OrcaRuntimeService): void {
|
||||
ipcMain.removeHandler('notes:list')
|
||||
ipcMain.removeHandler('notes:show')
|
||||
ipcMain.removeHandler('notes:create')
|
||||
ipcMain.removeHandler('notes:save')
|
||||
ipcMain.removeHandler('notes:rename')
|
||||
ipcMain.removeHandler('notes:delete')
|
||||
ipcMain.removeHandler('notes:append')
|
||||
ipcMain.removeHandler('notes:search')
|
||||
ipcMain.removeHandler('notes:link')
|
||||
ipcMain.removeHandler('notes:panelState')
|
||||
|
||||
ipcMain.handle('notes:list', (_event, args: NoteListArgs) => runtime.listProjectNotes(args))
|
||||
ipcMain.handle('notes:show', (_event, args: NoteShowArgs) => runtime.showProjectNote(args))
|
||||
ipcMain.handle('notes:create', (_event, args: NoteCreateArgs) => runtime.createProjectNote(args))
|
||||
ipcMain.handle('notes:save', (_event, args: NoteSaveArgs) => runtime.saveProjectNote(args))
|
||||
ipcMain.handle('notes:rename', (_event, args: NoteRenameArgs) => runtime.renameProjectNote(args))
|
||||
ipcMain.handle('notes:delete', (_event, args: NoteDeleteArgs) => runtime.deleteProjectNote(args))
|
||||
ipcMain.handle('notes:append', (_event, args: NoteAppendArgs) => runtime.appendProjectNote(args))
|
||||
ipcMain.handle('notes:search', (_event, args: NoteSearchArgs) => runtime.searchProjectNotes(args))
|
||||
ipcMain.handle('notes:link', (_event, args: NoteLinkArgs) => runtime.linkProjectNote(args))
|
||||
ipcMain.handle('notes:panelState', (_event, args: NotesPanelStateArgs) =>
|
||||
runtime.resolveNotesPanelOpenState(args)
|
||||
)
|
||||
}
|
||||
@@ -12,7 +12,6 @@ const {
|
||||
registerStatsHandlersMock,
|
||||
registerMemoryHandlersMock,
|
||||
registerNotebookHandlersMock,
|
||||
registerNotesHandlersMock,
|
||||
registerNotificationHandlersMock,
|
||||
registerDeveloperPermissionHandlersMock,
|
||||
registerComputerUsePermissionHandlersMock,
|
||||
@@ -54,7 +53,6 @@ const {
|
||||
registerStatsHandlersMock: vi.fn(),
|
||||
registerMemoryHandlersMock: vi.fn(),
|
||||
registerNotebookHandlersMock: vi.fn(),
|
||||
registerNotesHandlersMock: vi.fn(),
|
||||
registerNotificationHandlersMock: vi.fn(),
|
||||
registerDeveloperPermissionHandlersMock: vi.fn(),
|
||||
registerComputerUsePermissionHandlersMock: vi.fn(),
|
||||
@@ -136,10 +134,6 @@ vi.mock('./notebook', () => ({
|
||||
registerNotebookHandlers: registerNotebookHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./notes', () => ({
|
||||
registerNotesHandlers: registerNotesHandlersMock
|
||||
}))
|
||||
|
||||
vi.mock('./notifications', () => ({
|
||||
registerNotificationHandlers: registerNotificationHandlersMock
|
||||
}))
|
||||
@@ -256,7 +250,6 @@ describe('registerCoreHandlers', () => {
|
||||
registerStatsHandlersMock.mockReset()
|
||||
registerMemoryHandlersMock.mockReset()
|
||||
registerNotebookHandlersMock.mockReset()
|
||||
registerNotesHandlersMock.mockReset()
|
||||
registerNotificationHandlersMock.mockReset()
|
||||
registerDeveloperPermissionHandlersMock.mockReset()
|
||||
registerComputerUsePermissionHandlersMock.mockReset()
|
||||
|
||||
@@ -22,7 +22,6 @@ import { registerMemoryHandlers } from './memory'
|
||||
import { registerRateLimitHandlers } from './rate-limits'
|
||||
import { registerRuntimeHandlers } from './runtime'
|
||||
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
|
||||
import { registerNotesHandlers } from './notes'
|
||||
import { registerNotificationHandlers } from './notifications'
|
||||
import { registerNotebookHandlers } from './notebook'
|
||||
import { registerOnboardingHandlers } from './onboarding'
|
||||
@@ -132,7 +131,6 @@ export function registerCoreHandlers(
|
||||
registerFilesystemWatcherHandlers()
|
||||
registerRuntimeHandlers(runtime)
|
||||
registerRuntimeEnvironmentHandlers()
|
||||
registerNotesHandlers(runtime)
|
||||
registerClipboardHandlers()
|
||||
registerUpdaterHandlers(store)
|
||||
registerSpeechHandlers(store)
|
||||
|
||||
@@ -166,7 +166,6 @@ describe('registerWorktreeHandlers', () => {
|
||||
recordOptimisticReconcileToken: ReturnType<typeof vi.fn>
|
||||
reconcileWorktreeBaseStatus: ReturnType<typeof vi.fn>
|
||||
clearOptimisticReconcileToken: ReturnType<typeof vi.fn>
|
||||
unlinkNotesWorktree: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -302,8 +301,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
emitWorktreeBaseStatus: vi.fn(),
|
||||
recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'),
|
||||
reconcileWorktreeBaseStatus: vi.fn(),
|
||||
clearOptimisticReconcileToken: vi.fn(),
|
||||
unlinkNotesWorktree: vi.fn().mockResolvedValue(undefined)
|
||||
clearOptimisticReconcileToken: vi.fn()
|
||||
}
|
||||
registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never)
|
||||
})
|
||||
|
||||
@@ -564,7 +564,6 @@ export function registerWorktreeHandlers(
|
||||
if (repo.connectionId) {
|
||||
await provider!.removeWorktree(canonicalWorktreePath, args.force)
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
await runtime.unlinkNotesWorktree(repoId, args.worktreeId)
|
||||
store.removeWorktreeMeta(args.worktreeId)
|
||||
deleteWorktreeHistoryDir(args.worktreeId)
|
||||
notifyWorktreesChanged(mainWindow, repoId)
|
||||
@@ -610,7 +609,6 @@ export function registerWorktreeHandlers(
|
||||
// remains locked — other worktrees cannot check it out.
|
||||
await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {})
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
await runtime.unlinkNotesWorktree(repoId, args.worktreeId)
|
||||
store.removeWorktreeMeta(args.worktreeId)
|
||||
deleteWorktreeHistoryDir(args.worktreeId)
|
||||
invalidateAuthorizedRootsCache()
|
||||
@@ -622,7 +620,6 @@ export function registerWorktreeHandlers(
|
||||
)
|
||||
}
|
||||
runtime.clearOptimisticReconcileToken(args.worktreeId)
|
||||
await runtime.unlinkNotesWorktree(repoId, args.worktreeId)
|
||||
store.removeWorktreeMeta(args.worktreeId)
|
||||
deleteWorktreeHistoryDir(args.worktreeId)
|
||||
invalidateAuthorizedRootsCache()
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { NotesMarkdownStore } from './notes-markdown-store'
|
||||
|
||||
describe('NotesMarkdownStore mutations', () => {
|
||||
let rootPath: string
|
||||
let store: NotesMarkdownStore
|
||||
|
||||
beforeEach(async () => {
|
||||
rootPath = await mkdtemp(join(tmpdir(), 'orca-notes-store-'))
|
||||
store = new NotesMarkdownStore()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(rootPath, { force: true, recursive: true })
|
||||
})
|
||||
|
||||
it('renames the markdown file and keeps the note id stable', async () => {
|
||||
const created = await store.create(
|
||||
{ projectId: 'repo-1', rootPath },
|
||||
{
|
||||
projectId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
title: 'First note',
|
||||
bodyMarkdown: 'body'
|
||||
}
|
||||
)
|
||||
|
||||
const renamed = await store.rename(
|
||||
{ projectId: 'repo-1', rootPath },
|
||||
{
|
||||
projectId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
note: created.note.id,
|
||||
title: 'Renamed note'
|
||||
}
|
||||
)
|
||||
|
||||
expect(renamed.note.id).toBe(created.note.id)
|
||||
expect(renamed.note.title).toBe('Renamed note')
|
||||
expect(renamed.note.relativePath).toContain('renamed-note')
|
||||
|
||||
const listed = await store.list(
|
||||
{ projectId: 'repo-1', rootPath },
|
||||
{ projectId: 'repo-1', worktreeId: 'wt-1' }
|
||||
)
|
||||
expect(listed.notes).toHaveLength(1)
|
||||
expect(listed.notes[0].title).toBe('Renamed note')
|
||||
expect(listed.notes[0].linkKind).toBe('active')
|
||||
})
|
||||
|
||||
it('deletes the markdown file and clears worktree links', async () => {
|
||||
const created = await store.create(
|
||||
{ projectId: 'repo-1', rootPath },
|
||||
{
|
||||
projectId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
title: 'Delete me',
|
||||
bodyMarkdown: 'body'
|
||||
}
|
||||
)
|
||||
|
||||
await store.delete(
|
||||
{ projectId: 'repo-1', rootPath },
|
||||
{ projectId: 'repo-1', worktreeId: 'wt-1', note: created.note.id }
|
||||
)
|
||||
|
||||
const listed = await store.list(
|
||||
{ projectId: 'repo-1', rootPath },
|
||||
{ projectId: 'repo-1', worktreeId: 'wt-1' }
|
||||
)
|
||||
expect(listed.notes).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,586 +0,0 @@
|
||||
/* eslint-disable max-lines -- Why: note file serialization, index updates, and selector resolution need one persistence boundary so Markdown files stay user-owned without splitting active-link invariants across modules. */
|
||||
import { randomBytes } from 'crypto'
|
||||
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'fs/promises'
|
||||
import { join, posix } from 'path'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import type {
|
||||
NoteAppendArgs,
|
||||
NoteCreateArgs,
|
||||
NoteDeleteArgs,
|
||||
NoteDeleteResult,
|
||||
NoteLink,
|
||||
NoteLinkArgs,
|
||||
NoteLinkKind,
|
||||
NoteListArgs,
|
||||
NoteListResult,
|
||||
NoteMutationResult,
|
||||
NoteRecord,
|
||||
NoteRenameArgs,
|
||||
NoteSaveArgs,
|
||||
NoteSearchArgs,
|
||||
NoteShowArgs,
|
||||
NoteShowResult,
|
||||
NoteSummary,
|
||||
NotesPanelOpenState,
|
||||
NotesPanelStateArgs
|
||||
} from '../../shared/notes-types'
|
||||
|
||||
type NotesMarkdownScope = {
|
||||
projectId: string
|
||||
rootPath: string
|
||||
connectionId?: string | null
|
||||
provider?: IFilesystemProvider
|
||||
}
|
||||
|
||||
type NotesIndex = {
|
||||
version: 1
|
||||
activeByWorktree: Record<string, string>
|
||||
referencedByWorktree: Record<string, string[]>
|
||||
}
|
||||
|
||||
type NoteFrontMatter = {
|
||||
id: string
|
||||
title: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
archivedAt: string | null
|
||||
createdBySessionId: string | null
|
||||
updatedBySessionId: string | null
|
||||
revision: number
|
||||
}
|
||||
|
||||
const NOTES_DIR = 'notes'
|
||||
const INDEX_FILE = 'index.json'
|
||||
const DEFAULT_LIMIT = 50
|
||||
const MAX_LIMIT = 200
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `note_${randomBytes(8).toString('hex')}`
|
||||
}
|
||||
|
||||
function clampLimit(limit: number | undefined): number {
|
||||
if (!Number.isFinite(limit) || limit === undefined) {
|
||||
return DEFAULT_LIMIT
|
||||
}
|
||||
return Math.max(1, Math.min(MAX_LIMIT, Math.floor(limit)))
|
||||
}
|
||||
|
||||
function pathJoin(scope: NotesMarkdownScope, ...parts: string[]): string {
|
||||
return scope.connectionId ? posix.join(scope.rootPath, ...parts) : join(scope.rootPath, ...parts)
|
||||
}
|
||||
|
||||
function slugTitle(title: string): string {
|
||||
const slug = title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return slug || 'untitled-note'
|
||||
}
|
||||
|
||||
function isMissingFile(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
((error as NodeJS.ErrnoException).code === 'ENOENT' ||
|
||||
(error as NodeJS.ErrnoException).code === 'ENOTDIR')
|
||||
)
|
||||
}
|
||||
|
||||
function emptyIndex(): NotesIndex {
|
||||
return {
|
||||
version: 1,
|
||||
activeByWorktree: {},
|
||||
referencedByWorktree: {}
|
||||
}
|
||||
}
|
||||
|
||||
function notePreview(bodyMarkdown: string): string {
|
||||
return bodyMarkdown.replace(/\s+/g, ' ').trim().slice(0, 180)
|
||||
}
|
||||
|
||||
function parseFrontMatterValue(raw: string): string | number | null {
|
||||
const value = raw.trim()
|
||||
if (value === 'null') {
|
||||
return null
|
||||
}
|
||||
if (/^\d+$/.test(value)) {
|
||||
return Number.parseInt(value, 10)
|
||||
}
|
||||
if (value.startsWith('"')) {
|
||||
return JSON.parse(value) as string
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseNoteFile(
|
||||
projectId: string,
|
||||
filePath: string,
|
||||
relativePath: string,
|
||||
raw: string
|
||||
): NoteRecord {
|
||||
if (!raw.startsWith('---\n')) {
|
||||
throw new Error('invalid_note_file')
|
||||
}
|
||||
const end = raw.indexOf('\n---\n', 4)
|
||||
if (end === -1) {
|
||||
throw new Error('invalid_note_file')
|
||||
}
|
||||
const frontMatter = raw.slice(4, end)
|
||||
const bodyMarkdown = raw.slice(end + 5)
|
||||
const parsed: Partial<NoteFrontMatter> = {}
|
||||
for (const line of frontMatter.split('\n')) {
|
||||
const index = line.indexOf(':')
|
||||
if (index === -1) {
|
||||
continue
|
||||
}
|
||||
const key = line.slice(0, index).trim() as keyof NoteFrontMatter
|
||||
const value = parseFrontMatterValue(line.slice(index + 1))
|
||||
;(parsed as Record<string, unknown>)[key] = value
|
||||
}
|
||||
if (!parsed.id || !parsed.title || !parsed.createdAt || !parsed.updatedAt) {
|
||||
throw new Error('invalid_note_file')
|
||||
}
|
||||
return {
|
||||
id: parsed.id,
|
||||
projectId,
|
||||
filePath,
|
||||
relativePath,
|
||||
title: parsed.title,
|
||||
bodyMarkdown,
|
||||
revision: typeof parsed.revision === 'number' ? parsed.revision : 1,
|
||||
createdAt: parsed.createdAt,
|
||||
updatedAt: parsed.updatedAt,
|
||||
archivedAt: parsed.archivedAt ?? null,
|
||||
createdBySessionId: parsed.createdBySessionId ?? null,
|
||||
updatedBySessionId: parsed.updatedBySessionId ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function serializeNote(note: NoteRecord): string {
|
||||
const frontMatter: NoteFrontMatter = {
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
createdAt: note.createdAt,
|
||||
updatedAt: note.updatedAt,
|
||||
archivedAt: note.archivedAt,
|
||||
createdBySessionId: note.createdBySessionId ?? null,
|
||||
updatedBySessionId: note.updatedBySessionId ?? null,
|
||||
revision: note.revision
|
||||
}
|
||||
const lines = Object.entries(frontMatter).map(([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
return `${key}: ${JSON.stringify(value)}`
|
||||
}
|
||||
return `${key}: ${value === null ? 'null' : value}`
|
||||
})
|
||||
return `---\n${lines.join('\n')}\n---\n${note.bodyMarkdown}`
|
||||
}
|
||||
|
||||
function linkKindForNote(
|
||||
index: NotesIndex,
|
||||
noteId: string,
|
||||
worktreeId?: string | null
|
||||
): NoteLinkKind | null {
|
||||
if (!worktreeId) {
|
||||
return null
|
||||
}
|
||||
if (index.activeByWorktree[worktreeId] === noteId) {
|
||||
return 'active'
|
||||
}
|
||||
if ((index.referencedByWorktree[worktreeId] ?? []).includes(noteId)) {
|
||||
return 'referenced'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function toSummary(note: NoteRecord, index: NotesIndex, worktreeId?: string | null): NoteSummary {
|
||||
return {
|
||||
...note,
|
||||
preview: notePreview(note.bodyMarkdown),
|
||||
linkKind: linkKindForNote(index, note.id, worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
export class NotesMarkdownStore {
|
||||
async list(scope: NotesMarkdownScope, args: NoteListArgs): Promise<NoteListResult> {
|
||||
const limit = clampLimit(args.limit)
|
||||
const [notes, index] = await Promise.all([this.readNotes(scope), this.readIndex(scope)])
|
||||
const visible = notes
|
||||
.filter((note) => note.archivedAt === null)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
||||
const sorted = visible.sort((left, right) => {
|
||||
const leftLink = linkKindForNote(index, left.id, args.worktreeId)
|
||||
const rightLink = linkKindForNote(index, right.id, args.worktreeId)
|
||||
const rank = (kind: NoteLinkKind | null): number =>
|
||||
kind === 'active' ? 0 : kind === 'referenced' ? 1 : 2
|
||||
return rank(leftLink) - rank(rightLink) || right.updatedAt.localeCompare(left.updatedAt)
|
||||
})
|
||||
return {
|
||||
notes: sorted.slice(0, limit).map((note) => toSummary(note, index, args.worktreeId)),
|
||||
totalCount: sorted.length,
|
||||
truncated: sorted.length > limit
|
||||
}
|
||||
}
|
||||
|
||||
async show(scope: NotesMarkdownScope, args: NoteShowArgs): Promise<NoteShowResult> {
|
||||
const [note, index] = await Promise.all([
|
||||
this.resolveNote(scope, args.note),
|
||||
this.readIndex(scope)
|
||||
])
|
||||
return {
|
||||
note,
|
||||
linkKind: linkKindForNote(index, note.id, args.worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
async create(scope: NotesMarkdownScope, args: NoteCreateArgs): Promise<NoteMutationResult> {
|
||||
const at = nowIso()
|
||||
const id = generateId()
|
||||
const title = args.title.trim() || 'Untitled note'
|
||||
const note: NoteRecord = {
|
||||
id,
|
||||
projectId: scope.projectId,
|
||||
filePath: this.notePath(scope, title, id),
|
||||
relativePath: posix.join(NOTES_DIR, `${slugTitle(title)}-${id}.md`),
|
||||
title,
|
||||
bodyMarkdown: args.bodyMarkdown ?? '',
|
||||
revision: 1,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
archivedAt: null,
|
||||
createdBySessionId: args.createdBySessionId ?? null,
|
||||
updatedBySessionId: args.createdBySessionId ?? null
|
||||
}
|
||||
await this.writeNote(scope, note)
|
||||
if (args.makeActive !== false && args.worktreeId) {
|
||||
await this.setLink(scope, {
|
||||
projectId: args.projectId,
|
||||
worktreeId: args.worktreeId,
|
||||
note: note.id,
|
||||
kind: 'active'
|
||||
})
|
||||
}
|
||||
const index = await this.readIndex(scope)
|
||||
return {
|
||||
note,
|
||||
linkKind: linkKindForNote(index, note.id, args.worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
async save(scope: NotesMarkdownScope, args: NoteSaveArgs): Promise<NoteMutationResult> {
|
||||
const current = await this.resolveNote(scope, args.note)
|
||||
if (args.revision !== undefined && args.revision !== current.revision) {
|
||||
throw new Error('revision_conflict')
|
||||
}
|
||||
const next: NoteRecord = {
|
||||
...current,
|
||||
title: args.title?.trim() || current.title,
|
||||
bodyMarkdown: args.bodyMarkdown,
|
||||
revision: current.revision + 1,
|
||||
updatedAt: nowIso(),
|
||||
updatedBySessionId: args.updatedBySessionId ?? null
|
||||
}
|
||||
await this.writeNote(scope, next)
|
||||
if (args.makeActive === true && args.worktreeId) {
|
||||
await this.setLink(scope, {
|
||||
projectId: args.projectId,
|
||||
worktreeId: args.worktreeId,
|
||||
note: next.id,
|
||||
kind: 'active'
|
||||
})
|
||||
}
|
||||
const index = await this.readIndex(scope)
|
||||
return {
|
||||
note: next,
|
||||
linkKind: linkKindForNote(index, next.id, args.worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
async rename(scope: NotesMarkdownScope, args: NoteRenameArgs): Promise<NoteMutationResult> {
|
||||
const current = await this.resolveNote(scope, args.note)
|
||||
const title = args.title.trim()
|
||||
if (!title) {
|
||||
throw new Error('invalid_note_title')
|
||||
}
|
||||
const nextPath = this.notePath(scope, title, current.id)
|
||||
const nextRelativePath = posix.join(NOTES_DIR, `${slugTitle(title)}-${current.id}.md`)
|
||||
const next: NoteRecord = {
|
||||
...current,
|
||||
filePath: nextPath,
|
||||
relativePath: nextRelativePath,
|
||||
title,
|
||||
revision: current.revision + 1,
|
||||
updatedAt: nowIso(),
|
||||
updatedBySessionId: args.updatedBySessionId ?? null
|
||||
}
|
||||
if (next.filePath !== current.filePath) {
|
||||
await this.renamePath(scope, current.filePath, next.filePath)
|
||||
}
|
||||
await this.writeNote(scope, next)
|
||||
const index = await this.readIndex(scope)
|
||||
return {
|
||||
note: next,
|
||||
linkKind: linkKindForNote(index, next.id, args.worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
async delete(scope: NotesMarkdownScope, args: NoteDeleteArgs): Promise<NoteDeleteResult> {
|
||||
const note = await this.resolveNote(scope, args.note)
|
||||
await this.deletePath(scope, note.filePath)
|
||||
const index = await this.readIndex(scope)
|
||||
for (const [worktreeId, noteId] of Object.entries(index.activeByWorktree)) {
|
||||
if (noteId === note.id) {
|
||||
delete index.activeByWorktree[worktreeId]
|
||||
}
|
||||
}
|
||||
for (const [worktreeId, noteIds] of Object.entries(index.referencedByWorktree)) {
|
||||
const next = noteIds.filter((noteId) => noteId !== note.id)
|
||||
if (next.length === 0) {
|
||||
delete index.referencedByWorktree[worktreeId]
|
||||
} else {
|
||||
index.referencedByWorktree[worktreeId] = next
|
||||
}
|
||||
}
|
||||
await this.writeIndex(scope, index)
|
||||
return {
|
||||
noteId: note.id,
|
||||
projectId: scope.projectId
|
||||
}
|
||||
}
|
||||
|
||||
async append(scope: NotesMarkdownScope, args: NoteAppendArgs): Promise<NoteMutationResult> {
|
||||
const current = await this.resolveNote(scope, args.note)
|
||||
const separator = current.bodyMarkdown.trim().length > 0 ? '\n\n' : ''
|
||||
return await this.save(scope, {
|
||||
projectId: args.projectId,
|
||||
worktreeId: args.worktreeId,
|
||||
note: current.id,
|
||||
title: current.title,
|
||||
bodyMarkdown: `${current.bodyMarkdown}${separator}${args.bodyMarkdown}`,
|
||||
makeActive: args.makeActive,
|
||||
updatedBySessionId: args.updatedBySessionId
|
||||
})
|
||||
}
|
||||
|
||||
async search(scope: NotesMarkdownScope, args: NoteSearchArgs): Promise<NoteListResult> {
|
||||
const limit = clampLimit(args.limit)
|
||||
const query = args.query.trim().toLowerCase()
|
||||
const [notes, index] = await Promise.all([this.readNotes(scope), this.readIndex(scope)])
|
||||
const matches = notes
|
||||
.filter(
|
||||
(note) =>
|
||||
note.archivedAt === null &&
|
||||
(note.title.toLowerCase().includes(query) ||
|
||||
note.bodyMarkdown.toLowerCase().includes(query))
|
||||
)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
||||
return {
|
||||
notes: matches.slice(0, limit).map((note) => toSummary(note, index, args.worktreeId)),
|
||||
totalCount: matches.length,
|
||||
truncated: matches.length > limit
|
||||
}
|
||||
}
|
||||
|
||||
async setLink(scope: NotesMarkdownScope, args: NoteLinkArgs): Promise<NoteLink> {
|
||||
const note = await this.resolveNote(scope, args.note)
|
||||
const index = await this.readIndex(scope)
|
||||
if (args.kind === 'active') {
|
||||
index.activeByWorktree[args.worktreeId] = note.id
|
||||
} else {
|
||||
const existing = index.referencedByWorktree[args.worktreeId] ?? []
|
||||
index.referencedByWorktree[args.worktreeId] = Array.from(new Set([...existing, note.id]))
|
||||
}
|
||||
await this.writeIndex(scope, index)
|
||||
return {
|
||||
noteId: note.id,
|
||||
projectId: args.projectId,
|
||||
worktreeId: args.worktreeId,
|
||||
kind: args.kind,
|
||||
createdAt: nowIso()
|
||||
}
|
||||
}
|
||||
|
||||
async unlinkWorktree(scope: NotesMarkdownScope, worktreeId: string): Promise<void> {
|
||||
const index = await this.readIndex(scope)
|
||||
delete index.activeByWorktree[worktreeId]
|
||||
delete index.referencedByWorktree[worktreeId]
|
||||
await this.writeIndex(scope, index)
|
||||
}
|
||||
|
||||
async resolvePanelOpenState(
|
||||
scope: NotesMarkdownScope | null,
|
||||
args: NotesPanelStateArgs
|
||||
): Promise<NotesPanelOpenState> {
|
||||
if (!scope || !args.projectId) {
|
||||
return { state: 'noProject' }
|
||||
}
|
||||
const [notes, index] = await Promise.all([this.readNotes(scope), this.readIndex(scope)])
|
||||
if (args.worktreeId) {
|
||||
const activeId = index.activeByWorktree[args.worktreeId]
|
||||
const active = notes.find((note) => note.id === activeId && note.archivedAt === null)
|
||||
if (active) {
|
||||
return {
|
||||
state: 'active',
|
||||
projectId: args.projectId,
|
||||
worktreeId: args.worktreeId,
|
||||
note: active
|
||||
}
|
||||
}
|
||||
}
|
||||
const summaries = notes
|
||||
.filter((note) => note.archivedAt === null)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
||||
.map((note) => toSummary(note, index, args.worktreeId))
|
||||
if (summaries.length > 0) {
|
||||
return {
|
||||
state: 'pickerRequired',
|
||||
projectId: args.projectId,
|
||||
worktreeId: args.worktreeId ?? null,
|
||||
notes: summaries
|
||||
}
|
||||
}
|
||||
return {
|
||||
state: 'emptyDraft',
|
||||
projectId: args.projectId,
|
||||
worktreeId: args.worktreeId ?? null
|
||||
}
|
||||
}
|
||||
|
||||
private notesDir(scope: NotesMarkdownScope): string {
|
||||
return pathJoin(scope, NOTES_DIR)
|
||||
}
|
||||
|
||||
private indexPath(scope: NotesMarkdownScope): string {
|
||||
return pathJoin(scope, NOTES_DIR, INDEX_FILE)
|
||||
}
|
||||
|
||||
private notePath(scope: NotesMarkdownScope, title: string, id: string): string {
|
||||
return pathJoin(scope, NOTES_DIR, `${slugTitle(title)}-${id}.md`)
|
||||
}
|
||||
|
||||
private async ensureNotesDir(scope: NotesMarkdownScope): Promise<void> {
|
||||
if (scope.provider) {
|
||||
await scope.provider.createDir(this.notesDir(scope))
|
||||
return
|
||||
}
|
||||
await mkdir(this.notesDir(scope), { recursive: true })
|
||||
}
|
||||
|
||||
private async readText(scope: NotesMarkdownScope, filePath: string): Promise<string> {
|
||||
if (scope.provider) {
|
||||
return (await scope.provider.readFile(filePath)).content
|
||||
}
|
||||
return await readFile(filePath, 'utf8')
|
||||
}
|
||||
|
||||
private async writeText(
|
||||
scope: NotesMarkdownScope,
|
||||
filePath: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
await this.ensureNotesDir(scope)
|
||||
if (scope.provider) {
|
||||
await scope.provider.writeFile(filePath, content)
|
||||
return
|
||||
}
|
||||
await writeFile(filePath, content, 'utf8')
|
||||
}
|
||||
|
||||
private async renamePath(
|
||||
scope: NotesMarkdownScope,
|
||||
oldPath: string,
|
||||
newPath: string
|
||||
): Promise<void> {
|
||||
await this.ensureNotesDir(scope)
|
||||
if (scope.provider) {
|
||||
await scope.provider.rename(oldPath, newPath)
|
||||
return
|
||||
}
|
||||
await rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
private async deletePath(scope: NotesMarkdownScope, filePath: string): Promise<void> {
|
||||
if (scope.provider) {
|
||||
await scope.provider.deletePath(filePath)
|
||||
return
|
||||
}
|
||||
await rm(filePath, { force: true })
|
||||
}
|
||||
|
||||
private async readIndex(scope: NotesMarkdownScope): Promise<NotesIndex> {
|
||||
try {
|
||||
const raw = await this.readText(scope, this.indexPath(scope))
|
||||
const parsed = JSON.parse(raw) as Partial<NotesIndex>
|
||||
return {
|
||||
version: 1,
|
||||
activeByWorktree: parsed.activeByWorktree ?? {},
|
||||
referencedByWorktree: parsed.referencedByWorktree ?? {}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingFile(error)) {
|
||||
return emptyIndex()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async writeIndex(scope: NotesMarkdownScope, index: NotesIndex): Promise<void> {
|
||||
await this.writeText(scope, this.indexPath(scope), `${JSON.stringify(index, null, 2)}\n`)
|
||||
}
|
||||
|
||||
private async readNotes(scope: NotesMarkdownScope): Promise<NoteRecord[]> {
|
||||
let names: string[]
|
||||
try {
|
||||
if (scope.provider) {
|
||||
const entries = await scope.provider.readDir(this.notesDir(scope))
|
||||
names = entries.filter((entry) => !entry.isDirectory).map((entry) => entry.name)
|
||||
} else {
|
||||
names = await readdir(this.notesDir(scope))
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingFile(error)) {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const files = names.filter((name) => name.endsWith('.md'))
|
||||
const notes = await Promise.all(
|
||||
files.map(async (name) => {
|
||||
const filePath = pathJoin(scope, NOTES_DIR, name)
|
||||
const relativePath = posix.join(NOTES_DIR, name)
|
||||
const raw = await this.readText(scope, filePath)
|
||||
return parseNoteFile(scope.projectId, filePath, relativePath, raw)
|
||||
})
|
||||
)
|
||||
return notes
|
||||
}
|
||||
|
||||
private async writeNote(scope: NotesMarkdownScope, note: NoteRecord): Promise<void> {
|
||||
await this.writeText(scope, note.filePath, serializeNote(note))
|
||||
}
|
||||
|
||||
private async resolveNote(scope: NotesMarkdownScope, selector: string): Promise<NoteRecord> {
|
||||
const normalized = selector.trim().toLowerCase()
|
||||
const notes = await this.readNotes(scope)
|
||||
const matches = notes.filter(
|
||||
(note) =>
|
||||
note.archivedAt === null &&
|
||||
(note.id === selector ||
|
||||
note.title.toLowerCase() === normalized ||
|
||||
note.relativePath === selector ||
|
||||
note.filePath === selector)
|
||||
)
|
||||
if (matches.length === 0) {
|
||||
throw new Error('note_not_found')
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error('note_ambiguous')
|
||||
}
|
||||
return matches[0]
|
||||
}
|
||||
}
|
||||
@@ -19,19 +19,6 @@ import {
|
||||
} from '../providers/ssh-filesystem-dispatch'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => '/tmp/orca-user-data')
|
||||
},
|
||||
BrowserWindow: {
|
||||
fromId: vi.fn(() => null)
|
||||
},
|
||||
ipcMain: {
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const {
|
||||
MOCK_GIT_WORKTREES,
|
||||
addWorktreeMock,
|
||||
@@ -2172,120 +2159,6 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), result.setup)
|
||||
})
|
||||
|
||||
it('spawns startup and setup in runtime before revealing activated worktrees', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const activateWorktree = vi.fn()
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-visible-worktree' })
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-visible-startup' })
|
||||
.mockResolvedValueOnce({ id: 'pty-visible-setup' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree,
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-startup-setup')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
scripts: {
|
||||
setup: 'pnpm worktree:setup'
|
||||
}
|
||||
})
|
||||
vi.mocked(createSetupRunnerScript).mockReturnValue({
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup'
|
||||
}
|
||||
})
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-startup-setup',
|
||||
head: 'def',
|
||||
branch: 'runtime-startup-setup',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-startup-setup',
|
||||
runHooks: true,
|
||||
startup: {
|
||||
command: 'codex --prompt "setup"',
|
||||
env: {
|
||||
ORCA_STARTUP_SETUP: '1'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(2)
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/workspaces/runtime-startup-setup',
|
||||
command: 'codex --prompt "setup"',
|
||||
env: expect.objectContaining({
|
||||
ORCA_STARTUP_SETUP: '1'
|
||||
}),
|
||||
worktreeId: result.worktree.id
|
||||
})
|
||||
)
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/workspaces/runtime-startup-setup',
|
||||
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
|
||||
env: expect.objectContaining({
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup'
|
||||
}),
|
||||
worktreeId: result.worktree.id
|
||||
})
|
||||
)
|
||||
expect(revealTerminalSession).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
result.worktree.id,
|
||||
expect.objectContaining({
|
||||
ptyId: 'pty-visible-startup',
|
||||
title: null,
|
||||
activate: false
|
||||
})
|
||||
)
|
||||
expect(revealTerminalSession).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
result.worktree.id,
|
||||
expect.objectContaining({
|
||||
ptyId: 'pty-visible-setup',
|
||||
title: 'Setup',
|
||||
activate: false
|
||||
})
|
||||
)
|
||||
expect(activateWorktree).toHaveBeenCalledWith('repo-1', result.worktree.id, undefined)
|
||||
expect(revealTerminalSession.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
activateWorktree.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('follows normal setup policy for CLI-created worktrees without activating them', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const activateWorktree = vi.fn()
|
||||
@@ -2469,79 +2342,6 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('runs startup commands through background PTYs when worktree activation was not requested', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const activateWorktree = vi.fn()
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-startup-worktree' })
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-startup-worktree' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree,
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-startup-terminal')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-startup-terminal')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue(null)
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-startup-terminal',
|
||||
head: 'def',
|
||||
branch: 'runtime-startup-terminal',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-startup-terminal',
|
||||
startup: {
|
||||
command: 'codex --prompt "summarize"',
|
||||
env: {
|
||||
ORCA_TEST_MODE: '1'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(activateWorktree).not.toHaveBeenCalled()
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/workspaces/runtime-startup-terminal',
|
||||
command: 'codex --prompt "summarize"',
|
||||
env: expect.objectContaining({
|
||||
ORCA_TEST_MODE: '1'
|
||||
}),
|
||||
worktreeId: result.worktree.id,
|
||||
preAllocatedHandle: expect.stringMatching(/^term_/)
|
||||
})
|
||||
)
|
||||
expect(revealTerminalSession).toHaveBeenCalledWith(
|
||||
result.worktree.id,
|
||||
expect.objectContaining({
|
||||
ptyId: 'pty-startup-worktree',
|
||||
title: null,
|
||||
activate: false
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps CLI-created worktrees successful when initial terminal creation fails', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const spawn = vi.fn().mockRejectedValue(new Error('pty unavailable'))
|
||||
@@ -2642,70 +2442,6 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(activateWorktree).toHaveBeenCalledWith('repo-1', expect.any(String), undefined)
|
||||
})
|
||||
|
||||
it('spawns startup commands in runtime before revealing explicitly activated worktrees', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const activateWorktree = vi.fn()
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-visible-startup' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree,
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-visible-startup')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-visible-startup')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue(null)
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-visible-startup',
|
||||
head: 'def',
|
||||
branch: 'runtime-visible-startup',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-visible-startup',
|
||||
activate: true,
|
||||
startup: {
|
||||
command: 'claude --dangerously-skip-permissions',
|
||||
env: {
|
||||
ORCA_VISIBLE_STARTUP: '1'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/workspaces/runtime-visible-startup',
|
||||
command: 'claude --dangerously-skip-permissions',
|
||||
env: expect.objectContaining({
|
||||
ORCA_VISIBLE_STARTUP: '1'
|
||||
}),
|
||||
worktreeId: result.worktree.id
|
||||
})
|
||||
)
|
||||
expect(activateWorktree).toHaveBeenCalledWith('repo-1', result.worktree.id, undefined)
|
||||
})
|
||||
|
||||
it('stamps createdAt alongside lastActivityAt so CLI-created worktrees get the Recent-sort grace window', async () => {
|
||||
// Why: parity with createLocalWorktree / createRemoteWorktree. Without
|
||||
// createdAt, ambient PTY bumps in OTHER worktrees during the few seconds
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import type { AgentStatus } from '../../shared/agent-detection'
|
||||
import { gitExecFileAsync, wslAwareSpawn } from '../git/runner'
|
||||
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { basename, isAbsolute, join } from 'path'
|
||||
import { mkdir, readdir, rm, stat } from 'fs/promises'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
@@ -80,7 +80,7 @@ import { RuntimeBrowserCommands } from './orca-runtime-browser'
|
||||
import { RuntimeFileCommands } from './orca-runtime-files'
|
||||
import { RuntimeGitCommands } from './orca-runtime-git'
|
||||
import { joinWorktreeRelativePath } from './runtime-relative-paths'
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
|
||||
import {
|
||||
getPRForBranch,
|
||||
@@ -239,26 +239,6 @@ import type { CodexAccountService } from '../codex-accounts/service'
|
||||
import type { RateLimitService } from '../rate-limits/service'
|
||||
import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types'
|
||||
import type { RateLimitState } from '../../shared/rate-limit-types'
|
||||
import { NotesMarkdownStore } from '../notes/notes-markdown-store'
|
||||
import type {
|
||||
NoteAppendArgs,
|
||||
NoteCreateArgs,
|
||||
NoteDeleteArgs,
|
||||
NoteDeleteResult,
|
||||
NoteLinkArgs,
|
||||
NoteLink,
|
||||
NoteLinkKind,
|
||||
NoteListArgs,
|
||||
NoteListResult,
|
||||
NoteMutationResult,
|
||||
NoteRenameArgs,
|
||||
NoteSaveArgs,
|
||||
NoteSearchArgs,
|
||||
NoteShowArgs,
|
||||
NoteShowResult,
|
||||
NotesPanelOpenState,
|
||||
NotesPanelStateArgs
|
||||
} from '../../shared/notes-types'
|
||||
import type { VoiceSettings } from '../../shared/speech-types'
|
||||
import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service'
|
||||
|
||||
@@ -783,7 +763,6 @@ export class OrcaRuntimeService {
|
||||
private optimisticReconcileTokens = new Map<string, string>()
|
||||
private readonly getLocalProviderFn: (() => IPtyProvider) | null
|
||||
private accountServices: RuntimeAccountServices | null = null
|
||||
private _notesStore: NotesMarkdownStore | null = null
|
||||
private mobileDictation: {
|
||||
id: string
|
||||
owner: string
|
||||
@@ -838,17 +817,6 @@ export class OrcaRuntimeService {
|
||||
this._orchestrationDb = db
|
||||
}
|
||||
|
||||
getNotesStore(): NotesMarkdownStore {
|
||||
if (!this._notesStore) {
|
||||
this._notesStore = new NotesMarkdownStore()
|
||||
}
|
||||
return this._notesStore
|
||||
}
|
||||
|
||||
setNotesStore(store: NotesMarkdownStore): void {
|
||||
this._notesStore = store
|
||||
}
|
||||
|
||||
getRuntimeId(): string {
|
||||
return this.runtimeId
|
||||
}
|
||||
@@ -4912,205 +4880,6 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
async listNotes(args: { worktreeSelector: string; limit?: number }): Promise<NoteListResult> {
|
||||
const scope = await this.resolveNotesScope(args.worktreeSelector)
|
||||
return await this.getNotesStore().list(this.getNotesScope(scope.projectId), {
|
||||
projectId: scope.projectId,
|
||||
worktreeId: scope.worktreeId,
|
||||
limit: args.limit
|
||||
})
|
||||
}
|
||||
|
||||
async showNote(args: { worktreeSelector: string; note: string }): Promise<NoteShowResult> {
|
||||
const scope = await this.resolveNotesScope(args.worktreeSelector)
|
||||
return await this.getNotesStore().show(this.getNotesScope(scope.projectId), {
|
||||
projectId: scope.projectId,
|
||||
worktreeId: scope.worktreeId,
|
||||
note: args.note
|
||||
})
|
||||
}
|
||||
|
||||
async createNote(args: {
|
||||
worktreeSelector: string
|
||||
title: string
|
||||
bodyMarkdown?: string
|
||||
makeActive?: boolean
|
||||
createdBySessionId?: string | null
|
||||
}): Promise<NoteMutationResult> {
|
||||
const scope = await this.resolveNotesScope(args.worktreeSelector)
|
||||
return await this.getNotesStore().create(this.getNotesScope(scope.projectId), {
|
||||
projectId: scope.projectId,
|
||||
worktreeId: scope.worktreeId,
|
||||
title: args.title,
|
||||
bodyMarkdown: args.bodyMarkdown,
|
||||
makeActive: args.makeActive,
|
||||
createdBySessionId: args.createdBySessionId
|
||||
})
|
||||
}
|
||||
|
||||
async saveNote(args: {
|
||||
worktreeSelector: string
|
||||
note: string
|
||||
title?: string
|
||||
bodyMarkdown: string
|
||||
revision?: number
|
||||
makeActive?: boolean
|
||||
updatedBySessionId?: string | null
|
||||
}): Promise<NoteMutationResult> {
|
||||
const scope = await this.resolveNotesScope(args.worktreeSelector)
|
||||
return await this.getNotesStore().save(this.getNotesScope(scope.projectId), {
|
||||
projectId: scope.projectId,
|
||||
worktreeId: scope.worktreeId,
|
||||
note: args.note,
|
||||
title: args.title,
|
||||
bodyMarkdown: args.bodyMarkdown,
|
||||
revision: args.revision,
|
||||
makeActive: args.makeActive,
|
||||
updatedBySessionId: args.updatedBySessionId
|
||||
})
|
||||
}
|
||||
|
||||
async renameNote(args: {
|
||||
worktreeSelector: string
|
||||
note: string
|
||||
title: string
|
||||
updatedBySessionId?: string | null
|
||||
}): Promise<NoteMutationResult> {
|
||||
const scope = await this.resolveNotesScope(args.worktreeSelector)
|
||||
return await this.getNotesStore().rename(this.getNotesScope(scope.projectId), {
|
||||
projectId: scope.projectId,
|
||||
worktreeId: scope.worktreeId,
|
||||
note: args.note,
|
||||
title: args.title,
|
||||
updatedBySessionId: args.updatedBySessionId
|
||||
})
|
||||
}
|
||||
|
||||
async deleteNote(args: { worktreeSelector: string; note: string }): Promise<NoteDeleteResult> {
|
||||
const scope = await this.resolveNotesScope(args.worktreeSelector)
|
||||
return await this.getNotesStore().delete(this.getNotesScope(scope.projectId), {
|
||||
projectId: scope.projectId,
|
||||
worktreeId: scope.worktreeId,
|
||||
note: args.note
|
||||
})
|
||||
}
|
||||
|
||||
async appendNote(args: {
|
||||
worktreeSelector: string
|
||||
note: string
|
||||
bodyMarkdown: string
|
||||
makeActive?: boolean
|
||||
updatedBySessionId?: string | null
|
||||
}): Promise<NoteMutationResult> {
|
||||
const scope = await this.resolveNotesScope(args.worktreeSelector)
|
||||
return await this.getNotesStore().append(this.getNotesScope(scope.projectId), {
|
||||
projectId: scope.projectId,
|
||||
worktreeId: scope.worktreeId,
|
||||
note: args.note,
|
||||
bodyMarkdown: args.bodyMarkdown,
|
||||
makeActive: args.makeActive,
|
||||
updatedBySessionId: args.updatedBySessionId
|
||||
})
|
||||
}
|
||||
|
||||
async searchNotes(args: {
|
||||
worktreeSelector: string
|
||||
query: string
|
||||
limit?: number
|
||||
}): Promise<NoteListResult> {
|
||||
const scope = await this.resolveNotesScope(args.worktreeSelector)
|
||||
return await this.getNotesStore().search(this.getNotesScope(scope.projectId), {
|
||||
projectId: scope.projectId,
|
||||
worktreeId: scope.worktreeId,
|
||||
query: args.query,
|
||||
limit: args.limit
|
||||
})
|
||||
}
|
||||
|
||||
async linkNote(args: {
|
||||
worktreeSelector: string
|
||||
note: string
|
||||
kind: NoteLinkKind
|
||||
}): Promise<NoteLink> {
|
||||
const scope = await this.resolveNotesScope(args.worktreeSelector)
|
||||
return await this.getNotesStore().setLink(this.getNotesScope(scope.projectId), {
|
||||
projectId: scope.projectId,
|
||||
worktreeId: scope.worktreeId,
|
||||
note: args.note,
|
||||
kind: args.kind
|
||||
})
|
||||
}
|
||||
|
||||
async resolveNotesPanelOpenStateForWorktree(args: {
|
||||
worktreeSelector: string
|
||||
}): Promise<NotesPanelOpenState> {
|
||||
const scope = await this.resolveNotesScope(args.worktreeSelector)
|
||||
return await this.getNotesStore().resolvePanelOpenState(this.getNotesScope(scope.projectId), {
|
||||
projectId: scope.projectId,
|
||||
worktreeId: scope.worktreeId
|
||||
})
|
||||
}
|
||||
|
||||
async listProjectNotes(args: NoteListArgs): Promise<NoteListResult> {
|
||||
this.assertKnownNotesProject(args.projectId)
|
||||
return await this.getNotesStore().list(this.getNotesScope(args.projectId), args)
|
||||
}
|
||||
|
||||
async showProjectNote(args: NoteShowArgs): Promise<NoteShowResult> {
|
||||
this.assertKnownNotesProject(args.projectId)
|
||||
return await this.getNotesStore().show(this.getNotesScope(args.projectId), args)
|
||||
}
|
||||
|
||||
async createProjectNote(args: NoteCreateArgs): Promise<NoteMutationResult> {
|
||||
this.assertKnownNotesProject(args.projectId)
|
||||
return await this.getNotesStore().create(this.getNotesScope(args.projectId), args)
|
||||
}
|
||||
|
||||
async saveProjectNote(args: NoteSaveArgs): Promise<NoteMutationResult> {
|
||||
this.assertKnownNotesProject(args.projectId)
|
||||
return await this.getNotesStore().save(this.getNotesScope(args.projectId), args)
|
||||
}
|
||||
|
||||
async renameProjectNote(args: NoteRenameArgs): Promise<NoteMutationResult> {
|
||||
this.assertKnownNotesProject(args.projectId)
|
||||
return await this.getNotesStore().rename(this.getNotesScope(args.projectId), args)
|
||||
}
|
||||
|
||||
async deleteProjectNote(args: NoteDeleteArgs): Promise<NoteDeleteResult> {
|
||||
this.assertKnownNotesProject(args.projectId)
|
||||
return await this.getNotesStore().delete(this.getNotesScope(args.projectId), args)
|
||||
}
|
||||
|
||||
async appendProjectNote(args: NoteAppendArgs): Promise<NoteMutationResult> {
|
||||
this.assertKnownNotesProject(args.projectId)
|
||||
return await this.getNotesStore().append(this.getNotesScope(args.projectId), args)
|
||||
}
|
||||
|
||||
async searchProjectNotes(args: NoteSearchArgs): Promise<NoteListResult> {
|
||||
this.assertKnownNotesProject(args.projectId)
|
||||
return await this.getNotesStore().search(this.getNotesScope(args.projectId), args)
|
||||
}
|
||||
|
||||
async linkProjectNote(args: NoteLinkArgs) {
|
||||
this.assertKnownNotesProject(args.projectId)
|
||||
return await this.getNotesStore().setLink(this.getNotesScope(args.projectId), args)
|
||||
}
|
||||
|
||||
async unlinkNotesWorktree(projectId: string, worktreeId: string): Promise<void> {
|
||||
this.assertKnownNotesProject(projectId)
|
||||
await this.getNotesStore().unlinkWorktree(this.getNotesScope(projectId), worktreeId)
|
||||
}
|
||||
|
||||
async resolveNotesPanelOpenState(args: NotesPanelStateArgs): Promise<NotesPanelOpenState> {
|
||||
if (args.projectId) {
|
||||
this.assertKnownNotesProject(args.projectId)
|
||||
}
|
||||
return await this.getNotesStore().resolvePanelOpenState(
|
||||
args.projectId ? this.getNotesScope(args.projectId) : null,
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
async listManagedWorktrees(
|
||||
repoSelector?: string,
|
||||
limit = DEFAULT_WORKTREE_LIST_LIMIT
|
||||
@@ -6030,7 +5799,6 @@ export class OrcaRuntimeService {
|
||||
// remains locked — other worktrees cannot check it out.
|
||||
await gitExecFileAsync(['worktree', 'prune'], { cwd: repo.path }).catch(() => {})
|
||||
this.clearOptimisticReconcileToken(worktree.id)
|
||||
await this.getNotesStore().unlinkWorktree(this.getNotesScope(repo.id), worktree.id)
|
||||
this.store.removeWorktreeMeta(worktree.id)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
invalidateAuthorizedRootsCache()
|
||||
@@ -6043,7 +5811,6 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
this.clearOptimisticReconcileToken(worktree.id)
|
||||
await this.getNotesStore().unlinkWorktree(this.getNotesScope(repo.id), worktree.id)
|
||||
this.store.removeWorktreeMeta(worktree.id)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
invalidateAuthorizedRootsCache()
|
||||
@@ -6726,42 +6493,6 @@ export class OrcaRuntimeService {
|
||||
throw new Error('selector_not_found')
|
||||
}
|
||||
|
||||
private async resolveNotesScope(worktreeSelector: string): Promise<{
|
||||
projectId: string
|
||||
worktreeId: string
|
||||
}> {
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
return {
|
||||
projectId: worktree.repoId,
|
||||
worktreeId: worktree.id
|
||||
}
|
||||
}
|
||||
|
||||
private assertKnownNotesProject(projectId: string): void {
|
||||
if (!this.store?.getRepo(projectId)) {
|
||||
throw new Error('repo_not_found')
|
||||
}
|
||||
}
|
||||
|
||||
private getNotesScope(projectId: string): {
|
||||
projectId: string
|
||||
rootPath: string
|
||||
} {
|
||||
const repo = this.store?.getRepo(projectId)
|
||||
if (!repo) {
|
||||
throw new Error('repo_not_found')
|
||||
}
|
||||
const identity = `${repo.connectionId ?? 'local'}:${repo.path}`
|
||||
const notesRoot = createHash('sha256').update(identity).digest('hex').slice(0, 24)
|
||||
return {
|
||||
projectId,
|
||||
// Why: notes are Orca workspace memory, not repo source files. Keeping
|
||||
// them in userData prevents accidental git commits while still sharing
|
||||
// one notes folder across every Orca worktree for the same repo.
|
||||
rootPath: join(app.getPath('userData'), 'project-notes', notesRoot)
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveRepoSelector(selector: string): Promise<Repo> {
|
||||
if (!this.store) {
|
||||
throw new Error('repo_not_found')
|
||||
|
||||
@@ -43,9 +43,6 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
|
||||
'terminal_gone',
|
||||
'no_active_terminal',
|
||||
'repo_not_found',
|
||||
'note_not_found',
|
||||
'note_ambiguous',
|
||||
'revision_conflict',
|
||||
'timeout',
|
||||
'invalid_limit'
|
||||
])
|
||||
|
||||
@@ -17,7 +17,6 @@ import { GIT_METHODS } from './git'
|
||||
import { GITHUB_METHODS } from './github'
|
||||
import { HOSTED_REVIEW_METHODS } from './hosted-review'
|
||||
import { LINEAR_METHODS } from './linear'
|
||||
import { NOTE_METHODS } from './notes'
|
||||
import { SPEECH_METHODS } from './speech'
|
||||
|
||||
// Why: a flat manifest keeps registration order explicit and provides one
|
||||
@@ -42,6 +41,5 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
|
||||
...GITHUB_METHODS,
|
||||
...HOSTED_REVIEW_METHODS,
|
||||
...LINEAR_METHODS,
|
||||
...NOTE_METHODS,
|
||||
...SPEECH_METHODS
|
||||
]
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { RpcRequest } from '../core'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import { NOTE_METHODS } from './notes'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('notes RPC methods', () => {
|
||||
it('routes note reads through the selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listNotes: vi.fn().mockResolvedValue({ notes: [], totalCount: 0, truncated: false }),
|
||||
showNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS })
|
||||
|
||||
await dispatcher.dispatch(makeRequest('note.list', { worktree: 'id:wt-1', limit: 50 }))
|
||||
await dispatcher.dispatch(makeRequest('note.show', { worktree: 'id:wt-1', note: 'note-1' }))
|
||||
|
||||
expect(runtime.listNotes).toHaveBeenCalledWith({ worktreeSelector: 'id:wt-1', limit: 50 })
|
||||
expect(runtime.showNote).toHaveBeenCalledWith({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
note: 'note-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes note mutations through the selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
createNote: vi.fn().mockResolvedValue({ note: { id: 'created' }, linkKind: 'active' }),
|
||||
saveNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: 'active' }),
|
||||
renameNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }),
|
||||
deleteNote: vi.fn().mockResolvedValue({ noteId: 'note-1', projectId: 'repo-1' }),
|
||||
appendNote: vi.fn().mockResolvedValue({ note: { id: 'note-1' }, linkKind: null }),
|
||||
linkNote: vi.fn().mockResolvedValue({
|
||||
noteId: 'note-1',
|
||||
projectId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
kind: 'active',
|
||||
createdAt: 'now'
|
||||
})
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS })
|
||||
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('note.create', {
|
||||
worktree: 'id:wt-1',
|
||||
title: 'Plan',
|
||||
bodyMarkdown: 'body',
|
||||
makeActive: true
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('note.save', {
|
||||
worktree: 'id:wt-1',
|
||||
note: 'note-1',
|
||||
title: 'Plan v2',
|
||||
bodyMarkdown: 'updated',
|
||||
revision: 2,
|
||||
makeActive: true
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('note.rename', { worktree: 'id:wt-1', note: 'note-1', title: 'Renamed' })
|
||||
)
|
||||
await dispatcher.dispatch(makeRequest('note.delete', { worktree: 'id:wt-1', note: 'note-1' }))
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('note.append', {
|
||||
worktree: 'id:wt-1',
|
||||
note: 'note-1',
|
||||
bodyMarkdown: 'more',
|
||||
makeActive: true
|
||||
})
|
||||
)
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('note.link', { worktree: 'id:wt-1', note: 'note-1', kind: 'active' })
|
||||
)
|
||||
|
||||
expect(runtime.createNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
title: 'Plan',
|
||||
bodyMarkdown: 'body',
|
||||
makeActive: true
|
||||
})
|
||||
)
|
||||
expect(runtime.saveNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
note: 'note-1',
|
||||
title: 'Plan v2',
|
||||
bodyMarkdown: 'updated',
|
||||
revision: 2,
|
||||
makeActive: true
|
||||
})
|
||||
)
|
||||
expect(runtime.renameNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
note: 'note-1',
|
||||
title: 'Renamed'
|
||||
})
|
||||
)
|
||||
expect(runtime.deleteNote).toHaveBeenCalledWith({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
note: 'note-1'
|
||||
})
|
||||
expect(runtime.appendNote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
note: 'note-1',
|
||||
bodyMarkdown: 'more',
|
||||
makeActive: true
|
||||
})
|
||||
)
|
||||
expect(runtime.linkNote).toHaveBeenCalledWith({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
note: 'note-1',
|
||||
kind: 'active'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes panel state and search through the selected worktree', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
searchNotes: vi.fn().mockResolvedValue({ notes: [], totalCount: 0, truncated: false }),
|
||||
resolveNotesPanelOpenStateForWorktree: vi.fn().mockResolvedValue({ state: 'emptyDraft' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: NOTE_METHODS })
|
||||
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('note.search', { worktree: 'id:wt-1', query: 'todo', limit: 20 })
|
||||
)
|
||||
await dispatcher.dispatch(makeRequest('note.panelState', { worktree: 'id:wt-1' }))
|
||||
|
||||
expect(runtime.searchNotes).toHaveBeenCalledWith({
|
||||
worktreeSelector: 'id:wt-1',
|
||||
query: 'todo',
|
||||
limit: 20
|
||||
})
|
||||
expect(runtime.resolveNotesPanelOpenStateForWorktree).toHaveBeenCalledWith({
|
||||
worktreeSelector: 'id:wt-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,165 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcAnyMethod } from '../core'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
|
||||
const NoteScopedParams = z.object({
|
||||
worktree: requiredString('Missing worktree selector')
|
||||
})
|
||||
|
||||
const NoteListParams = NoteScopedParams.extend({
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
const NoteShowParams = NoteScopedParams.extend({
|
||||
note: requiredString('Missing note selector')
|
||||
})
|
||||
|
||||
const NoteCreateParams = NoteScopedParams.extend({
|
||||
title: requiredString('Missing note title'),
|
||||
bodyMarkdown: OptionalString,
|
||||
makeActive: z.boolean().optional(),
|
||||
createdBySessionId: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
const NoteSaveParams = NoteScopedParams.extend({
|
||||
note: requiredString('Missing note selector'),
|
||||
title: OptionalString,
|
||||
bodyMarkdown: requiredString('Missing note body'),
|
||||
revision: OptionalFiniteNumber,
|
||||
makeActive: z.boolean().optional(),
|
||||
updatedBySessionId: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
const NoteRenameParams = NoteScopedParams.extend({
|
||||
note: requiredString('Missing note selector'),
|
||||
title: requiredString('Missing note title'),
|
||||
updatedBySessionId: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
const NoteDeleteParams = NoteScopedParams.extend({
|
||||
note: requiredString('Missing note selector')
|
||||
})
|
||||
|
||||
const NoteAppendParams = NoteScopedParams.extend({
|
||||
note: requiredString('Missing note selector'),
|
||||
bodyMarkdown: requiredString('Missing note body'),
|
||||
makeActive: z.boolean().optional(),
|
||||
updatedBySessionId: z.string().nullable().optional()
|
||||
})
|
||||
|
||||
const NoteSearchParams = NoteScopedParams.extend({
|
||||
query: requiredString('Missing search query'),
|
||||
limit: OptionalFiniteNumber
|
||||
})
|
||||
|
||||
const NoteLinkParams = NoteScopedParams.extend({
|
||||
note: requiredString('Missing note selector'),
|
||||
kind: z.enum(['active', 'referenced'])
|
||||
})
|
||||
|
||||
export const NOTE_METHODS: readonly RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'note.list',
|
||||
params: NoteListParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.listNotes({
|
||||
worktreeSelector: params.worktree,
|
||||
limit: params.limit
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'note.show',
|
||||
params: NoteShowParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.showNote({
|
||||
worktreeSelector: params.worktree,
|
||||
note: params.note
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'note.create',
|
||||
params: NoteCreateParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.createNote({
|
||||
worktreeSelector: params.worktree,
|
||||
title: params.title,
|
||||
bodyMarkdown: params.bodyMarkdown,
|
||||
makeActive: params.makeActive,
|
||||
createdBySessionId: params.createdBySessionId
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'note.save',
|
||||
params: NoteSaveParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.saveNote({
|
||||
worktreeSelector: params.worktree,
|
||||
note: params.note,
|
||||
title: params.title,
|
||||
bodyMarkdown: params.bodyMarkdown,
|
||||
revision: params.revision,
|
||||
makeActive: params.makeActive,
|
||||
updatedBySessionId: params.updatedBySessionId
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'note.rename',
|
||||
params: NoteRenameParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.renameNote({
|
||||
worktreeSelector: params.worktree,
|
||||
note: params.note,
|
||||
title: params.title,
|
||||
updatedBySessionId: params.updatedBySessionId
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'note.delete',
|
||||
params: NoteDeleteParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.deleteNote({
|
||||
worktreeSelector: params.worktree,
|
||||
note: params.note
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'note.append',
|
||||
params: NoteAppendParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.appendNote({
|
||||
worktreeSelector: params.worktree,
|
||||
note: params.note,
|
||||
bodyMarkdown: params.bodyMarkdown,
|
||||
makeActive: params.makeActive,
|
||||
updatedBySessionId: params.updatedBySessionId
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'note.search',
|
||||
params: NoteSearchParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.searchNotes({
|
||||
worktreeSelector: params.worktree,
|
||||
query: params.query,
|
||||
limit: params.limit
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'note.link',
|
||||
params: NoteLinkParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.linkNote({
|
||||
worktreeSelector: params.worktree,
|
||||
note: params.note,
|
||||
kind: params.kind
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'note.panelState',
|
||||
params: NoteScopedParams,
|
||||
handler: async (params, { runtime }) =>
|
||||
await runtime.resolveNotesPanelOpenStateForWorktree({
|
||||
worktreeSelector: params.worktree
|
||||
})
|
||||
})
|
||||
]
|
||||
@@ -207,24 +207,6 @@ import type {
|
||||
AutomationRun,
|
||||
AutomationUpdateInput
|
||||
} from '../shared/automations-types'
|
||||
import type {
|
||||
NoteAppendArgs,
|
||||
NoteCreateArgs,
|
||||
NoteDeleteArgs,
|
||||
NoteDeleteResult,
|
||||
NoteLinkArgs,
|
||||
NoteLink,
|
||||
NoteListArgs,
|
||||
NoteListResult,
|
||||
NoteMutationResult,
|
||||
NoteRenameArgs,
|
||||
NoteSaveArgs,
|
||||
NoteSearchArgs,
|
||||
NoteShowArgs,
|
||||
NoteShowResult,
|
||||
NotesPanelOpenState,
|
||||
NotesPanelStateArgs
|
||||
} from '../shared/notes-types'
|
||||
|
||||
export type BrowserApi = {
|
||||
registerGuest: (args: {
|
||||
@@ -1281,18 +1263,6 @@ export type PreloadApi = {
|
||||
connectionId?: string
|
||||
}) => Promise<string | null>
|
||||
}
|
||||
notes: {
|
||||
list: (args: NoteListArgs) => Promise<NoteListResult>
|
||||
show: (args: NoteShowArgs) => Promise<NoteShowResult>
|
||||
create: (args: NoteCreateArgs) => Promise<NoteMutationResult>
|
||||
save: (args: NoteSaveArgs) => Promise<NoteMutationResult>
|
||||
rename: (args: NoteRenameArgs) => Promise<NoteMutationResult>
|
||||
delete: (args: NoteDeleteArgs) => Promise<NoteDeleteResult>
|
||||
append: (args: NoteAppendArgs) => Promise<NoteMutationResult>
|
||||
search: (args: NoteSearchArgs) => Promise<NoteListResult>
|
||||
link: (args: NoteLinkArgs) => Promise<NoteLink>
|
||||
panelState: (args: NotesPanelStateArgs) => Promise<NotesPanelOpenState>
|
||||
}
|
||||
ui: {
|
||||
get: () => Promise<PersistedUIState>
|
||||
set: (args: Partial<PersistedUIState>) => Promise<void>
|
||||
|
||||
@@ -338,19 +338,6 @@ const api = {
|
||||
isAvailable: (): Promise<boolean> => ipcRenderer.invoke('pwsh:isAvailable')
|
||||
},
|
||||
|
||||
notes: {
|
||||
list: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:list', args),
|
||||
show: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:show', args),
|
||||
create: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:create', args),
|
||||
save: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:save', args),
|
||||
rename: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:rename', args),
|
||||
delete: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:delete', args),
|
||||
append: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:append', args),
|
||||
search: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:search', args),
|
||||
link: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:link', args),
|
||||
panelState: (args: unknown): Promise<unknown> => ipcRenderer.invoke('notes:panelState', args)
|
||||
},
|
||||
|
||||
repos: {
|
||||
list: (): Promise<unknown[]> => ipcRenderer.invoke('repos:list'),
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
type EditorRequestFileCloseDetail,
|
||||
requestEditorSaveQuiesce
|
||||
} from './editor/editor-autosave'
|
||||
import { requestProjectNotesTabClose } from '@/lib/project-notes-close-request'
|
||||
import { isUpdaterQuitAndInstallInProgress } from '@/lib/updater-beforeunload'
|
||||
import EditorAutosaveController from './editor/EditorAutosaveController'
|
||||
import type { TabGroupLayoutNode } from '../../../shared/types'
|
||||
@@ -50,7 +49,6 @@ import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
|
||||
import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
|
||||
import { shouldRepairActiveTerminalTab } from './terminal/active-terminal-repair'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { openProjectNotesTab } from '@/lib/open-project-notes-tab'
|
||||
import {
|
||||
getEffectiveLayoutForWorktree as getEffectiveLayout,
|
||||
anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout
|
||||
@@ -656,16 +654,6 @@ function Terminal(): React.JSX.Element | null {
|
||||
}
|
||||
}, [activeWorktreeId, openFile])
|
||||
|
||||
const handleNewNotesTab = useCallback(
|
||||
(noteId?: string) => {
|
||||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
void openProjectNotesTab(activeWorktreeId, noteId)
|
||||
},
|
||||
[activeWorktreeId]
|
||||
)
|
||||
|
||||
const handleCloseTab = useCallback(
|
||||
(tabId: string) => {
|
||||
const state = useAppStore.getState()
|
||||
@@ -986,13 +974,6 @@ function Terminal(): React.JSX.Element | null {
|
||||
handleCloseFile(state.activeFileId)
|
||||
} else if (state.activeTabType === 'browser' && state.activeBrowserTabId) {
|
||||
closeBrowserTab(state.activeBrowserTabId)
|
||||
} else if (state.activeTabType === 'notes') {
|
||||
const activeTab = activeWorktreeId ? state.getActiveTab(activeWorktreeId) : null
|
||||
if (activeTab?.contentType === 'notes') {
|
||||
requestProjectNotesTabClose(activeTab.id, () => {
|
||||
state.closeUnifiedTab(activeTab.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1211,7 +1192,6 @@ function Terminal(): React.JSX.Element | null {
|
||||
onNewTerminalWithShell={handleNewTab}
|
||||
onNewBrowserTab={handleNewBrowserTab}
|
||||
onNewFileTab={handleNewFile}
|
||||
onNewNotesTab={handleNewNotesTab}
|
||||
wslAvailable={wslAvailable}
|
||||
onSetCustomTitle={setTabCustomTitle}
|
||||
onSetTabColor={setTabColor}
|
||||
|
||||
@@ -228,7 +228,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
const deferredQuery = useDeferredValue(query)
|
||||
const [selectedItemId, setSelectedItemId] = useState('')
|
||||
const previousWorktreeIdRef = useRef<string | null>(null)
|
||||
const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal' | 'notes'>('terminal')
|
||||
const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal'>('terminal')
|
||||
const previousBrowserPageIdRef = useRef<string | null>(null)
|
||||
const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview')
|
||||
const wasVisibleRef = useRef(false)
|
||||
|
||||
@@ -257,7 +257,7 @@ function EditorPanelInner({
|
||||
settings,
|
||||
renameDialogFile?.runtimeEnvironmentId
|
||||
)?.activeRuntimeEnvironmentId?.trim() ||
|
||||
(renameDialogFile ? getConnectionId(renameDialogFile.worktreeId) : null)
|
||||
(renameDialogFile ? getConnectionId(renameDialogFile.worktreeId) : null)
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable max-lines -- Why: the floating surface owns both terminal chrome and local notes tabs until the shared floating shell is extracted. */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import TabBar from '@/components/tab-bar/TabBar'
|
||||
import TerminalPane from '@/components/terminal-pane/TerminalPane'
|
||||
@@ -11,15 +10,10 @@ import {
|
||||
isOrchestrationSetupDismissed,
|
||||
notifyOrchestrationSetupStateChanged
|
||||
} from '@/lib/orchestration-setup-state'
|
||||
import { notifyProjectNotesSelectionChanged } from '@/lib/open-project-notes-tab'
|
||||
import { requestProjectNotesTabClose } from '@/lib/project-notes-close-request'
|
||||
import { linkRuntimeProjectNote, showRuntimeProjectNote } from '@/runtime/runtime-notes-client'
|
||||
import { useAppStore } from '@/store'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog'
|
||||
import ProjectNotesTabContent from '@/components/notes/ProjectNotesTabContent'
|
||||
import { FloatingTerminalResizeHandles } from './FloatingTerminalResizeHandles'
|
||||
import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls'
|
||||
export { FloatingTerminalToggleButton } from './FloatingTerminalToggleButton'
|
||||
@@ -30,7 +24,6 @@ import {
|
||||
type FloatingTerminalPanelBounds
|
||||
} from './floating-terminal-panel-bounds'
|
||||
const EMPTY_TERMINAL_TABS: TerminalTab[] = []
|
||||
type FloatingNotesTab = { id: string; label: string; noteId: string | null; isDirty: boolean }
|
||||
|
||||
type FloatingTerminalPanelProps = {
|
||||
open: boolean
|
||||
@@ -53,7 +46,6 @@ export function FloatingTerminalPanel({
|
||||
const setTabPaneExpanded = useAppStore((s) => s.setTabPaneExpanded)
|
||||
const tabBarOrder = useAppStore((s) => s.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID])
|
||||
const floatingTerminalCwd = useAppStore((s) => s.settings?.floatingTerminalCwd ?? '~')
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
|
||||
const [cwd, setCwd] = useState<string | null>(null)
|
||||
const [bounds, setBounds] = useState(() => getDefaultFloatingTerminalBounds())
|
||||
@@ -62,9 +54,6 @@ export function FloatingTerminalPanel({
|
||||
const [showOrchestrationSetup, setShowOrchestrationSetup] = useState(
|
||||
() => !hasOrchestrationSetupMarker() && !isOrchestrationSetupDismissed()
|
||||
)
|
||||
const [notesTabs, setNotesTabs] = useState<FloatingNotesTab[]>([])
|
||||
const [activeNotesTabId, setActiveNotesTabId] = useState<string | null>(null)
|
||||
const [activeSurface, setActiveSurface] = useState<'terminal' | 'notes'>('terminal')
|
||||
const restoreBoundsRef = useRef<FloatingTerminalPanelBounds | null>(null)
|
||||
const normalizedInitialBoundsRef = useRef(false)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
@@ -155,7 +144,6 @@ export function FloatingTerminalPanel({
|
||||
const createFloatingTab = useCallback(() => {
|
||||
const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false })
|
||||
setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tab.id)
|
||||
setActiveSurface('terminal')
|
||||
const state = useAppStore.getState()
|
||||
const currentTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
|
||||
const stored = state.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
|
||||
@@ -174,7 +162,6 @@ export function FloatingTerminalPanel({
|
||||
const closeFloatingTab = useCallback(
|
||||
(tabId: string) => {
|
||||
closeTab(tabId)
|
||||
setActiveSurface('terminal')
|
||||
},
|
||||
[closeTab]
|
||||
)
|
||||
@@ -203,73 +190,6 @@ export function FloatingTerminalPanel({
|
||||
[closeTab, tabs]
|
||||
)
|
||||
|
||||
const openFloatingNotesTab = useCallback(
|
||||
async (noteId?: string) => {
|
||||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const worktree = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((candidate) => candidate.id === activeWorktreeId)
|
||||
const projectId =
|
||||
state.repos.find((candidate) => candidate.id === worktree?.repoId)?.id ?? worktree?.repoId
|
||||
const settings = state.settings
|
||||
let label = 'Project Notes'
|
||||
if (noteId && projectId) {
|
||||
try {
|
||||
const result = await showRuntimeProjectNote(settings, {
|
||||
projectId,
|
||||
worktreeId: activeWorktreeId,
|
||||
note: noteId
|
||||
})
|
||||
label = result.note.title
|
||||
} catch {
|
||||
label = 'Project Notes'
|
||||
}
|
||||
if (projectId) {
|
||||
await linkRuntimeProjectNote(settings, {
|
||||
projectId,
|
||||
worktreeId: activeWorktreeId,
|
||||
note: noteId,
|
||||
kind: 'active'
|
||||
})
|
||||
notifyProjectNotesSelectionChanged()
|
||||
}
|
||||
}
|
||||
const id = `floating-project-notes:${createBrowserUuid()}`
|
||||
setNotesTabs((current) => [...current, { id, label, noteId: noteId ?? null, isDirty: false }])
|
||||
setActiveNotesTabId(id)
|
||||
setActiveSurface('notes')
|
||||
},
|
||||
[activeWorktreeId]
|
||||
)
|
||||
|
||||
const closeFloatingNotesTab = useCallback((tabId: string) => {
|
||||
requestProjectNotesTabClose(tabId, () => {
|
||||
setNotesTabs((current) => current.filter((tab) => tab.id !== tabId))
|
||||
setActiveNotesTabId((current) => (current === tabId ? null : current))
|
||||
setActiveSurface('terminal')
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || activeSurface !== 'notes' || !activeNotesTabId) {
|
||||
return
|
||||
}
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
const mod = isMac ? event.metaKey : event.ctrlKey
|
||||
if (!mod || event.shiftKey || event.repeat || event.key.toLowerCase() !== 'w') {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
closeFloatingNotesTab(activeNotesTabId)
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [activeNotesTabId, activeSurface, closeFloatingNotesTab, open])
|
||||
|
||||
const toggleMaximized = useCallback(() => {
|
||||
setMaximized((current) => {
|
||||
if (current) {
|
||||
@@ -372,31 +292,19 @@ export function FloatingTerminalPanel({
|
||||
activeTabId={activeTab?.id ?? null}
|
||||
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
|
||||
expandedPaneByTabId={expandedPaneByTabId}
|
||||
onActivate={(tabId) => {
|
||||
setActiveSurface('terminal')
|
||||
setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tabId)
|
||||
}}
|
||||
onActivate={(tabId) => setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tabId)}
|
||||
onClose={closeFloatingTab}
|
||||
onCloseOthers={closeOthers}
|
||||
onCloseToRight={closeToRight}
|
||||
onNewTerminalTab={createFloatingTab}
|
||||
onNewBrowserTab={() => {}}
|
||||
onNewNotesTab={activeWorktreeId ? openFloatingNotesTab : undefined}
|
||||
notesWorktreeId={activeWorktreeId}
|
||||
terminalOnly
|
||||
onSetCustomTitle={setTabCustomTitle}
|
||||
onSetTabColor={setTabColor}
|
||||
onTogglePaneExpand={(tabId) =>
|
||||
setTabPaneExpanded(tabId, expandedPaneByTabId[tabId] !== true)
|
||||
}
|
||||
notesTabs={activeWorktreeId ? notesTabs : []}
|
||||
activeNotesTabId={activeSurface === 'notes' ? activeNotesTabId : null}
|
||||
onActivateNotesTab={(tabId) => {
|
||||
setActiveNotesTabId(tabId)
|
||||
setActiveSurface('notes')
|
||||
}}
|
||||
onCloseNotesTab={closeFloatingNotesTab}
|
||||
activeTabType={activeSurface}
|
||||
activeTabType="terminal"
|
||||
tabBarOrder={tabBarOrder}
|
||||
/>
|
||||
</div>
|
||||
@@ -408,42 +316,27 @@ export function FloatingTerminalPanel({
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-background">
|
||||
{activeSurface === 'notes' && activeWorktreeId && activeNotesTabId ? (
|
||||
<ProjectNotesTabContent
|
||||
key={activeNotesTabId}
|
||||
worktreeId={activeWorktreeId}
|
||||
tabId={activeNotesTabId}
|
||||
noteId={notesTabs.find((tab) => tab.id === activeNotesTabId)?.noteId ?? null}
|
||||
forceNew={notesTabs.find((tab) => tab.id === activeNotesTabId)?.noteId === null}
|
||||
onDirtyChange={(dirty) => {
|
||||
setNotesTabs((current) =>
|
||||
current.map((tab) =>
|
||||
tab.id === activeNotesTabId ? { ...tab, isDirty: dirty } : tab
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : cwd ? (
|
||||
tabs.map((tab) => (
|
||||
<div
|
||||
key={`${tab.id}-${tab.generation ?? 0}`}
|
||||
className={
|
||||
tab.id === activeTab?.id ? 'absolute inset-0' : 'absolute inset-0 hidden'
|
||||
}
|
||||
aria-hidden={tab.id !== activeTab?.id}
|
||||
>
|
||||
<TerminalPane
|
||||
tabId={tab.id}
|
||||
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
|
||||
cwd={cwd}
|
||||
isActive={tab.id === activeTab?.id}
|
||||
isVisible={tab.id === activeTab?.id}
|
||||
onPtyExit={() => closeTab(tab.id)}
|
||||
onCloseTab={() => closeTab(tab.id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : null}
|
||||
{cwd
|
||||
? tabs.map((tab) => (
|
||||
<div
|
||||
key={`${tab.id}-${tab.generation ?? 0}`}
|
||||
className={
|
||||
tab.id === activeTab?.id ? 'absolute inset-0' : 'absolute inset-0 hidden'
|
||||
}
|
||||
aria-hidden={tab.id !== activeTab?.id}
|
||||
>
|
||||
<TerminalPane
|
||||
tabId={tab.id}
|
||||
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
|
||||
cwd={cwd}
|
||||
isActive={tab.id === activeTab?.id}
|
||||
isVisible={tab.id === activeTab?.id}
|
||||
onPtyExit={() => closeTab(tab.id)}
|
||||
onCloseTab={() => closeTab(tab.id)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
{showOrchestrationSetup ? (
|
||||
|
||||
@@ -1,699 +0,0 @@
|
||||
/* eslint-disable max-lines -- Why: project notes keep picker, save-as, and
|
||||
rich/source/preview markdown modes together so unsaved note creation and
|
||||
active-note switching cannot drift across separate surfaces. */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Copy, FileText, MoreHorizontal, Plus, Save } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import RichMarkdownEditor from '@/components/editor/RichMarkdownEditor'
|
||||
import MarkdownPreview from '@/components/editor/MarkdownPreview'
|
||||
import EditorViewToggle from '@/components/editor/EditorViewToggle'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useRepoById, useWorktreeById } from '@/store/selectors'
|
||||
import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events'
|
||||
import {
|
||||
getProjectNotesEntityId,
|
||||
notifyProjectNotesSelectionChanged
|
||||
} from '@/lib/open-project-notes-tab'
|
||||
import {
|
||||
createRuntimeProjectNote,
|
||||
listRuntimeProjectNotes,
|
||||
linkRuntimeProjectNote,
|
||||
resolveRuntimeNotesPanelState,
|
||||
saveRuntimeProjectNote,
|
||||
showRuntimeProjectNote
|
||||
} from '@/runtime/runtime-notes-client'
|
||||
import {
|
||||
ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT,
|
||||
type ProjectNotesCloseRequestDetail
|
||||
} from '@/lib/project-notes-close-request'
|
||||
import { toast } from 'sonner'
|
||||
import type { MarkdownDocument } from '../../../../shared/types'
|
||||
import type { NoteRecord, NoteSummary, NotesPanelOpenState } from '../../../../shared/notes-types'
|
||||
import type { MarkdownViewMode } from '@/store/slices/editor'
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 700
|
||||
|
||||
type Draft = {
|
||||
id: string | null
|
||||
filePath: string | null
|
||||
relativePath: string | null
|
||||
title: string
|
||||
bodyMarkdown: string
|
||||
revision: number | null
|
||||
}
|
||||
|
||||
function emptyDraft(): Draft {
|
||||
return {
|
||||
id: null,
|
||||
filePath: null,
|
||||
relativePath: null,
|
||||
title: 'Untitled note',
|
||||
bodyMarkdown: '',
|
||||
revision: null
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProjectNotesTabContent({
|
||||
worktreeId,
|
||||
tabId,
|
||||
noteId = null,
|
||||
forceNew = false,
|
||||
onDirtyChange
|
||||
}: {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
noteId?: string | null
|
||||
forceNew?: boolean
|
||||
onDirtyChange?: (dirty: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const worktree = useWorktreeById(worktreeId)
|
||||
const repo = useRepoById(worktree?.repoId ?? null)
|
||||
const projectId = repo?.id ?? worktree?.repoId ?? null
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
|
||||
const [panelState, setPanelState] = useState<NotesPanelOpenState>({ state: 'noProject' })
|
||||
const [notes, setNotes] = useState<NoteSummary[]>([])
|
||||
const [draft, setDraft] = useState<Draft>(() => emptyDraft())
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<MarkdownViewMode>('rich')
|
||||
const [saveAsOpen, setSaveAsOpen] = useState(false)
|
||||
const [closePromptOpen, setClosePromptOpen] = useState(false)
|
||||
const [saveAsTitle, setSaveAsTitle] = useState('Untitled note')
|
||||
const [selectedNoteId, setSelectedNoteId] = useState<string | null>(noteId)
|
||||
const saveAsInputRef = useRef<HTMLInputElement>(null)
|
||||
const pendingCreateBodyRef = useRef<string | null>(null)
|
||||
const pendingCloseRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const canSave = projectId !== null && (draft.id === null || draft.title.trim().length > 0)
|
||||
|
||||
const refreshNotes = useCallback(async (): Promise<void> => {
|
||||
if (!projectId) {
|
||||
setNotes([])
|
||||
return
|
||||
}
|
||||
const result = await listRuntimeProjectNotes(settings, { projectId, worktreeId, limit: 100 })
|
||||
setNotes(result.notes)
|
||||
}, [projectId, settings, worktreeId])
|
||||
|
||||
const loadPanelState = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setError(null)
|
||||
if (selectedNoteId && projectId) {
|
||||
const result = await showRuntimeProjectNote(settings, {
|
||||
projectId,
|
||||
worktreeId,
|
||||
note: selectedNoteId
|
||||
})
|
||||
setPanelState({ state: 'active', projectId, worktreeId, note: result.note })
|
||||
setDraft({
|
||||
id: result.note.id,
|
||||
filePath: result.note.filePath,
|
||||
relativePath: result.note.relativePath,
|
||||
title: result.note.title,
|
||||
bodyMarkdown: result.note.bodyMarkdown,
|
||||
revision: result.note.revision
|
||||
})
|
||||
setDirty(false)
|
||||
const state = useAppStore.getState()
|
||||
state.setTabLabel(tabId, result.note.title)
|
||||
state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id))
|
||||
await refreshNotes()
|
||||
return
|
||||
}
|
||||
if (forceNew && projectId) {
|
||||
setPanelState({ state: 'emptyDraft', projectId, worktreeId })
|
||||
setDraft(emptyDraft())
|
||||
setDirty(false)
|
||||
useAppStore.getState().setTabLabel(tabId, 'Project Notes')
|
||||
await refreshNotes()
|
||||
return
|
||||
}
|
||||
const next = await resolveRuntimeNotesPanelState(settings, { projectId, worktreeId })
|
||||
setPanelState(next)
|
||||
if (next.state === 'active') {
|
||||
setDraft({
|
||||
id: next.note.id,
|
||||
filePath: next.note.filePath,
|
||||
relativePath: next.note.relativePath,
|
||||
title: next.note.title,
|
||||
bodyMarkdown: next.note.bodyMarkdown,
|
||||
revision: next.note.revision
|
||||
})
|
||||
const state = useAppStore.getState()
|
||||
state.setTabLabel(tabId, next.note.title)
|
||||
state.setTabEntityId(tabId, getProjectNotesEntityId(next.projectId, next.note.id))
|
||||
setDirty(false)
|
||||
} else {
|
||||
setDraft(emptyDraft())
|
||||
useAppStore
|
||||
.getState()
|
||||
.setTabEntityId(tabId, getProjectNotesEntityId(projectId ?? worktreeId))
|
||||
setDirty(false)
|
||||
}
|
||||
if (next.state === 'pickerRequired') {
|
||||
setNotes(next.notes)
|
||||
} else {
|
||||
await refreshNotes()
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}, [forceNew, projectId, refreshNotes, selectedNoteId, settings, tabId, worktreeId])
|
||||
|
||||
useEffect(() => {
|
||||
void loadPanelState()
|
||||
}, [loadPanelState])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedNoteId(noteId)
|
||||
}, [noteId])
|
||||
|
||||
useEffect(() => {
|
||||
useAppStore.getState().setTabDirty(tabId, dirty)
|
||||
onDirtyChange?.(dirty)
|
||||
}, [dirty, onDirtyChange, tabId])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (): void => {
|
||||
if (dirty) {
|
||||
return
|
||||
}
|
||||
void loadPanelState()
|
||||
}
|
||||
window.addEventListener(NOTES_ACTIVE_CHANGED_EVENT, listener)
|
||||
return () => window.removeEventListener(NOTES_ACTIVE_CHANGED_EVENT, listener)
|
||||
}, [dirty, loadPanelState])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: Event): void => {
|
||||
const detail = (event as CustomEvent<ProjectNotesCloseRequestDetail>).detail
|
||||
if (!detail || detail.tabId !== tabId) {
|
||||
return
|
||||
}
|
||||
detail.claim()
|
||||
if (!dirty) {
|
||||
detail.close()
|
||||
return
|
||||
}
|
||||
pendingCloseRef.current = detail.close
|
||||
setClosePromptOpen(true)
|
||||
}
|
||||
window.addEventListener(ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, listener)
|
||||
return () => window.removeEventListener(ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, listener)
|
||||
}, [dirty, tabId])
|
||||
|
||||
const selectNote = useCallback(
|
||||
async (noteId: string): Promise<void> => {
|
||||
if (!projectId) {
|
||||
return
|
||||
}
|
||||
const result = await showRuntimeProjectNote(settings, { projectId, worktreeId, note: noteId })
|
||||
await linkRuntimeProjectNote(settings, {
|
||||
projectId,
|
||||
worktreeId,
|
||||
note: noteId,
|
||||
kind: 'active'
|
||||
})
|
||||
setSelectedNoteId(noteId)
|
||||
setDraft({
|
||||
id: result.note.id,
|
||||
filePath: result.note.filePath,
|
||||
relativePath: result.note.relativePath,
|
||||
title: result.note.title,
|
||||
bodyMarkdown: result.note.bodyMarkdown,
|
||||
revision: result.note.revision
|
||||
})
|
||||
setPanelState({ state: 'active', projectId, worktreeId, note: result.note })
|
||||
const state = useAppStore.getState()
|
||||
state.setTabLabel(tabId, result.note.title)
|
||||
state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id))
|
||||
setDirty(false)
|
||||
await refreshNotes()
|
||||
},
|
||||
[projectId, refreshNotes, settings, tabId, worktreeId]
|
||||
)
|
||||
|
||||
const saveDraft = useCallback(
|
||||
async (bodyOverride?: string): Promise<NoteRecord | null> => {
|
||||
if (!canSave || !projectId) {
|
||||
return null
|
||||
}
|
||||
const bodyMarkdown = bodyOverride ?? draft.bodyMarkdown
|
||||
if (!draft.id) {
|
||||
// Why: project notes are markdown files. Match untitled Markdown tabs
|
||||
// by asking for the user-facing name before creating the file instead
|
||||
// of silently persisting "Untitled note" from autosave.
|
||||
pendingCreateBodyRef.current = bodyMarkdown
|
||||
setSaveAsTitle(draft.title.trim() || 'Untitled note')
|
||||
setSaveAsOpen(true)
|
||||
return null
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await saveRuntimeProjectNote(settings, {
|
||||
projectId,
|
||||
worktreeId,
|
||||
note: draft.id,
|
||||
title: draft.title,
|
||||
bodyMarkdown,
|
||||
revision: draft.revision ?? undefined,
|
||||
makeActive: true
|
||||
})
|
||||
setDraft({
|
||||
id: result.note.id,
|
||||
filePath: result.note.filePath,
|
||||
relativePath: result.note.relativePath,
|
||||
title: result.note.title,
|
||||
bodyMarkdown: result.note.bodyMarkdown,
|
||||
revision: result.note.revision
|
||||
})
|
||||
setPanelState({ state: 'active', projectId, worktreeId, note: result.note })
|
||||
setSelectedNoteId(result.note.id)
|
||||
const state = useAppStore.getState()
|
||||
state.setTabLabel(tabId, result.note.title)
|
||||
state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id))
|
||||
setDirty(false)
|
||||
await refreshNotes()
|
||||
notifyProjectNotesSelectionChanged()
|
||||
return result.note
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
return null
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
},
|
||||
[canSave, draft, projectId, refreshNotes, settings, tabId, worktreeId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty || !canSave || !draft.id) {
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
void saveDraft()
|
||||
}, SAVE_DEBOUNCE_MS)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [canSave, dirty, draft.id, saveDraft])
|
||||
|
||||
const createNewDraft = useCallback(() => {
|
||||
setSelectedNoteId(null)
|
||||
setDraft(emptyDraft())
|
||||
const state = useAppStore.getState()
|
||||
state.setTabLabel(tabId, 'Project Notes')
|
||||
state.setTabEntityId(tabId, getProjectNotesEntityId(projectId ?? worktreeId))
|
||||
setPanelState(
|
||||
projectId ? { state: 'emptyDraft', projectId, worktreeId } : { state: 'noProject' }
|
||||
)
|
||||
setDirty(false)
|
||||
}, [projectId, tabId, worktreeId])
|
||||
|
||||
const markdownDocuments = useMemo<MarkdownDocument[]>(
|
||||
() =>
|
||||
notes.map((note) => ({
|
||||
filePath: note.filePath,
|
||||
relativePath: note.relativePath,
|
||||
basename: note.relativePath.split('/').pop() ?? note.title,
|
||||
name: note.title
|
||||
})),
|
||||
[notes]
|
||||
)
|
||||
|
||||
const editorFilePath =
|
||||
draft.filePath ?? `orca://project-notes/${projectId ?? 'project'}/untitled.md`
|
||||
const editorPathLabel = draft.filePath
|
||||
? (draft.relativePath ?? draft.title)
|
||||
: `notes/${draft.title.trim() || 'untitled'}.md`
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
}
|
||||
}, [error])
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveAsOpen) {
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => saveAsInputRef.current?.select())
|
||||
}, [saveAsOpen])
|
||||
|
||||
const confirmCreateNote = useCallback(async (): Promise<void> => {
|
||||
if (!projectId) {
|
||||
return
|
||||
}
|
||||
const title = saveAsTitle.trim().replace(/\.md$/i, '')
|
||||
if (!title) {
|
||||
setError('Name cannot be empty')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await createRuntimeProjectNote(settings, {
|
||||
projectId,
|
||||
worktreeId,
|
||||
title,
|
||||
bodyMarkdown: pendingCreateBodyRef.current ?? draft.bodyMarkdown,
|
||||
makeActive: true
|
||||
})
|
||||
setDraft({
|
||||
id: result.note.id,
|
||||
filePath: result.note.filePath,
|
||||
relativePath: result.note.relativePath,
|
||||
title: result.note.title,
|
||||
bodyMarkdown: result.note.bodyMarkdown,
|
||||
revision: result.note.revision
|
||||
})
|
||||
setPanelState({ state: 'active', projectId, worktreeId, note: result.note })
|
||||
setSelectedNoteId(result.note.id)
|
||||
const state = useAppStore.getState()
|
||||
state.setTabLabel(tabId, result.note.title)
|
||||
state.setTabEntityId(tabId, getProjectNotesEntityId(projectId, result.note.id))
|
||||
setDirty(false)
|
||||
setSaveAsOpen(false)
|
||||
pendingCreateBodyRef.current = null
|
||||
await refreshNotes()
|
||||
notifyProjectNotesSelectionChanged()
|
||||
const pendingClose = pendingCloseRef.current
|
||||
if (pendingClose) {
|
||||
pendingCloseRef.current = null
|
||||
pendingClose()
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [draft.bodyMarkdown, projectId, refreshNotes, saveAsTitle, settings, tabId, worktreeId])
|
||||
|
||||
const handleClosePromptSave = useCallback(async (): Promise<void> => {
|
||||
setClosePromptOpen(false)
|
||||
const saved = await saveDraft()
|
||||
if (!saved) {
|
||||
if (draft.id) {
|
||||
setClosePromptOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
const pendingClose = pendingCloseRef.current
|
||||
pendingCloseRef.current = null
|
||||
pendingClose?.()
|
||||
}, [draft.id, saveDraft])
|
||||
|
||||
const handleClosePromptDiscard = useCallback(() => {
|
||||
setClosePromptOpen(false)
|
||||
setDirty(false)
|
||||
const pendingClose = pendingCloseRef.current
|
||||
pendingCloseRef.current = null
|
||||
pendingClose?.()
|
||||
}, [])
|
||||
|
||||
const handleClosePromptCancel = useCallback(() => {
|
||||
setClosePromptOpen(false)
|
||||
pendingCloseRef.current = null
|
||||
}, [])
|
||||
|
||||
const handleSaveAsCancel = useCallback(() => {
|
||||
setSaveAsOpen(false)
|
||||
pendingCreateBodyRef.current = null
|
||||
pendingCloseRef.current = null
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col bg-background">
|
||||
<div className="editor-header">
|
||||
<div className="editor-header-text">
|
||||
<div className="editor-header-path-row">
|
||||
<button
|
||||
type="button"
|
||||
className="editor-header-path"
|
||||
title={editorFilePath}
|
||||
onClick={() => {
|
||||
void window.api.ui.writeClipboardText(editorFilePath)
|
||||
}}
|
||||
>
|
||||
{editorPathLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EditorViewToggle
|
||||
value={viewMode}
|
||||
modes={['source', 'rich', 'preview']}
|
||||
onChange={(next) => setViewMode(next as MarkdownViewMode)}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="More actions"
|
||||
title="More actions"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel>Current note</DropdownMenuLabel>
|
||||
<div className="px-2 pb-2">
|
||||
<Input
|
||||
value={draft.title}
|
||||
disabled={panelState.state === 'noProject'}
|
||||
onChange={(event) => {
|
||||
setDraft((current) => ({ ...current, title: event.target.value }))
|
||||
setDirty(true)
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
placeholder="Note title"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuItem onSelect={createNewDraft}>
|
||||
<Plus className="size-3.5" />
|
||||
New note
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
void window.api.ui.writeClipboardText(draft.bodyMarkdown)
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
Copy Markdown
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!canSave || saving} onSelect={() => void saveDraft()}>
|
||||
<Save className="size-3.5" />
|
||||
Save now
|
||||
</DropdownMenuItem>
|
||||
{notes.length > 0 ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Switch note</DropdownMenuLabel>
|
||||
{notes.map((note) => (
|
||||
<DropdownMenuItem key={note.id} onSelect={() => void selectNote(note.id)}>
|
||||
<FileText className="size-3.5" />
|
||||
<span className="truncate">{note.title}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
{panelState.state === 'noProject' ? (
|
||||
<div className="flex h-full items-center justify-center px-4 text-sm text-muted-foreground">
|
||||
Open a project worktree to use notes.
|
||||
</div>
|
||||
) : panelState.state === 'pickerRequired' ? (
|
||||
<div className="flex h-full flex-col overflow-y-auto px-8 py-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">Project Notes</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Choose a saved note or start a new one.
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline" onClick={createNewDraft}>
|
||||
<Plus className="size-3.5" />
|
||||
New note
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{notes.map((note) => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
className="flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-left hover:bg-accent/45"
|
||||
onClick={() => void selectNote(note.id)}
|
||||
>
|
||||
<FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{note.title}
|
||||
</span>
|
||||
<span className="mt-0.5 line-clamp-2 block text-xs leading-5 text-muted-foreground">
|
||||
{note.preview || note.relativePath}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'source' ? (
|
||||
<textarea
|
||||
className="h-full w-full resize-none bg-background px-8 py-6 font-mono text-[13px] leading-6 text-foreground outline-none scrollbar-editor"
|
||||
value={draft.bodyMarkdown}
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
setDraft((current) => ({ ...current, bodyMarkdown: event.target.value }))
|
||||
setDirty(true)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const saveShortcut = isMac ? event.metaKey : event.ctrlKey
|
||||
if (saveShortcut && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault()
|
||||
void saveDraft()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : viewMode === 'preview' ? (
|
||||
<MarkdownPreview
|
||||
content={draft.bodyMarkdown}
|
||||
filePath={editorFilePath}
|
||||
scrollCacheKey={`notes:${draft.id ?? 'new'}:preview`}
|
||||
markdownDocuments={markdownDocuments}
|
||||
onOpenDocument={(document) => {
|
||||
const note = notes.find((candidate) => candidate.filePath === document.filePath)
|
||||
if (note) {
|
||||
void selectNote(note.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<RichMarkdownEditor
|
||||
fileId={draft.id ?? 'new-note'}
|
||||
content={draft.bodyMarkdown}
|
||||
filePath={editorFilePath}
|
||||
worktreeId={worktreeId}
|
||||
scrollCacheKey={`notes:${draft.id ?? 'new'}`}
|
||||
onContentChange={(content) => {
|
||||
setDraft((current) => ({ ...current, bodyMarkdown: content }))
|
||||
setDirty(true)
|
||||
}}
|
||||
onDirtyStateHint={(nextDirty) => {
|
||||
if (nextDirty) {
|
||||
setDirty(true)
|
||||
}
|
||||
}}
|
||||
onSave={(content) => {
|
||||
setDraft((current) => ({ ...current, bodyMarkdown: content }))
|
||||
setDirty(true)
|
||||
void saveDraft(content)
|
||||
}}
|
||||
markdownDocuments={markdownDocuments}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Dialog
|
||||
open={saveAsOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (isOpen) {
|
||||
return
|
||||
}
|
||||
handleSaveAsCancel()
|
||||
}}
|
||||
>
|
||||
<DialogContent showCloseButton={false} className="max-w-[340px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Save project note</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Name this markdown note before saving it in Orca project notes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] font-medium text-muted-foreground">Name</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
ref={saveAsInputRef}
|
||||
value={saveAsTitle}
|
||||
onChange={(event) => setSaveAsTitle(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void confirmCreateNote()
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
placeholder="note name"
|
||||
/>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">.md</span>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="mt-1">
|
||||
<Button variant="outline" size="sm" onClick={handleSaveAsCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" disabled={saving} onClick={() => void confirmCreateNote()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={closePromptOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
handleClosePromptCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Unsaved Changes</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
"{draft.title.trim() || 'Project Notes'}" has unsaved changes. Do you want
|
||||
to save before closing?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleClosePromptCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleClosePromptDiscard}>
|
||||
Don't Save
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={saving}
|
||||
onClick={() => void handleClosePromptSave()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Copy, FileText, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useActiveWorktree, useRepoById } from '@/store/selectors'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getProjectNotesEntityId, openProjectNotesTab } from '@/lib/open-project-notes-tab'
|
||||
import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events'
|
||||
import {
|
||||
deleteRuntimeProjectNote,
|
||||
listRuntimeProjectNotes,
|
||||
renameRuntimeProjectNote
|
||||
} from '@/runtime/runtime-notes-client'
|
||||
import type { NoteSummary } from '../../../../shared/notes-types'
|
||||
|
||||
export default function NotesPanel(): React.JSX.Element {
|
||||
const activeWorktree = useActiveWorktree()
|
||||
const repo = useRepoById(activeWorktree?.repoId ?? null)
|
||||
const projectId = repo?.id ?? activeWorktree?.repoId ?? null
|
||||
const worktreeId = activeWorktree?.id ?? null
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
|
||||
const [notes, setNotes] = useState<NoteSummary[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [renamingNoteId, setRenamingNoteId] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<NoteSummary | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const renameCommittedRef = useRef(false)
|
||||
const deleteConfirmButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const refresh = useCallback(async (): Promise<void> => {
|
||||
if (!projectId || !worktreeId) {
|
||||
setNotes([])
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await listRuntimeProjectNotes(settings, { projectId, worktreeId, limit: 100 })
|
||||
setNotes(result.notes)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [projectId, settings, worktreeId])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (): void => {
|
||||
void refresh()
|
||||
}
|
||||
window.addEventListener(NOTES_ACTIVE_CHANGED_EVENT, listener)
|
||||
return () => window.removeEventListener(NOTES_ACTIVE_CHANGED_EVENT, listener)
|
||||
}, [refresh])
|
||||
|
||||
const openNotes = useCallback(() => {
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
void openProjectNotesTab(worktreeId)
|
||||
}, [worktreeId])
|
||||
|
||||
const selectNote = useCallback(
|
||||
async (noteId: string): Promise<void> => {
|
||||
if (!projectId || !worktreeId) {
|
||||
return
|
||||
}
|
||||
await openProjectNotesTab(worktreeId, noteId)
|
||||
await refresh()
|
||||
},
|
||||
[projectId, refresh, worktreeId]
|
||||
)
|
||||
|
||||
const startRename = useCallback((note: NoteSummary) => {
|
||||
renameCommittedRef.current = false
|
||||
setRenamingNoteId(note.id)
|
||||
setRenameValue(note.title)
|
||||
}, [])
|
||||
|
||||
const commitRename = useCallback(
|
||||
async (note: NoteSummary): Promise<void> => {
|
||||
if (renameCommittedRef.current) {
|
||||
return
|
||||
}
|
||||
renameCommittedRef.current = true
|
||||
const title = renameValue.trim()
|
||||
setRenamingNoteId(null)
|
||||
if (!projectId || !worktreeId || !title || title === note.title) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await renameRuntimeProjectNote(settings, {
|
||||
projectId,
|
||||
worktreeId,
|
||||
note: note.id,
|
||||
title
|
||||
})
|
||||
const entityId = getProjectNotesEntityId(projectId, note.id)
|
||||
const state = useAppStore.getState()
|
||||
for (const tabs of Object.values(state.unifiedTabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
if (tab.contentType === 'notes' && tab.entityId === entityId) {
|
||||
state.setTabLabel(tab.id, result.note.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : `Failed to rename '${note.title}'.`)
|
||||
}
|
||||
},
|
||||
[projectId, refresh, renameValue, settings, worktreeId]
|
||||
)
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
renameCommittedRef.current = true
|
||||
setRenamingNoteId(null)
|
||||
}, [])
|
||||
|
||||
const confirmDeleteNote = useCallback(async (): Promise<void> => {
|
||||
if (!projectId || !worktreeId || !deleteTarget || deleting) {
|
||||
return
|
||||
}
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteRuntimeProjectNote(settings, { projectId, worktreeId, note: deleteTarget.id })
|
||||
const entityId = getProjectNotesEntityId(projectId, deleteTarget.id)
|
||||
const state = useAppStore.getState()
|
||||
const tabIdsToClose: string[] = []
|
||||
for (const tabs of Object.values(state.unifiedTabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
if (tab.contentType === 'notes' && tab.entityId === entityId) {
|
||||
tabIdsToClose.push(tab.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const tabId of tabIdsToClose) {
|
||||
useAppStore.getState().closeUnifiedTab(tabId)
|
||||
}
|
||||
await refresh()
|
||||
toast.success(`'${deleteTarget.title}' deleted`)
|
||||
setDeleteTarget(null)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : `Failed to delete '${deleteTarget.title}'.`)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [deleteTarget, deleting, projectId, refresh, settings, worktreeId])
|
||||
|
||||
const copyNotePath = useCallback(async (note: NoteSummary): Promise<void> => {
|
||||
await navigator.clipboard.writeText(note.relativePath)
|
||||
toast.success('Note path copied')
|
||||
}, [])
|
||||
|
||||
if (!projectId || !worktreeId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">
|
||||
Open a project worktree to use notes.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-foreground">Project Notes</div>
|
||||
<div className="line-clamp-2 text-[11px] leading-4 text-muted-foreground">
|
||||
Shared across all workspaces for {repo?.displayName ?? 'this repo'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Refresh notes"
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="New project notes tab"
|
||||
onClick={openNotes}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="border-b border-border px-3 py-2 text-xs text-destructive">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-1">
|
||||
{notes.length === 0 ? (
|
||||
<div className="px-3 py-4">
|
||||
<div className="text-xs font-medium text-foreground">No project notes yet</div>
|
||||
<div className="mt-1 text-[11px] leading-4 text-muted-foreground">
|
||||
Create one to keep repo context available across every workspace.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
notes.map((note) => (
|
||||
<ContextMenu key={note.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="mx-1.5 flex w-[calc(100%-0.75rem)] items-start gap-2 rounded-md px-2 py-2 text-left hover:bg-accent/45"
|
||||
onClick={() => {
|
||||
if (renamingNoteId !== note.id) {
|
||||
void selectNote(note.id)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (renamingNoteId !== note.id) {
|
||||
void selectNote(note.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
{renamingNoteId === note.id ? (
|
||||
<Input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => setRenameValue(event.target.value)}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onBlur={() => void commitRename(note)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
void commitRename(note)
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
cancelRename()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="block truncate text-xs font-medium text-foreground">
|
||||
{note.title}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-0.5 line-clamp-2 block text-[11px] leading-4 text-muted-foreground">
|
||||
{note.preview || note.relativePath}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => void selectNote(note.id)}>
|
||||
<FileText className="size-3.5" />
|
||||
Open
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => startRename(note)}>
|
||||
<Pencil className="size-3.5" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => void copyNotePath(note)}>
|
||||
<Copy className="size-3.5" />
|
||||
Copy Path
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem variant="destructive" onSelect={() => setDeleteTarget(note)}>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
return
|
||||
}
|
||||
setDeleteTarget(null)
|
||||
setDeleting(false)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
deleteConfirmButtonRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Delete Project Note</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Delete{' '}
|
||||
<span className="break-all font-medium text-foreground">{deleteTarget?.title}</span>?
|
||||
This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{deleteTarget ? (
|
||||
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
|
||||
<div className="break-all font-medium text-foreground">{deleteTarget.title}</div>
|
||||
<div className="mt-1 break-all text-muted-foreground">
|
||||
{deleteTarget.relativePath}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={deleting}
|
||||
onClick={() => {
|
||||
setDeleteTarget(null)
|
||||
setDeleting(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
ref={deleteConfirmButtonRef}
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={() => void confirmDeleteNote()}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Files, Search, GitBranch, ListChecks, Cable, PanelRight, FileText } from 'lucide-react'
|
||||
import { Files, Search, GitBranch, ListChecks, Cable, PanelRight } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getRepoMapFromState, useActiveWorktree, useRepoById } from '@/store/selectors'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -22,7 +22,6 @@ import SourceControl from './SourceControl'
|
||||
import SearchPanel from './Search'
|
||||
import ChecksPanel from './ChecksPanel'
|
||||
import PortsPanel from './PortsPanel'
|
||||
import NotesPanel from './NotesPanel'
|
||||
|
||||
const MIN_WIDTH = 220
|
||||
// Why: long file names (e.g. construction drawing sheets, multi-part document
|
||||
@@ -88,12 +87,6 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [
|
||||
title: 'Search',
|
||||
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}F`
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
icon: FileText,
|
||||
title: 'Project Notes',
|
||||
shortcut: ''
|
||||
},
|
||||
{
|
||||
id: 'source-control',
|
||||
icon: GitBranch,
|
||||
@@ -252,7 +245,6 @@ function RightSidebarInner(): React.JSX.Element {
|
||||
{effectiveTab === 'source-control' && <SourceControl />}
|
||||
{effectiveTab === 'checks' && <ChecksPanel />}
|
||||
{effectiveTab === 'ports' && <PortsPanel />}
|
||||
{effectiveTab === 'notes' && <NotesPanel />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -731,9 +731,6 @@ const WorktreeList = React.memo(function WorktreeList() {
|
||||
const needsTabs = showActiveOnly || sortBy === 'smart'
|
||||
const tabsByWorktree = useAppStore((s) => (needsTabs ? s.tabsByWorktree : null))
|
||||
const ptyIdsByTabId = useAppStore((s) => (needsTabs ? s.ptyIdsByTabId : null))
|
||||
const unifiedTabsByWorktree = useAppStore((s) =>
|
||||
showActiveOnly ? s.unifiedTabsByWorktree : null
|
||||
)
|
||||
const browserTabsByWorktree = useAppStore((s) =>
|
||||
showActiveOnly ? s.browserTabsByWorktree : null
|
||||
)
|
||||
@@ -989,7 +986,6 @@ const WorktreeList = React.memo(function WorktreeList() {
|
||||
showActiveOnly,
|
||||
tabsByWorktree,
|
||||
ptyIdsByTabId,
|
||||
unifiedTabsByWorktree,
|
||||
browserTabsByWorktree,
|
||||
activeWorktreeId,
|
||||
hideDefaultBranchWorkspace,
|
||||
@@ -1004,7 +1000,6 @@ const WorktreeList = React.memo(function WorktreeList() {
|
||||
repoMap,
|
||||
tabsByWorktree,
|
||||
ptyIdsByTabId,
|
||||
unifiedTabsByWorktree,
|
||||
browserTabsByWorktree,
|
||||
sortedIds,
|
||||
worktreeMap,
|
||||
|
||||
@@ -13,9 +13,6 @@ import {
|
||||
export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
|
||||
const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId] ?? EMPTY_TABS)
|
||||
const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS)
|
||||
const hasNotesSurface = useAppStore((s) =>
|
||||
(s.unifiedTabsByWorktree[worktreeId] ?? []).some((tab) => tab.contentType === 'notes')
|
||||
)
|
||||
const runtimePaneTitlesForWorktree = useAppStore(
|
||||
useShallow((s) => selectRuntimePaneTitlesForWorktree(s, worktreeId))
|
||||
)
|
||||
@@ -74,7 +71,6 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
|
||||
browserTabs,
|
||||
ptyIdsByTabId: ptyIdsForWorktree,
|
||||
runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree,
|
||||
hasNotesSurface,
|
||||
hasPermission,
|
||||
hasLiveDone,
|
||||
hasRetainedDone
|
||||
@@ -84,7 +80,6 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
|
||||
browserTabs,
|
||||
ptyIdsForWorktree,
|
||||
runtimePaneTitlesForWorktree,
|
||||
hasNotesSurface,
|
||||
hasPermission,
|
||||
hasLiveDone,
|
||||
hasRetainedDone
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
isDefaultBranchWorkspace,
|
||||
sidebarHasActiveFilters
|
||||
} from './visible-worktrees'
|
||||
import type { Repo, Tab, TerminalTab, Worktree } from '../../../../shared/types'
|
||||
import type { Repo, TerminalTab, Worktree } from '../../../../shared/types'
|
||||
|
||||
function makeTab(id: string, worktreeId: string, ptyId: string | null): TerminalTab {
|
||||
return {
|
||||
@@ -21,21 +21,6 @@ function makeTab(id: string, worktreeId: string, ptyId: string | null): Terminal
|
||||
}
|
||||
}
|
||||
|
||||
function makeNotesTab(id: string, worktreeId: string): Tab {
|
||||
return {
|
||||
id,
|
||||
entityId: `notes:${worktreeId}:${id}`,
|
||||
groupId: 'group-1',
|
||||
worktreeId,
|
||||
contentType: 'notes',
|
||||
label: 'Project Notes',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function makeWorktree(id: string, repoId = 'repo1'): Worktree {
|
||||
return {
|
||||
id,
|
||||
@@ -110,22 +95,6 @@ describe('computeVisibleWorktreeIds', () => {
|
||||
expect(result).toEqual([wt.id])
|
||||
})
|
||||
|
||||
it('treats project-notes tabs as active for the active-only filter', () => {
|
||||
const notesWt = makeWorktree('wt-notes')
|
||||
const unrelatedWt = makeWorktree('wt-unrelated')
|
||||
|
||||
const result = computeVisibleWorktreeIds(
|
||||
{ repo1: [notesWt, unrelatedWt] },
|
||||
[notesWt.id, unrelatedWt.id],
|
||||
visibleOptions({
|
||||
showActiveOnly: true,
|
||||
unifiedTabsByWorktree: { [notesWt.id]: [makeNotesTab('notes-1', notesWt.id)] }
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual([notesWt.id])
|
||||
})
|
||||
|
||||
it('keeps the currently active worktree visible even without PTYs', () => {
|
||||
const wt = makeWorktree('wt-active')
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Worktree, Repo, Tab, TerminalTab } from '../../../../shared/types'
|
||||
import type { Worktree, Repo, TerminalTab } from '../../../../shared/types'
|
||||
import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import { useAppStore } from '@/store'
|
||||
@@ -81,7 +81,6 @@ export function computeVisibleWorktreeIds(
|
||||
showActiveOnly: boolean
|
||||
tabsByWorktree: Record<string, TerminalTab[]> | null
|
||||
ptyIdsByTabId: Record<string, string[]> | null
|
||||
unifiedTabsByWorktree?: Record<string, Tab[]> | null
|
||||
browserTabsByWorktree?: Record<string, { id: string }[]> | null
|
||||
activeWorktreeId?: string | null
|
||||
// Why required: every caller (WorktreeList, getVisibleWorktreeIds
|
||||
@@ -115,15 +114,11 @@ export function computeVisibleWorktreeIds(
|
||||
opts.ptyIdsByTabId ? tabHasLivePty(opts.ptyIdsByTabId, tab.id) : false
|
||||
)
|
||||
const hasBrowserTabs = (opts.browserTabsByWorktree?.[w.id] ?? []).length > 0
|
||||
const hasNotesTabs = (opts.unifiedTabsByWorktree?.[w.id] ?? []).some(
|
||||
(tab) => tab.contentType === 'notes'
|
||||
)
|
||||
// Why: "Active only" should reflect the surfaces Orca can actually
|
||||
// restore into, not just PTY-backed terminals. A browser-tab worktree is
|
||||
// still active from the user's point of view even if it has no live PTY.
|
||||
// Project notes follow the same rule because they are workspace tabs
|
||||
// backed by the notes store, not PTY-backed terminal state.
|
||||
return hasLiveTerminal || hasBrowserTabs || hasNotesTabs || opts.activeWorktreeId === w.id
|
||||
// still active from the user's point of view even if it has no live PTY,
|
||||
// and the currently selected worktree should never vanish from the list.
|
||||
return hasLiveTerminal || hasBrowserTabs || opts.activeWorktreeId === w.id
|
||||
})
|
||||
}
|
||||
|
||||
@@ -207,7 +202,6 @@ export function getVisibleWorktreeIds(): string[] {
|
||||
showActiveOnly: state.showActiveOnly,
|
||||
tabsByWorktree: state.tabsByWorktree,
|
||||
ptyIdsByTabId: state.ptyIdsByTabId,
|
||||
unifiedTabsByWorktree: state.unifiedTabsByWorktree,
|
||||
browserTabsByWorktree: state.browserTabsByWorktree,
|
||||
activeWorktreeId: state.activeWorktreeId,
|
||||
hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace,
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { Columns2, FileText, Rows2, X } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab'
|
||||
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
|
||||
import {
|
||||
ACTIVE_TAB_INDICATOR_CLASSES,
|
||||
getDropIndicatorClasses,
|
||||
type DropIndicator
|
||||
} from './drop-indicator'
|
||||
|
||||
export type ProjectNotesTabState = {
|
||||
id: string
|
||||
label: string
|
||||
isDirty: boolean
|
||||
}
|
||||
|
||||
export function ProjectNotesTab({
|
||||
tab,
|
||||
isActive,
|
||||
hasTabsToRight,
|
||||
onActivate,
|
||||
onClose,
|
||||
onCloseToRight,
|
||||
onSplitGroup,
|
||||
dragData,
|
||||
dropIndicator
|
||||
}: {
|
||||
tab: ProjectNotesTabState
|
||||
isActive: boolean
|
||||
hasTabsToRight: boolean
|
||||
onActivate: () => void
|
||||
onClose: () => void
|
||||
onCloseToRight: () => void
|
||||
onSplitGroup: (direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId: string) => void
|
||||
dragData: TabDragItemData
|
||||
dropIndicator?: DropIndicator
|
||||
}): React.JSX.Element {
|
||||
const { attributes, listeners, setNodeRef } = useSortable({
|
||||
id: tab.id,
|
||||
data: dragData
|
||||
})
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const closeMenu = (): void => setMenuOpen(false)
|
||||
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
|
||||
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={onActivate}
|
||||
onContextMenuCapture={(event) => {
|
||||
event.preventDefault()
|
||||
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
|
||||
setMenuPoint({ x: event.clientX, y: event.clientY })
|
||||
setMenuOpen(true)
|
||||
}}
|
||||
className={`group relative flex h-full min-w-[120px] max-w-[220px] items-center gap-1.5 border-l border-border px-2 text-xs ${
|
||||
hasTabsToRight ? 'border-r border-r-border/70' : ''
|
||||
} ${isActive ? 'bg-background text-foreground' : 'bg-card text-muted-foreground hover:bg-muted/50 hover:text-foreground'}`}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
>
|
||||
{dropIndicator ? <div className={getDropIndicatorClasses(dropIndicator)} /> : null}
|
||||
<FileText className="size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{tab.label}</span>
|
||||
<div className="relative flex size-4 shrink-0 items-center justify-center">
|
||||
{tab.isDirty ? (
|
||||
<span className="absolute size-1.5 rounded-full bg-foreground/60 group-hover:hidden" />
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`flex size-4 items-center justify-center rounded-sm ${
|
||||
tab.isDirty
|
||||
? 'hidden text-muted-foreground hover:bg-muted hover:text-foreground group-hover:flex'
|
||||
: isActive
|
||||
? 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
: 'text-transparent hover:!bg-muted hover:!text-foreground group-hover:text-muted-foreground'
|
||||
}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClose()
|
||||
}}
|
||||
aria-label="Close Project Notes"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
{isActive ? <div className={ACTIVE_TAB_INDICATOR_CLASSES} /> : null}
|
||||
</div>
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<span
|
||||
className="fixed size-px"
|
||||
style={{ left: menuPoint.x, top: menuPoint.y }}
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('right', tab.id)}>
|
||||
<Columns2 className="size-4" />
|
||||
Split Right
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitGroup('down', tab.id)}>
|
||||
<Rows2 className="size-4" />
|
||||
Split Down
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onCloseToRight}>Close Tabs to Right</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onSelect={onClose}>
|
||||
Close
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,6 @@ vi.mock('react', async () => {
|
||||
memo: <T>(component: T) => component,
|
||||
useEffect: () => {},
|
||||
useLayoutEffect: () => {},
|
||||
useCallback: <T>(callback: T) => callback,
|
||||
useMemo: <T>(factory: () => T) => factory(),
|
||||
useRef: <T>(current: T) => ({ current }),
|
||||
useState: <T>(initial: T) => [initial, vi.fn()] as const
|
||||
@@ -37,9 +36,6 @@ vi.mock('lucide-react', () => ({
|
||||
FilePlus: function FilePlus() {
|
||||
return null
|
||||
},
|
||||
FileText: function FileText() {
|
||||
return null
|
||||
},
|
||||
Globe: function Globe() {
|
||||
return null
|
||||
},
|
||||
@@ -127,15 +123,6 @@ vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuShortcut', props }
|
||||
},
|
||||
DropdownMenuSub: function DropdownMenuSub(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSub', props }
|
||||
},
|
||||
DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSubContent', props }
|
||||
},
|
||||
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuSubTrigger', props }
|
||||
},
|
||||
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
|
||||
return { type: 'DropdownMenuTrigger', props }
|
||||
}
|
||||
|
||||
@@ -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, FileText } from 'lucide-react'
|
||||
import { FilePlus, Globe, Plus, TerminalSquare } from 'lucide-react'
|
||||
import type {
|
||||
BrowserTab as BrowserTabState,
|
||||
TerminalTab,
|
||||
@@ -17,7 +17,6 @@ import type { OpenFile } from '../../store/slices/editor'
|
||||
import SortableTab from './SortableTab'
|
||||
import EditorFileTab from './EditorFileTab'
|
||||
import BrowserTab, { getBrowserTabLabel } from './BrowserTab'
|
||||
import { ProjectNotesTab, type ProjectNotesTabState } from './ProjectNotesTab'
|
||||
import { QuickLaunchAgentMenuItems } from './QuickLaunchButton'
|
||||
import type { DropIndicator } from './drop-indicator'
|
||||
import { reconcileTabOrder } from './reconcile-order'
|
||||
@@ -33,13 +32,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { listRuntimeProjectNotes } from '@/runtime/runtime-notes-client'
|
||||
import type { NoteSummary } from '../../../../shared/notes-types'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isWindows = navigator.userAgent.includes('Windows')
|
||||
@@ -64,8 +58,6 @@ type TabBarProps = {
|
||||
terminalOnly?: boolean
|
||||
showAgentLaunchItems?: boolean
|
||||
onNewFileTab?: () => void
|
||||
onNewNotesTab?: (noteId?: string) => void
|
||||
notesWorktreeId?: string | null
|
||||
/** Whether WSL is installed on this Windows machine. When true, the "+"
|
||||
* dropdown shows a WSL option under the terminal submenu. */
|
||||
wslAvailable?: boolean
|
||||
@@ -74,17 +66,13 @@ type TabBarProps = {
|
||||
onTogglePaneExpand: (tabId: string) => void
|
||||
editorFiles?: (OpenFile & { tabId?: string })[]
|
||||
browserTabs?: (BrowserTabState & { tabId?: string })[]
|
||||
notesTabs?: ProjectNotesTabState[]
|
||||
activeFileId?: string | null
|
||||
activeBrowserTabId?: string | null
|
||||
activeNotesTabId?: string | null
|
||||
activeTabType?: WorkspaceVisibleTabType
|
||||
onActivateFile?: (fileId: string) => void
|
||||
onCloseFile?: (fileId: string) => void
|
||||
onActivateBrowserTab?: (tabId: string) => void
|
||||
onCloseBrowserTab?: (tabId: string) => void
|
||||
onActivateNotesTab?: (tabId: string) => void
|
||||
onCloseNotesTab?: (tabId: string) => void
|
||||
onDuplicateBrowserTab?: (tabId: string) => void
|
||||
onCloseAllFiles?: () => void
|
||||
onPinFile?: (fileId: string, tabId?: string) => void
|
||||
@@ -110,7 +98,6 @@ type TabItem =
|
||||
unifiedTabId: string
|
||||
data: BrowserTabState & { tabId?: string }
|
||||
}
|
||||
| { type: 'notes'; id: string; unifiedTabId: string; data: ProjectNotesTabState }
|
||||
|
||||
function getTabDragLabel(item: TabItem): string {
|
||||
if (item.type === 'terminal') {
|
||||
@@ -119,9 +106,6 @@ function getTabDragLabel(item: TabItem): string {
|
||||
if (item.type === 'browser') {
|
||||
return getBrowserTabLabel(item.data)
|
||||
}
|
||||
if (item.type === 'notes') {
|
||||
return item.data.label
|
||||
}
|
||||
return getEditorDisplayLabel(item.data)
|
||||
}
|
||||
|
||||
@@ -141,24 +125,18 @@ function TabBarInner({
|
||||
terminalOnly = false,
|
||||
showAgentLaunchItems = true,
|
||||
onNewFileTab,
|
||||
onNewNotesTab,
|
||||
notesWorktreeId,
|
||||
onSetCustomTitle,
|
||||
onSetTabColor,
|
||||
onTogglePaneExpand,
|
||||
editorFiles,
|
||||
browserTabs,
|
||||
notesTabs,
|
||||
activeFileId,
|
||||
activeBrowserTabId,
|
||||
activeNotesTabId,
|
||||
activeTabType,
|
||||
onActivateFile,
|
||||
onCloseFile,
|
||||
onActivateBrowserTab,
|
||||
onCloseBrowserTab,
|
||||
onActivateNotesTab,
|
||||
onCloseNotesTab,
|
||||
onDuplicateBrowserTab,
|
||||
onCloseAllFiles,
|
||||
onPinFile,
|
||||
@@ -174,12 +152,7 @@ function TabBarInner({
|
||||
const defaultWindowsPowerShellImplementation = useAppStore(
|
||||
(s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto'
|
||||
)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const [pwshAvailable, setPwshAvailable] = useState(false)
|
||||
const [projectNotes, setProjectNotes] = useState<NoteSummary[]>([])
|
||||
const [projectNotesLoading, setProjectNotesLoading] = useState(false)
|
||||
const [projectNotesError, setProjectNotesError] = useState<string | null>(null)
|
||||
const [projectNotesMenuOpen, setProjectNotesMenuOpen] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!isWindows) {
|
||||
setPwshAvailable(false)
|
||||
@@ -189,7 +162,6 @@ function TabBarInner({
|
||||
void window.api.pwsh.isAvailable().then(setPwshAvailable)
|
||||
}, [])
|
||||
const resolvedGroupId = groupId ?? worktreeId
|
||||
const targetNotesWorktreeId = notesWorktreeId ?? worktreeId
|
||||
|
||||
const statusByRelativePath = useMemo(
|
||||
() => buildStatusMap(gitStatusByWorktree[worktreeId] ?? []),
|
||||
@@ -211,49 +183,6 @@ function TabBarInner({
|
||||
return () => window.removeEventListener('blur', dismiss)
|
||||
}, [newTabMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!newTabMenuOpen) {
|
||||
setProjectNotesMenuOpen(false)
|
||||
return
|
||||
}
|
||||
const refreshProjectNotes = async (): Promise<void> => {
|
||||
if (!onNewNotesTab) {
|
||||
return
|
||||
}
|
||||
let context: { projectId: string; worktreeId: string } | null = null
|
||||
if (targetNotesWorktreeId) {
|
||||
const state = useAppStore.getState()
|
||||
const worktree = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((candidate) => candidate.id === targetNotesWorktreeId)
|
||||
if (worktree) {
|
||||
const repo = state.repos.find((candidate) => candidate.id === worktree.repoId)
|
||||
context = { projectId: repo?.id ?? worktree.repoId, worktreeId: targetNotesWorktreeId }
|
||||
}
|
||||
}
|
||||
if (!context) {
|
||||
setProjectNotes([])
|
||||
setProjectNotesError(null)
|
||||
return
|
||||
}
|
||||
setProjectNotesLoading(true)
|
||||
setProjectNotesError(null)
|
||||
try {
|
||||
const result = await listRuntimeProjectNotes(settings, {
|
||||
projectId: context.projectId,
|
||||
worktreeId: context.worktreeId,
|
||||
limit: 100
|
||||
})
|
||||
setProjectNotes(result.notes)
|
||||
} catch (err) {
|
||||
setProjectNotesError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setProjectNotesLoading(false)
|
||||
}
|
||||
}
|
||||
void refreshProjectNotes()
|
||||
}, [newTabMenuOpen, onNewNotesTab, settings, targetNotesWorktreeId])
|
||||
|
||||
const terminalMap = useMemo(() => new Map(tabs.map((t) => [t.id, t])), [tabs])
|
||||
const editorMap = useMemo(
|
||||
() => new Map((editorFiles ?? []).map((f) => [f.tabId ?? f.id, f])),
|
||||
@@ -263,22 +192,14 @@ function TabBarInner({
|
||||
() => new Map((browserTabs ?? []).map((t) => [t.id, t])),
|
||||
[browserTabs]
|
||||
)
|
||||
const notesMap = useMemo(() => new Map((notesTabs ?? []).map((t) => [t.id, t])), [notesTabs])
|
||||
|
||||
const terminalIds = useMemo(() => tabs.map((t) => t.id), [tabs])
|
||||
const editorFileIds = useMemo(() => editorFiles?.map((f) => f.tabId ?? f.id) ?? [], [editorFiles])
|
||||
const browserTabIds = useMemo(() => browserTabs?.map((tab) => tab.id) ?? [], [browserTabs])
|
||||
const notesTabIds = useMemo(() => notesTabs?.map((tab) => tab.id) ?? [], [notesTabs])
|
||||
|
||||
// Build the unified ordered list, reconciling stored order with current items
|
||||
const orderedItems = useMemo(() => {
|
||||
const ids = reconcileTabOrder(
|
||||
tabBarOrder,
|
||||
terminalIds,
|
||||
editorFileIds,
|
||||
browserTabIds,
|
||||
notesTabIds
|
||||
)
|
||||
const ids = reconcileTabOrder(tabBarOrder, terminalIds, editorFileIds, browserTabIds)
|
||||
const items: TabItem[] = []
|
||||
for (const id of ids) {
|
||||
const terminal = terminalMap.get(id)
|
||||
@@ -306,23 +227,9 @@ function TabBarInner({
|
||||
})
|
||||
continue
|
||||
}
|
||||
const notesTab = notesMap.get(id)
|
||||
if (notesTab) {
|
||||
items.push({ type: 'notes', id, unifiedTabId: notesTab.id, data: notesTab })
|
||||
}
|
||||
}
|
||||
return items
|
||||
}, [
|
||||
tabBarOrder,
|
||||
terminalIds,
|
||||
editorFileIds,
|
||||
browserTabIds,
|
||||
notesTabIds,
|
||||
terminalMap,
|
||||
editorMap,
|
||||
browserMap,
|
||||
notesMap
|
||||
])
|
||||
}, [tabBarOrder, terminalIds, editorFileIds, browserTabIds, terminalMap, editorMap, browserMap])
|
||||
|
||||
const sortableIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems])
|
||||
|
||||
@@ -521,24 +428,6 @@ function TabBarInner({
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.type === 'notes') {
|
||||
return (
|
||||
<ProjectNotesTab
|
||||
key={item.id}
|
||||
tab={item.data}
|
||||
isActive={activeTabType === 'notes' && activeNotesTabId === item.id}
|
||||
hasTabsToRight={index < orderedItems.length - 1}
|
||||
onActivate={() => onActivateNotesTab?.(item.id)}
|
||||
onClose={() => onCloseNotesTab?.(item.id)}
|
||||
onCloseToRight={() => onCloseToRight(item.id)}
|
||||
onSplitGroup={(direction, sourceVisibleTabId) =>
|
||||
onCreateSplitGroup?.(direction, sourceVisibleTabId)
|
||||
}
|
||||
dragData={dragData}
|
||||
dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<EditorFileTab
|
||||
key={item.id}
|
||||
@@ -677,77 +566,6 @@ function TabBarInner({
|
||||
<DropdownMenuShortcut>{NEW_FILE_SHORTCUT}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onNewNotesTab && (
|
||||
<DropdownMenuSub open={projectNotesMenuOpen} onOpenChange={setProjectNotesMenuOpen}>
|
||||
<DropdownMenuSubTrigger
|
||||
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
|
||||
onPointerEnter={() => setProjectNotesMenuOpen(true)}
|
||||
onFocus={() => setProjectNotesMenuOpen(true)}
|
||||
onClick={() => {
|
||||
// Why: this row looks like the other creation commands in
|
||||
// the + menu. Keep hover-to-pick-saved-note, but make a
|
||||
// direct click create a fresh note instead of only opening
|
||||
// the submenu.
|
||||
onNewNotesTab()
|
||||
setProjectNotesMenuOpen(false)
|
||||
setNewTabMenuOpen(false)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
onNewNotesTab()
|
||||
setProjectNotesMenuOpen(false)
|
||||
setNewTabMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<FileText className="size-4 text-muted-foreground" />
|
||||
Project Notes
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-64">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onNewNotesTab()}
|
||||
className="gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium"
|
||||
>
|
||||
<FileText className="size-3.5 text-muted-foreground" />
|
||||
New Project Note
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{projectNotesLoading ? (
|
||||
<DropdownMenuItem disabled className="text-muted-foreground">
|
||||
Loading notes...
|
||||
</DropdownMenuItem>
|
||||
) : projectNotesError ? (
|
||||
<DropdownMenuItem disabled className="text-destructive">
|
||||
Failed to load notes
|
||||
</DropdownMenuItem>
|
||||
) : projectNotes.length === 0 ? (
|
||||
<DropdownMenuItem disabled className="text-muted-foreground">
|
||||
No saved notes
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className="max-h-64 overflow-y-auto pr-0.5">
|
||||
{projectNotes.map((note) => (
|
||||
<DropdownMenuItem
|
||||
key={note.id}
|
||||
onSelect={() => onNewNotesTab(note.id)}
|
||||
className="items-start gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5"
|
||||
>
|
||||
<FileText className="mt-0.5 size-3.5 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{note.title}</span>
|
||||
<span className="block truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{note.preview || note.relativePath}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{showAgentLaunchItems ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileCode, Globe, Terminal as TerminalIcon, FileText } from 'lucide-react'
|
||||
import { FileCode, Globe, Terminal as TerminalIcon } from 'lucide-react'
|
||||
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
|
||||
|
||||
// Why: rendered inside dnd-kit's DragOverlay (a document-level portal), so
|
||||
@@ -9,13 +9,7 @@ import type { TabDragItemData } from '../tab-group/useTabDragSplit'
|
||||
// the wrapper's top-left.
|
||||
export default function TabDragPreview({ drag }: { drag: TabDragItemData }): React.JSX.Element {
|
||||
const Icon =
|
||||
drag.tabType === 'browser'
|
||||
? Globe
|
||||
: drag.tabType === 'editor'
|
||||
? FileCode
|
||||
: drag.tabType === 'notes'
|
||||
? FileText
|
||||
: TerminalIcon
|
||||
drag.tabType === 'browser' ? Globe : drag.tabType === 'editor' ? FileCode : TerminalIcon
|
||||
return (
|
||||
<div className="pointer-events-none flex h-full w-full items-center gap-1.5 rounded-sm border border-border bg-accent px-2 text-xs text-foreground shadow-md">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { AppState } from '../../store/types'
|
||||
import { reconcileTabOrder } from './reconcile-order'
|
||||
|
||||
export type VisibleTabRef = {
|
||||
type: 'terminal' | 'editor' | 'browser' | 'notes'
|
||||
type: 'terminal' | 'editor' | 'browser'
|
||||
id: string
|
||||
tabId?: string
|
||||
}
|
||||
@@ -12,7 +12,6 @@ export type ActiveTabNavOrderIds = {
|
||||
terminalIds?: string[]
|
||||
editorIds?: string[]
|
||||
browserIds?: string[]
|
||||
notesIds?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +62,6 @@ export function getGroupVisibleTabOrder(
|
||||
const seenTerminals = new Set<string>()
|
||||
const seenBrowsers = new Set<string>()
|
||||
const seenEditors = new Set<string>()
|
||||
const seenNotes = new Set<string>()
|
||||
for (const unifiedId of group.tabOrder) {
|
||||
const tab = tabsById.get(unifiedId)
|
||||
if (!tab) {
|
||||
@@ -81,12 +79,6 @@ export function getGroupVisibleTabOrder(
|
||||
}
|
||||
seenBrowsers.add(tab.entityId)
|
||||
result.push({ type: 'browser', id: tab.entityId, tabId: tab.id })
|
||||
} else if (tab.contentType === 'notes') {
|
||||
if (seenNotes.has(tab.id)) {
|
||||
continue
|
||||
}
|
||||
seenNotes.add(tab.id)
|
||||
result.push({ type: 'notes', id: tab.id, tabId: tab.id })
|
||||
} else {
|
||||
if (!editorEntityIds.has(tab.entityId) || seenEditors.has(tab.id)) {
|
||||
continue
|
||||
@@ -131,11 +123,6 @@ export function getActiveTabNavOrder(
|
||||
ids.editorIds ?? state.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id)
|
||||
const browserIds =
|
||||
ids.browserIds ?? (state.browserTabsByWorktree?.[worktreeId] ?? []).map((t) => t.id)
|
||||
const notesIds =
|
||||
ids.notesIds ??
|
||||
(state.unifiedTabsByWorktree?.[worktreeId] ?? [])
|
||||
.filter((tab) => tab.contentType === 'notes')
|
||||
.map((tab) => tab.id)
|
||||
|
||||
const activeGroupId = state.activeGroupIdByWorktree[worktreeId]
|
||||
const group = activeGroupId
|
||||
@@ -160,13 +147,11 @@ export function getActiveTabNavOrder(
|
||||
state.tabBarOrderByWorktree[worktreeId],
|
||||
terminalIds,
|
||||
editorIds,
|
||||
browserIds,
|
||||
notesIds
|
||||
browserIds
|
||||
)
|
||||
const terminalIdSet = new Set(terminalIds)
|
||||
const editorIdSet = new Set(editorIds)
|
||||
const browserIdSet = new Set(browserIds)
|
||||
const notesIdSet = new Set(notesIds)
|
||||
const result: VisibleTabRef[] = []
|
||||
for (const id of visibleIds) {
|
||||
if (terminalIdSet.has(id)) {
|
||||
@@ -175,8 +160,6 @@ export function getActiveTabNavOrder(
|
||||
result.push({ type: 'editor', id })
|
||||
} else if (browserIdSet.has(id)) {
|
||||
result.push({ type: 'browser', id })
|
||||
} else if (notesIdSet.has(id)) {
|
||||
result.push({ type: 'notes', id, tabId: id })
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -7,10 +7,9 @@ export function reconcileTabOrder(
|
||||
storedOrder: string[] | undefined,
|
||||
terminalIds: string[],
|
||||
editorIds: string[],
|
||||
browserIds: string[] = [],
|
||||
notesIds: string[] = []
|
||||
browserIds: string[] = []
|
||||
): string[] {
|
||||
const validIds = new Set([...terminalIds, ...editorIds, ...browserIds, ...notesIds])
|
||||
const validIds = new Set([...terminalIds, ...editorIds, ...browserIds])
|
||||
// Why: storedOrder is persisted group tab order and is mutated by many
|
||||
// codepaths (drop/move/reorder/hydrate). A stale or racey write can leave
|
||||
// the same tab id twice in the list, which surfaces as React's "two
|
||||
@@ -25,7 +24,7 @@ export function reconcileTabOrder(
|
||||
inResult.add(id)
|
||||
}
|
||||
}
|
||||
for (const id of [...terminalIds, ...editorIds, ...browserIds, ...notesIds]) {
|
||||
for (const id of [...terminalIds, ...editorIds, ...browserIds]) {
|
||||
if (!inResult.has(id)) {
|
||||
result.push(id)
|
||||
inResult.add(id)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable max-lines -- Why: this pane shell coordinates terminals, editor tabs, browser slots, and notes tabs so split-group routing stays in one place. */
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { Columns2, Ellipsis, Rows2, X } from 'lucide-react'
|
||||
@@ -20,13 +19,8 @@ import {
|
||||
type TabDropZone
|
||||
} from './useTabDragSplit'
|
||||
import { tabGroupBodyAnchorName } from './tab-group-body-anchor'
|
||||
import {
|
||||
getProjectNoteIdFromEntityId,
|
||||
isNewProjectNoteEntityId
|
||||
} from '@/lib/open-project-notes-tab'
|
||||
|
||||
const EditorPanel = lazy(() => import('../editor/EditorPanel'))
|
||||
const ProjectNotesTabContent = lazy(() => import('../notes/ProjectNotesTabContent'))
|
||||
|
||||
export default function TabGroupPanel({
|
||||
groupId,
|
||||
@@ -62,8 +56,7 @@ export default function TabGroupPanel({
|
||||
}, [])
|
||||
|
||||
const model = useTabGroupWorkspaceModel({ groupId, worktreeId })
|
||||
const { activeTab, browserItems, commands, editorItems, notesItems, tabBarOrder, terminalTabs } =
|
||||
model
|
||||
const { activeTab, browserItems, commands, editorItems, tabBarOrder, terminalTabs } = model
|
||||
const { setNodeRef: setBodyDropRef } = useDroppable({
|
||||
id: getTabPaneBodyDroppableId(groupId),
|
||||
data: {
|
||||
@@ -126,30 +119,23 @@ export default function TabGroupPanel({
|
||||
wslAvailable={wslAvailable}
|
||||
onNewBrowserTab={commands.newBrowserTab}
|
||||
onNewFileTab={commands.newFileTab}
|
||||
onNewNotesTab={commands.newNotesTab}
|
||||
onSetCustomTitle={commands.setTabCustomTitle}
|
||||
onSetTabColor={commands.setTabColor}
|
||||
onTogglePaneExpand={() => {}}
|
||||
editorFiles={editorItems}
|
||||
browserTabs={browserItems}
|
||||
notesTabs={notesItems}
|
||||
activeFileId={
|
||||
activeTab?.contentType === 'terminal' ||
|
||||
activeTab?.contentType === 'browser' ||
|
||||
activeTab?.contentType === 'notes'
|
||||
activeTab?.contentType === 'terminal' || activeTab?.contentType === 'browser'
|
||||
? null
|
||||
: activeTab?.id
|
||||
}
|
||||
activeBrowserTabId={activeTab?.contentType === 'browser' ? activeTab.entityId : null}
|
||||
activeNotesTabId={activeTab?.contentType === 'notes' ? activeTab.id : null}
|
||||
activeTabType={
|
||||
activeTab?.contentType === 'terminal'
|
||||
? 'terminal'
|
||||
: activeTab?.contentType === 'browser'
|
||||
? 'browser'
|
||||
: activeTab?.contentType === 'notes'
|
||||
? 'notes'
|
||||
: 'editor'
|
||||
: 'editor'
|
||||
}
|
||||
onActivateFile={commands.activateEditor}
|
||||
onCloseFile={commands.closeItem}
|
||||
@@ -162,15 +148,6 @@ export default function TabGroupPanel({
|
||||
commands.closeItem(item.id)
|
||||
}
|
||||
}}
|
||||
onActivateNotesTab={commands.activateNotes}
|
||||
onCloseNotesTab={(notesTabId) => {
|
||||
const item = model.groupTabs.find(
|
||||
(candidate) => candidate.id === notesTabId && candidate.contentType === 'notes'
|
||||
)
|
||||
if (item) {
|
||||
commands.closeItem(item.id)
|
||||
}
|
||||
}}
|
||||
onDuplicateBrowserTab={commands.duplicateBrowserTab}
|
||||
onCloseAllFiles={commands.closeAllEditorTabsInGroup}
|
||||
onPinFile={(_fileId, tabId) => {
|
||||
@@ -361,36 +338,9 @@ export default function TabGroupPanel({
|
||||
style={bodyAnchorStyle}
|
||||
>
|
||||
{activeDropZone ? <TabGroupDropOverlay zone={activeDropZone} /> : null}
|
||||
{model.groupTabs
|
||||
.filter((tab) => tab.contentType === 'notes')
|
||||
.map((notesTab) => (
|
||||
<div
|
||||
key={notesTab.id}
|
||||
className={`absolute inset-0 min-h-0 min-w-0 ${
|
||||
activeTab?.id === notesTab.id ? 'flex' : 'hidden'
|
||||
}`}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
Loading notes...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ProjectNotesTabContent
|
||||
worktreeId={worktreeId}
|
||||
tabId={notesTab.id}
|
||||
noteId={getProjectNoteIdFromEntityId(notesTab.entityId)}
|
||||
forceNew={isNewProjectNoteEntityId(notesTab.entityId)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{activeTab &&
|
||||
activeTab.contentType !== 'terminal' &&
|
||||
activeTab.contentType !== 'browser' &&
|
||||
activeTab.contentType !== 'notes' && (
|
||||
activeTab.contentType !== 'browser' && (
|
||||
<div className="absolute inset-0 flex min-h-0 min-w-0">
|
||||
{/* Why: split groups render editor/browser content inside a
|
||||
plain relative pane body instead of the legacy flex column in
|
||||
|
||||
@@ -34,7 +34,7 @@ export type TabDragItemData = {
|
||||
groupId: string
|
||||
unifiedTabId: string
|
||||
visibleTabId: string
|
||||
tabType: 'terminal' | 'editor' | 'browser' | 'notes'
|
||||
tabType: 'terminal' | 'editor' | 'browser'
|
||||
/** Rendered by the DragOverlay ghost that follows the cursor across
|
||||
* groups. Source tab strips use overflow-hidden, so without the overlay
|
||||
* the dragged tab would be invisible once the cursor leaves its own
|
||||
|
||||
@@ -14,13 +14,9 @@ import { extractIpcErrorMessage } from '../../lib/ipc-error'
|
||||
import { destroyWorkspaceWebviews } from '../../store/slices/browser-webview-cleanup'
|
||||
import { requestEditorFileClose } from '../editor/editor-autosave'
|
||||
import { focusTerminalTabSurface } from '../../lib/focus-terminal-tab-surface'
|
||||
import { getProjectNotesEntityId } from '../../lib/open-project-notes-tab'
|
||||
import { requestProjectNotesTabClose } from '../../lib/project-notes-close-request'
|
||||
import { linkRuntimeProjectNote, showRuntimeProjectNote } from '@/runtime/runtime-notes-client'
|
||||
|
||||
export type GroupEditorItem = OpenFile & { tabId: string }
|
||||
export type GroupBrowserItem = BrowserTabState & { tabId: string }
|
||||
export type GroupNotesItem = { id: string; label: string; entityId: string; isDirty: boolean }
|
||||
|
||||
const EMPTY_GROUPS: readonly TabGroup[] = []
|
||||
const EMPTY_UNIFIED_TABS: readonly Tab[] = []
|
||||
@@ -144,19 +140,6 @@ export function useTabGroupWorkspaceModel({
|
||||
[groupTabs, worktreeState.browserTabs]
|
||||
)
|
||||
|
||||
const notesItems = useMemo<GroupNotesItem[]>(
|
||||
() =>
|
||||
groupTabs
|
||||
.filter((item) => item.contentType === 'notes')
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
entityId: item.entityId,
|
||||
isDirty: item.isDirty === true
|
||||
})),
|
||||
[groupTabs]
|
||||
)
|
||||
|
||||
const closeEditorIfUnreferenced = useCallback(
|
||||
(entityId: string, closingTabId: string) => {
|
||||
const otherReference = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some(
|
||||
@@ -210,14 +193,6 @@ export function useTabGroupWorkspaceModel({
|
||||
} else if (item.contentType === 'browser') {
|
||||
destroyWorkspaceWebviews(useAppStore.getState().browserPagesByWorkspace, item.entityId)
|
||||
closeBrowserTab(item.entityId)
|
||||
} else if (item.contentType === 'notes') {
|
||||
requestProjectNotesTabClose(item.id, () => {
|
||||
closeUnifiedTab(item.id)
|
||||
if (!opts?.skipEmptyCheck) {
|
||||
leaveWorktreeIfEmpty()
|
||||
}
|
||||
})
|
||||
return
|
||||
} else {
|
||||
const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id)
|
||||
if (!canCloseTab) {
|
||||
@@ -251,10 +226,6 @@ export function useTabGroupWorkspaceModel({
|
||||
} else if (item.contentType === 'browser') {
|
||||
destroyWorkspaceWebviews(useAppStore.getState().browserPagesByWorkspace, item.entityId)
|
||||
closeBrowserTab(item.entityId)
|
||||
} else if (item.contentType === 'notes') {
|
||||
requestProjectNotesTabClose(item.id, () => {
|
||||
closeUnifiedTab(item.id)
|
||||
})
|
||||
} else {
|
||||
const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id)
|
||||
if (canCloseTab) {
|
||||
@@ -312,21 +283,6 @@ export function useTabGroupWorkspaceModel({
|
||||
[activateTab, focusGroup, groupId, groupTabs, setActiveBrowserTab, setActiveTabType, worktreeId]
|
||||
)
|
||||
|
||||
const activateNotes = useCallback(
|
||||
(tabId: string) => {
|
||||
const item = groupTabs.find(
|
||||
(candidate) => candidate.id === tabId && candidate.contentType === 'notes'
|
||||
)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
focusGroup(worktreeId, groupId)
|
||||
activateTab(item.id)
|
||||
setActiveTabType('notes')
|
||||
},
|
||||
[activateTab, focusGroup, groupId, groupTabs, setActiveTabType, worktreeId]
|
||||
)
|
||||
|
||||
const createSplitGroup = useCallback(
|
||||
(direction: 'left' | 'right' | 'up' | 'down', sourceVisibleTabId?: string) => {
|
||||
const sourceTab =
|
||||
@@ -465,7 +421,6 @@ export function useTabGroupWorkspaceModel({
|
||||
activeTab,
|
||||
browserItems,
|
||||
editorItems,
|
||||
notesItems,
|
||||
terminalTabs,
|
||||
tabBarOrder,
|
||||
groupTabs,
|
||||
@@ -476,7 +431,6 @@ export function useTabGroupWorkspaceModel({
|
||||
},
|
||||
activateBrowser,
|
||||
activateEditor,
|
||||
activateNotes,
|
||||
activateTerminal,
|
||||
closeAllEditorTabsInGroup,
|
||||
closeGroup,
|
||||
@@ -527,40 +481,6 @@ export function useTabGroupWorkspaceModel({
|
||||
toast.error(extractIpcErrorMessage(err, 'Failed to create untitled markdown file.'))
|
||||
}
|
||||
},
|
||||
newNotesTab: async (noteId?: string) => {
|
||||
const projectId = worktree?.repoId ?? worktreeId
|
||||
let label = 'Project Notes'
|
||||
if (noteId) {
|
||||
try {
|
||||
const settings = useAppStore.getState().settings
|
||||
const result = await showRuntimeProjectNote(settings, {
|
||||
projectId,
|
||||
worktreeId,
|
||||
note: noteId
|
||||
})
|
||||
label = result.note.title
|
||||
await linkRuntimeProjectNote(settings, {
|
||||
projectId,
|
||||
worktreeId,
|
||||
note: noteId,
|
||||
kind: 'active'
|
||||
})
|
||||
} catch {
|
||||
label = 'Project Notes'
|
||||
}
|
||||
}
|
||||
const tab = useAppStore.getState().createUnifiedTab(worktreeId, 'notes', {
|
||||
targetGroupId: groupId,
|
||||
label,
|
||||
// Why: each Project Notes tab is an editor surface, not the note
|
||||
// identity itself. Give it a unique entity id so users can keep
|
||||
// more than one notes tab open in the same pane.
|
||||
entityId: getProjectNotesEntityId(projectId, noteId)
|
||||
})
|
||||
focusGroup(worktreeId, groupId)
|
||||
activateTab(tab.id)
|
||||
setActiveTabType('notes')
|
||||
},
|
||||
newTerminalTab: () => {
|
||||
const terminal = createTab(worktreeId, groupId)
|
||||
setActiveTab(terminal.id)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type TabCycleType = 'terminal' | 'editor' | 'browser' | 'notes'
|
||||
export type TabCycleType = 'terminal' | 'editor' | 'browser'
|
||||
|
||||
export type TypeCyclableTab = {
|
||||
type: TabCycleType
|
||||
|
||||
@@ -32,7 +32,7 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'relative z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-1 text-black dark:text-white shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-1 text-black dark:text-white shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
// Why: Electron's -webkit-app-region: drag on the titlebar captures
|
||||
@@ -209,11 +209,11 @@ function DropdownMenuSubContent({
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
'relative z-50 min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-1 text-black dark:text-white shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'z-50 min-w-[11rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-[11px] border border-black/14 bg-[rgba(255,255,255,0.82)] p-1 text-black dark:text-white shadow-[0_16px_36px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.14)] backdrop-blur-2xl dark:border-white/14 dark:bg-[rgba(0,0,0,0.72)] dark:shadow-[0_20px_44px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.04)] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
// Why: submenus must escape the parent menu's scroll clipping; the
|
||||
// portal also needs no-drag for titlebar-overlapping menus in Electron.
|
||||
// Why: same no-drag fix as DropdownMenuContent — titlebar drag region
|
||||
// would otherwise capture clicks when submenu overlaps it.
|
||||
style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -67,9 +67,6 @@ function applyNextTab(store: AppStoreState, next: TypeCyclableTab): void {
|
||||
store.activateTab?.(next.tabId)
|
||||
}
|
||||
store.setActiveTabType('browser')
|
||||
} else if (next.type === 'notes') {
|
||||
store.activateTab?.(next.tabId ?? next.id)
|
||||
store.setActiveTabType('notes')
|
||||
} else {
|
||||
// Why: `setActiveFile` targets the file entity (its implicit activateTab
|
||||
// picks the first matching tab in the active group); `activateTab(tabId)`
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
export function resolveZoomTarget(args: {
|
||||
activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations' | 'space'
|
||||
activeTabType: 'terminal' | 'editor' | 'browser' | 'notes'
|
||||
activeTabType: 'terminal' | 'editor' | 'browser'
|
||||
activeElement: unknown
|
||||
}): 'terminal' | 'editor' | 'ui' {
|
||||
const { activeView, activeTabType, activeElement } = args
|
||||
@@ -35,7 +35,7 @@ export function resolveZoomTarget(args: {
|
||||
if (activeView !== 'terminal') {
|
||||
return 'ui'
|
||||
}
|
||||
if (activeTabType === 'editor' || activeTabType === 'notes' || editorFocused) {
|
||||
if (activeTabType === 'editor' || editorFocused) {
|
||||
return 'editor'
|
||||
}
|
||||
// Why: terminal tabs should keep using per-pane terminal font zoom even when
|
||||
|
||||
@@ -34,7 +34,6 @@ import { destroyPersistentWebview } from '@/components/browser-pane/webview-regi
|
||||
import { attachMobileMarkdownBridge } from '@/runtime/mobile-markdown-bridge'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { requestProjectNotesTabClose } from '@/lib/project-notes-close-request'
|
||||
|
||||
export { resolveZoomTarget } from './resolve-zoom-target'
|
||||
|
||||
@@ -864,15 +863,6 @@ export function useIpcEvents(): void {
|
||||
const store = useAppStore.getState()
|
||||
if (store.activeTabType === 'browser' && store.activeBrowserTabId) {
|
||||
store.closeBrowserTab(store.activeBrowserTabId)
|
||||
return
|
||||
}
|
||||
if (store.activeTabType === 'notes' && store.activeWorktreeId) {
|
||||
const activeTab = store.getActiveTab(store.activeWorktreeId)
|
||||
if (activeTab?.contentType === 'notes') {
|
||||
requestProjectNotesTabClose(activeTab.id, () => {
|
||||
store.closeUnifiedTab(activeTab.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const NOTES_ACTIVE_CHANGED_EVENT = 'orca:notes-active-changed'
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events'
|
||||
import { linkRuntimeProjectNote, showRuntimeProjectNote } from '@/runtime/runtime-notes-client'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
|
||||
export function getProjectNotesEntityId(projectId: string, noteId?: string): string {
|
||||
if (noteId) {
|
||||
return `notes:${projectId}:note:${noteId}`
|
||||
}
|
||||
return `notes:${projectId}:new:${createBrowserUuid()}`
|
||||
}
|
||||
|
||||
export function getProjectNoteIdFromEntityId(entityId: string): string | null {
|
||||
const [, , kind, noteId] = entityId.split(':')
|
||||
return kind === 'note' && noteId ? noteId : null
|
||||
}
|
||||
|
||||
export function isNewProjectNoteEntityId(entityId: string): boolean {
|
||||
const [, , kind] = entityId.split(':')
|
||||
return kind === 'new'
|
||||
}
|
||||
|
||||
export async function openProjectNotesTab(worktreeId: string, noteId?: string): Promise<void> {
|
||||
const state = useAppStore.getState()
|
||||
const targetGroupId =
|
||||
state.activeGroupIdByWorktree[worktreeId] ?? (state.groupsByWorktree[worktreeId] ?? [])[0]?.id
|
||||
const worktree = Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((candidate) => candidate.id === worktreeId)
|
||||
const repo = state.repos.find((candidate) => candidate.id === worktree?.repoId)
|
||||
const projectId = repo?.id ?? worktree?.repoId ?? null
|
||||
const settings = state.settings
|
||||
|
||||
if (noteId && projectId) {
|
||||
await linkRuntimeProjectNote(settings, { projectId, worktreeId, note: noteId, kind: 'active' })
|
||||
}
|
||||
|
||||
let label = 'Project Notes'
|
||||
if (noteId && projectId) {
|
||||
try {
|
||||
const result = await showRuntimeProjectNote(settings, { projectId, worktreeId, note: noteId })
|
||||
label = result.note.title
|
||||
} catch {
|
||||
label = 'Project Notes'
|
||||
}
|
||||
}
|
||||
|
||||
state.setActiveView('terminal')
|
||||
|
||||
const tab = state.createUnifiedTab(worktreeId, 'notes', {
|
||||
targetGroupId,
|
||||
label,
|
||||
entityId: getProjectNotesEntityId(projectId ?? worktree?.repoId ?? worktreeId, noteId)
|
||||
})
|
||||
state.focusGroup(worktreeId, tab.groupId)
|
||||
state.activateTab(tab.id)
|
||||
state.setActiveTabType('notes')
|
||||
if (noteId) {
|
||||
notifyProjectNotesSelectionChanged()
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyProjectNotesSelectionChanged(): void {
|
||||
window.dispatchEvent(new CustomEvent(NOTES_ACTIVE_CHANGED_EVENT))
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT,
|
||||
requestProjectNotesTabClose,
|
||||
type ProjectNotesCloseRequestDetail
|
||||
} from './project-notes-close-request'
|
||||
|
||||
type WindowEventStub = Pick<Window, 'addEventListener' | 'removeEventListener' | 'dispatchEvent'>
|
||||
|
||||
beforeEach(() => {
|
||||
const eventTarget = new EventTarget()
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: eventTarget.addEventListener.bind(eventTarget),
|
||||
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
|
||||
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget)
|
||||
} satisfies WindowEventStub)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('requestProjectNotesTabClose', () => {
|
||||
it('dispatches a close request and lets a mounted notes tab claim it', () => {
|
||||
const close = vi.fn()
|
||||
const listener = vi.fn((event: Event) => {
|
||||
const detail = (event as CustomEvent<ProjectNotesCloseRequestDetail>).detail
|
||||
detail.claim()
|
||||
expect(detail.tabId).toBe('tab-1')
|
||||
expect(detail.close).toBe(close)
|
||||
})
|
||||
window.addEventListener(ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, listener)
|
||||
try {
|
||||
requestProjectNotesTabClose('tab-1', close)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
window.removeEventListener(ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, listener)
|
||||
}
|
||||
})
|
||||
|
||||
it('closes immediately when no notes tab claims the request', () => {
|
||||
const close = vi.fn()
|
||||
requestProjectNotesTabClose('tab-1', close)
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
export const ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT = 'orca:project-notes-request-close'
|
||||
|
||||
export type ProjectNotesCloseRequestDetail = {
|
||||
tabId: string
|
||||
close: () => void
|
||||
claim: () => void
|
||||
}
|
||||
|
||||
export function requestProjectNotesTabClose(tabId: string, close: () => void): void {
|
||||
let claimed = false
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<ProjectNotesCloseRequestDetail>(ORCA_PROJECT_NOTES_REQUEST_CLOSE_EVENT, {
|
||||
detail: {
|
||||
tabId,
|
||||
close,
|
||||
claim: () => {
|
||||
claimed = true
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
// Why: Project Notes tabs are normally mounted so dirty state can prompt on
|
||||
// close, but close requests should still complete if a tab shell exists
|
||||
// before its lazy content has attached a listener.
|
||||
if (!claimed) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
@@ -188,28 +188,4 @@ describe('resolveWorktreeStatus', () => {
|
||||
|
||||
expect(status).toBe('active')
|
||||
})
|
||||
|
||||
it('treats a notes-only worktree as active without promoting unrelated worktrees', () => {
|
||||
const notesWorktreeStatus = resolveWorktreeStatus({
|
||||
tabs: [],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: {},
|
||||
hasNotesSurface: true,
|
||||
hasPermission: false,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
const unrelatedWorktreeStatus = resolveWorktreeStatus({
|
||||
tabs: [],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: {},
|
||||
hasNotesSurface: false,
|
||||
hasPermission: false,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(notesWorktreeStatus).toBe('active')
|
||||
expect(unrelatedWorktreeStatus).toBe('inactive')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -98,7 +98,6 @@ export function resolveWorktreeStatus(args: {
|
||||
browserTabs: { id: string }[]
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
runtimePaneTitlesByTabId?: Record<string, Record<number, string>>
|
||||
hasNotesSurface?: boolean
|
||||
hasPermission: boolean
|
||||
hasLiveDone: boolean
|
||||
hasRetainedDone: boolean
|
||||
@@ -126,8 +125,5 @@ export function resolveWorktreeStatus(args: {
|
||||
if (args.hasLiveDone || args.hasRetainedDone) {
|
||||
return 'done'
|
||||
}
|
||||
if (heuristic === 'inactive' && args.hasNotesSurface) {
|
||||
return 'active'
|
||||
}
|
||||
return heuristic
|
||||
}
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createRuntimeProjectNote,
|
||||
deleteRuntimeProjectNote,
|
||||
linkRuntimeProjectNote,
|
||||
listRuntimeProjectNotes,
|
||||
renameRuntimeProjectNote,
|
||||
resolveRuntimeNotesPanelState,
|
||||
saveRuntimeProjectNote,
|
||||
showRuntimeProjectNote
|
||||
} from './runtime-notes-client'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponseIfNeeded,
|
||||
type RuntimeEnvironmentCallRequest
|
||||
} from './runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
|
||||
|
||||
const notesList = vi.fn()
|
||||
const notesShow = vi.fn()
|
||||
const notesCreate = vi.fn()
|
||||
const notesSave = vi.fn()
|
||||
const notesRename = vi.fn()
|
||||
const notesDelete = vi.fn()
|
||||
const notesAppend = vi.fn()
|
||||
const notesSearch = vi.fn()
|
||||
const notesLink = vi.fn()
|
||||
const notesPanelState = vi.fn()
|
||||
const runtimeCall = vi.fn()
|
||||
const runtimeEnvironmentCall = vi.fn()
|
||||
const runtimeEnvironmentTransportCall = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
notesList.mockReset()
|
||||
notesShow.mockReset()
|
||||
notesCreate.mockReset()
|
||||
notesSave.mockReset()
|
||||
notesRename.mockReset()
|
||||
notesDelete.mockReset()
|
||||
notesAppend.mockReset()
|
||||
notesSearch.mockReset()
|
||||
notesLink.mockReset()
|
||||
notesPanelState.mockReset()
|
||||
runtimeCall.mockReset()
|
||||
runtimeEnvironmentCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockReset()
|
||||
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
|
||||
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
notes: {
|
||||
list: notesList,
|
||||
show: notesShow,
|
||||
create: notesCreate,
|
||||
save: notesSave,
|
||||
rename: notesRename,
|
||||
delete: notesDelete,
|
||||
append: notesAppend,
|
||||
search: notesSearch,
|
||||
link: notesLink,
|
||||
panelState: notesPanelState
|
||||
},
|
||||
runtime: { call: runtimeCall },
|
||||
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtime notes client', () => {
|
||||
it('uses local notes IPC when no remote runtime is active', async () => {
|
||||
notesList.mockResolvedValue({ notes: [], totalCount: 0, truncated: false })
|
||||
|
||||
await listRuntimeProjectNotes(
|
||||
{ activeRuntimeEnvironmentId: null },
|
||||
{ projectId: 'repo-1', worktreeId: 'wt-1', limit: 100 }
|
||||
)
|
||||
|
||||
expect(notesList).toHaveBeenCalledWith({
|
||||
projectId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
limit: 100
|
||||
})
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes note reads through the active runtime environment', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: { notes: [], totalCount: 0, truncated: false },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
const settings = { activeRuntimeEnvironmentId: 'env-1' }
|
||||
|
||||
await listRuntimeProjectNotes(settings, {
|
||||
projectId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
limit: 100
|
||||
})
|
||||
runtimeEnvironmentCall.mockResolvedValueOnce({
|
||||
id: 'rpc-2',
|
||||
ok: true,
|
||||
result: { note: { id: 'note-1' }, linkKind: null },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
await showRuntimeProjectNote(settings, {
|
||||
projectId: 'repo-1',
|
||||
worktreeId: 'wt-1',
|
||||
note: 'note-1'
|
||||
})
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, {
|
||||
selector: 'env-1',
|
||||
method: 'note.list',
|
||||
params: { worktree: 'wt-1', limit: 100 },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, {
|
||||
selector: 'env-1',
|
||||
method: 'note.show',
|
||||
params: { worktree: 'wt-1', note: 'note-1' },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('routes note mutations through the active runtime environment', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: { note: { id: 'note-1' }, linkKind: 'active' },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
const settings = { activeRuntimeEnvironmentId: 'env-1' }
|
||||
const base = { projectId: 'repo-1', worktreeId: 'wt-1' }
|
||||
|
||||
await createRuntimeProjectNote(settings, { ...base, title: 'Plan', bodyMarkdown: 'body' })
|
||||
await saveRuntimeProjectNote(settings, {
|
||||
...base,
|
||||
note: 'note-1',
|
||||
title: 'Plan',
|
||||
bodyMarkdown: 'updated',
|
||||
revision: 3,
|
||||
makeActive: true
|
||||
})
|
||||
await renameRuntimeProjectNote(settings, { ...base, note: 'note-1', title: 'Renamed' })
|
||||
await deleteRuntimeProjectNote(settings, { ...base, note: 'note-1' })
|
||||
await linkRuntimeProjectNote(settings, { ...base, note: 'note-1', kind: 'active' })
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, {
|
||||
selector: 'env-1',
|
||||
method: 'note.create',
|
||||
params: {
|
||||
worktree: 'wt-1',
|
||||
title: 'Plan',
|
||||
bodyMarkdown: 'body',
|
||||
makeActive: undefined,
|
||||
createdBySessionId: undefined
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, {
|
||||
selector: 'env-1',
|
||||
method: 'note.save',
|
||||
params: {
|
||||
worktree: 'wt-1',
|
||||
note: 'note-1',
|
||||
title: 'Plan',
|
||||
bodyMarkdown: 'updated',
|
||||
revision: 3,
|
||||
makeActive: true,
|
||||
updatedBySessionId: undefined
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, {
|
||||
selector: 'env-1',
|
||||
method: 'note.rename',
|
||||
params: {
|
||||
worktree: 'wt-1',
|
||||
note: 'note-1',
|
||||
title: 'Renamed',
|
||||
updatedBySessionId: undefined
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(4, {
|
||||
selector: 'env-1',
|
||||
method: 'note.delete',
|
||||
params: { worktree: 'wt-1', note: 'note-1' },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(5, {
|
||||
selector: 'env-1',
|
||||
method: 'note.link',
|
||||
params: { worktree: 'wt-1', note: 'note-1', kind: 'active' },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('routes panel state through the active runtime environment', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: { state: 'emptyDraft', projectId: 'repo-1', worktreeId: 'wt-1' },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
|
||||
await resolveRuntimeNotesPanelState(
|
||||
{ activeRuntimeEnvironmentId: 'env-1' },
|
||||
{ projectId: 'repo-1', worktreeId: 'wt-1' }
|
||||
)
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'note.panelState',
|
||||
params: { worktree: 'wt-1' },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('returns noProject for remote panel state without a project/worktree', async () => {
|
||||
await expect(
|
||||
resolveRuntimeNotesPanelState({ activeRuntimeEnvironmentId: 'env-1' }, {})
|
||||
).resolves.toEqual({ state: 'noProject' })
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,229 +0,0 @@
|
||||
import type { GlobalSettings } from '../../../shared/types'
|
||||
import type {
|
||||
NoteAppendArgs,
|
||||
NoteCreateArgs,
|
||||
NoteDeleteArgs,
|
||||
NoteDeleteResult,
|
||||
NoteLink,
|
||||
NoteLinkArgs,
|
||||
NoteListArgs,
|
||||
NoteListResult,
|
||||
NoteMutationResult,
|
||||
NoteRenameArgs,
|
||||
NoteSaveArgs,
|
||||
NoteSearchArgs,
|
||||
NoteShowArgs,
|
||||
NoteShowResult,
|
||||
NotesPanelOpenState,
|
||||
NotesPanelStateArgs
|
||||
} from '../../../shared/notes-types'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
|
||||
|
||||
type RuntimeNotesSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
|
||||
function requireWorktreeId(worktreeId: string | null | undefined): string {
|
||||
if (!worktreeId?.trim()) {
|
||||
throw new Error('Project notes require an active worktree on remote runtime servers.')
|
||||
}
|
||||
return worktreeId
|
||||
}
|
||||
|
||||
function noteTarget(settings: RuntimeNotesSettings): ReturnType<typeof getActiveRuntimeTarget> {
|
||||
return getActiveRuntimeTarget(settings)
|
||||
}
|
||||
|
||||
export async function listRuntimeProjectNotes(
|
||||
settings: RuntimeNotesSettings,
|
||||
args: NoteListArgs
|
||||
): Promise<NoteListResult> {
|
||||
const target = noteTarget(settings)
|
||||
if (target.kind === 'local') {
|
||||
return window.api.notes.list(args)
|
||||
}
|
||||
return callRuntimeRpc<NoteListResult>(
|
||||
target,
|
||||
'note.list',
|
||||
{ worktree: requireWorktreeId(args.worktreeId), limit: args.limit },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function showRuntimeProjectNote(
|
||||
settings: RuntimeNotesSettings,
|
||||
args: NoteShowArgs
|
||||
): Promise<NoteShowResult> {
|
||||
const target = noteTarget(settings)
|
||||
if (target.kind === 'local') {
|
||||
return window.api.notes.show(args)
|
||||
}
|
||||
return callRuntimeRpc<NoteShowResult>(
|
||||
target,
|
||||
'note.show',
|
||||
{ worktree: requireWorktreeId(args.worktreeId), note: args.note },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function createRuntimeProjectNote(
|
||||
settings: RuntimeNotesSettings,
|
||||
args: NoteCreateArgs
|
||||
): Promise<NoteMutationResult> {
|
||||
const target = noteTarget(settings)
|
||||
if (target.kind === 'local') {
|
||||
return window.api.notes.create(args)
|
||||
}
|
||||
return callRuntimeRpc<NoteMutationResult>(
|
||||
target,
|
||||
'note.create',
|
||||
{
|
||||
worktree: requireWorktreeId(args.worktreeId),
|
||||
title: args.title,
|
||||
bodyMarkdown: args.bodyMarkdown,
|
||||
makeActive: args.makeActive,
|
||||
createdBySessionId: args.createdBySessionId
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function saveRuntimeProjectNote(
|
||||
settings: RuntimeNotesSettings,
|
||||
args: NoteSaveArgs
|
||||
): Promise<NoteMutationResult> {
|
||||
const target = noteTarget(settings)
|
||||
if (target.kind === 'local') {
|
||||
return window.api.notes.save(args)
|
||||
}
|
||||
return callRuntimeRpc<NoteMutationResult>(
|
||||
target,
|
||||
'note.save',
|
||||
{
|
||||
worktree: requireWorktreeId(args.worktreeId),
|
||||
note: args.note,
|
||||
title: args.title,
|
||||
bodyMarkdown: args.bodyMarkdown,
|
||||
revision: args.revision,
|
||||
makeActive: args.makeActive,
|
||||
updatedBySessionId: args.updatedBySessionId
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function renameRuntimeProjectNote(
|
||||
settings: RuntimeNotesSettings,
|
||||
args: NoteRenameArgs
|
||||
): Promise<NoteMutationResult> {
|
||||
const target = noteTarget(settings)
|
||||
if (target.kind === 'local') {
|
||||
return window.api.notes.rename(args)
|
||||
}
|
||||
return callRuntimeRpc<NoteMutationResult>(
|
||||
target,
|
||||
'note.rename',
|
||||
{
|
||||
worktree: requireWorktreeId(args.worktreeId),
|
||||
note: args.note,
|
||||
title: args.title,
|
||||
updatedBySessionId: args.updatedBySessionId
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteRuntimeProjectNote(
|
||||
settings: RuntimeNotesSettings,
|
||||
args: NoteDeleteArgs
|
||||
): Promise<NoteDeleteResult> {
|
||||
const target = noteTarget(settings)
|
||||
if (target.kind === 'local') {
|
||||
return window.api.notes.delete(args)
|
||||
}
|
||||
return callRuntimeRpc<NoteDeleteResult>(
|
||||
target,
|
||||
'note.delete',
|
||||
{ worktree: requireWorktreeId(args.worktreeId), note: args.note },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function appendRuntimeProjectNote(
|
||||
settings: RuntimeNotesSettings,
|
||||
args: NoteAppendArgs
|
||||
): Promise<NoteMutationResult> {
|
||||
const target = noteTarget(settings)
|
||||
if (target.kind === 'local') {
|
||||
return window.api.notes.append(args)
|
||||
}
|
||||
return callRuntimeRpc<NoteMutationResult>(
|
||||
target,
|
||||
'note.append',
|
||||
{
|
||||
worktree: requireWorktreeId(args.worktreeId),
|
||||
note: args.note,
|
||||
bodyMarkdown: args.bodyMarkdown,
|
||||
makeActive: args.makeActive,
|
||||
updatedBySessionId: args.updatedBySessionId
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function searchRuntimeProjectNotes(
|
||||
settings: RuntimeNotesSettings,
|
||||
args: NoteSearchArgs
|
||||
): Promise<NoteListResult> {
|
||||
const target = noteTarget(settings)
|
||||
if (target.kind === 'local') {
|
||||
return window.api.notes.search(args)
|
||||
}
|
||||
return callRuntimeRpc<NoteListResult>(
|
||||
target,
|
||||
'note.search',
|
||||
{
|
||||
worktree: requireWorktreeId(args.worktreeId),
|
||||
query: args.query,
|
||||
limit: args.limit
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function linkRuntimeProjectNote(
|
||||
settings: RuntimeNotesSettings,
|
||||
args: NoteLinkArgs
|
||||
): Promise<NoteLink> {
|
||||
const target = noteTarget(settings)
|
||||
if (target.kind === 'local') {
|
||||
return window.api.notes.link(args)
|
||||
}
|
||||
return callRuntimeRpc<NoteLink>(
|
||||
target,
|
||||
'note.link',
|
||||
{
|
||||
worktree: requireWorktreeId(args.worktreeId),
|
||||
note: args.note,
|
||||
kind: args.kind
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolveRuntimeNotesPanelState(
|
||||
settings: RuntimeNotesSettings,
|
||||
args: NotesPanelStateArgs
|
||||
): Promise<NotesPanelOpenState> {
|
||||
const target = noteTarget(settings)
|
||||
if (target.kind === 'local') {
|
||||
return window.api.notes.panelState(args)
|
||||
}
|
||||
if (!args.projectId && !args.worktreeId) {
|
||||
return { state: 'noProject' }
|
||||
}
|
||||
return callRuntimeRpc<NotesPanelOpenState>(
|
||||
target,
|
||||
'note.panelState',
|
||||
{ worktree: requireWorktreeId(args.worktreeId) },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
}
|
||||
@@ -140,13 +140,7 @@ export type OpenFile = {
|
||||
mode: 'edit' | 'diff' | 'conflict-review' | 'markdown-preview'
|
||||
}
|
||||
|
||||
export type RightSidebarTab =
|
||||
| 'explorer'
|
||||
| 'search'
|
||||
| 'source-control'
|
||||
| 'checks'
|
||||
| 'ports'
|
||||
| 'notes'
|
||||
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks' | 'ports'
|
||||
export type ActivityBarPosition = 'top' | 'side'
|
||||
|
||||
export type MarkdownViewMode = 'source' | 'rich' | 'preview'
|
||||
|
||||
@@ -62,8 +62,7 @@ function hydrateUnifiedFormat(
|
||||
tabsByWorktree[worktreeId] = [...tabs]
|
||||
.map((tab) => ({
|
||||
...tab,
|
||||
entityId: tab.entityId ?? tab.id,
|
||||
isDirty: false
|
||||
entityId: tab.entityId ?? tab.id
|
||||
}))
|
||||
.filter((tab) => {
|
||||
if (!isTransientEditorContentType(tab.contentType)) {
|
||||
|
||||
@@ -968,14 +968,6 @@ describe('TabsSlice', () => {
|
||||
store.getState().setUnifiedTabColor(tab.id, '#ff0000')
|
||||
expect(store.getState().unifiedTabsByWorktree[WT][0].color).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('setTabDirty updates dirty state', () => {
|
||||
const tab = store.getState().createUnifiedTab(WT, 'notes')
|
||||
store.getState().setTabDirty(tab.id, true)
|
||||
expect(store.getState().unifiedTabsByWorktree[WT][0].isDirty).toBe(true)
|
||||
store.getState().setTabDirty(tab.id, false)
|
||||
expect(store.getState().unifiedTabsByWorktree[WT][0].isDirty).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── pinTab / unpinTab ────────────────────────────────────────────
|
||||
@@ -1511,47 +1503,5 @@ describe('TabsSlice', () => {
|
||||
groupId: restoredGroup?.id
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps project notes tabs even though they are not openFiles-backed editors', () => {
|
||||
const groupId = 'g-1'
|
||||
store.setState({
|
||||
unifiedTabsByWorktree: {
|
||||
[WT]: [
|
||||
{
|
||||
id: 'notes-tab-1',
|
||||
entityId: 'notes:repo-1:note-1',
|
||||
groupId,
|
||||
worktreeId: WT,
|
||||
contentType: 'notes',
|
||||
label: 'Project Notes',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[WT]: [
|
||||
{
|
||||
id: groupId,
|
||||
worktreeId: WT,
|
||||
activeTabId: 'notes-tab-1',
|
||||
tabOrder: ['notes-tab-1']
|
||||
}
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { [WT]: groupId },
|
||||
tabsByWorktree: { [WT]: [] },
|
||||
openFiles: []
|
||||
})
|
||||
|
||||
const result = store.getState().reconcileWorktreeTabModel(WT)
|
||||
|
||||
expect(result.renderableTabCount).toBe(1)
|
||||
expect(result.activeRenderableTabId).toBe('notes-tab-1')
|
||||
expect(store.getState().unifiedTabsByWorktree[WT]).toHaveLength(1)
|
||||
expect(store.getState().groupsByWorktree[WT][0].activeTabId).toBe('notes-tab-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,9 +61,7 @@ export type TabsSlice = {
|
||||
tabId: string
|
||||
) => { closedTabId: string; wasLastTab: boolean; worktreeId: string } | null
|
||||
reorderUnifiedTabs: (groupId: string, tabIds: string[]) => void
|
||||
setTabEntityId: (tabId: string, entityId: string) => void
|
||||
setTabLabel: (tabId: string, label: string) => void
|
||||
setTabDirty: (tabId: string, isDirty: boolean) => void
|
||||
setTabCustomLabel: (tabId: string, label: string | null) => void
|
||||
setUnifiedTabColor: (tabId: string, color: string | null) => void
|
||||
pinTab: (tabId: string) => void
|
||||
@@ -230,13 +228,7 @@ function collapseGroupLayout(
|
||||
}
|
||||
|
||||
function toVisibleTabType(contentType: TabContentType): WorkspaceVisibleTabType {
|
||||
return contentType === 'browser'
|
||||
? 'browser'
|
||||
: contentType === 'terminal'
|
||||
? 'terminal'
|
||||
: contentType === 'notes'
|
||||
? 'notes'
|
||||
: 'editor'
|
||||
return contentType === 'browser' ? 'browser' : contentType === 'terminal' ? 'terminal' : 'editor'
|
||||
}
|
||||
|
||||
function deriveActiveSurfaceForWorktree(
|
||||
@@ -439,8 +431,7 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
sortOrder: nextOrder.length,
|
||||
createdAt: Date.now(),
|
||||
isPreview: init?.isPreview,
|
||||
isPinned: init?.isPinned,
|
||||
isDirty: false
|
||||
isPinned: init?.isPinned
|
||||
}
|
||||
|
||||
nextOrder = dedupeTabOrder([...nextOrder, created.id])
|
||||
@@ -738,12 +729,6 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
setTabLabel: (tabId, label) =>
|
||||
set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { label }) ?? {}),
|
||||
|
||||
setTabEntityId: (tabId, entityId) =>
|
||||
set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { entityId }) ?? {}),
|
||||
|
||||
setTabDirty: (tabId, isDirty) =>
|
||||
set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { isDirty }) ?? {}),
|
||||
|
||||
setTabCustomLabel: (tabId, label) =>
|
||||
set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}),
|
||||
|
||||
@@ -1375,12 +1360,6 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
if (tab.contentType === 'browser') {
|
||||
return liveBrowserIds.has(tab.entityId)
|
||||
}
|
||||
if (tab.contentType === 'notes') {
|
||||
// Why: project notes are backed by the project notes store, not by
|
||||
// openFiles. Treating them as editor-backed files makes reconcile
|
||||
// prune valid notes tabs and can leave the workspace looking empty.
|
||||
return true
|
||||
}
|
||||
return liveEditorIds.has(tab.entityId)
|
||||
}
|
||||
|
||||
|
||||
@@ -61,13 +61,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
|
||||
}
|
||||
|
||||
function toVisibleTabType(contentType: string): WorkspaceVisibleTabType {
|
||||
return contentType === 'browser'
|
||||
? 'browser'
|
||||
: contentType === 'terminal'
|
||||
? 'terminal'
|
||||
: contentType === 'notes'
|
||||
? 'notes'
|
||||
: 'editor'
|
||||
return contentType === 'browser' ? 'browser' : contentType === 'terminal' ? 'terminal' : 'editor'
|
||||
}
|
||||
|
||||
async function listWorktreesForRepo(
|
||||
|
||||
@@ -129,7 +129,6 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
||||
browser: createBrowserApi(),
|
||||
gh: createGitHubApi(),
|
||||
linear: createRuntimeNamespaceApi('linear'),
|
||||
notes: createNotesApi(),
|
||||
hooks: createHooksApi(),
|
||||
stats: {
|
||||
getSummary: async () =>
|
||||
@@ -635,23 +634,6 @@ function createRuntimeNamespaceApi(prefix: string): never {
|
||||
}) as never
|
||||
}
|
||||
|
||||
function createNotesApi(): NonNullable<Partial<PreloadApi>['notes']> {
|
||||
const noteCall = (method: string) => (args: Record<string, unknown>) =>
|
||||
callRuntimeResult(method, { ...args, worktree: args.worktreeId })
|
||||
return {
|
||||
list: noteCall('note.list'),
|
||||
show: noteCall('note.show'),
|
||||
create: noteCall('note.create'),
|
||||
save: noteCall('note.save'),
|
||||
rename: noteCall('note.rename'),
|
||||
delete: noteCall('note.delete'),
|
||||
append: noteCall('note.append'),
|
||||
search: noteCall('note.search'),
|
||||
link: noteCall('note.link'),
|
||||
panelState: noteCall('note.panelState')
|
||||
} as NonNullable<Partial<PreloadApi>['notes']>
|
||||
}
|
||||
|
||||
function createHooksApi(): NonNullable<Partial<PreloadApi>['hooks']> {
|
||||
return {
|
||||
check: async ({ repoId }) => callRuntimeResult('repo.hooksCheck', { repo: repoId }),
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
export type NoteLinkKind = 'active' | 'referenced'
|
||||
|
||||
export type NoteRecord = {
|
||||
id: string
|
||||
projectId: string
|
||||
filePath: string
|
||||
relativePath: string
|
||||
title: string
|
||||
bodyMarkdown: string
|
||||
revision: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
archivedAt: string | null
|
||||
createdBySessionId?: string | null
|
||||
updatedBySessionId?: string | null
|
||||
}
|
||||
|
||||
export type NoteLink = {
|
||||
noteId: string
|
||||
projectId: string
|
||||
worktreeId: string
|
||||
kind: NoteLinkKind
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type NoteSummary = Omit<NoteRecord, 'bodyMarkdown'> & {
|
||||
preview: string
|
||||
linkKind: NoteLinkKind | null
|
||||
}
|
||||
|
||||
export type NoteListResult = {
|
||||
notes: NoteSummary[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type NoteShowResult = {
|
||||
note: NoteRecord
|
||||
linkKind: NoteLinkKind | null
|
||||
}
|
||||
|
||||
export type NoteMutationResult = {
|
||||
note: NoteRecord
|
||||
linkKind: NoteLinkKind | null
|
||||
}
|
||||
|
||||
export type NotesPanelOpenState =
|
||||
| { state: 'noProject' }
|
||||
| { state: 'emptyDraft'; projectId: string; worktreeId: string | null }
|
||||
| { state: 'pickerRequired'; projectId: string; worktreeId: string | null; notes: NoteSummary[] }
|
||||
| { state: 'active'; projectId: string; worktreeId: string | null; note: NoteRecord }
|
||||
|
||||
export type NoteListArgs = {
|
||||
projectId: string
|
||||
worktreeId?: string | null
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type NoteShowArgs = {
|
||||
projectId: string
|
||||
worktreeId?: string | null
|
||||
note: string
|
||||
}
|
||||
|
||||
export type NoteCreateArgs = {
|
||||
projectId: string
|
||||
worktreeId?: string | null
|
||||
title: string
|
||||
bodyMarkdown?: string
|
||||
makeActive?: boolean
|
||||
createdBySessionId?: string | null
|
||||
}
|
||||
|
||||
export type NoteSaveArgs = {
|
||||
projectId: string
|
||||
worktreeId?: string | null
|
||||
note: string
|
||||
title?: string
|
||||
bodyMarkdown: string
|
||||
revision?: number
|
||||
makeActive?: boolean
|
||||
updatedBySessionId?: string | null
|
||||
}
|
||||
|
||||
export type NoteRenameArgs = {
|
||||
projectId: string
|
||||
worktreeId?: string | null
|
||||
note: string
|
||||
title: string
|
||||
updatedBySessionId?: string | null
|
||||
}
|
||||
|
||||
export type NoteDeleteArgs = {
|
||||
projectId: string
|
||||
worktreeId?: string | null
|
||||
note: string
|
||||
}
|
||||
|
||||
export type NoteDeleteResult = {
|
||||
noteId: string
|
||||
projectId: string
|
||||
}
|
||||
|
||||
export type NoteAppendArgs = {
|
||||
projectId: string
|
||||
worktreeId?: string | null
|
||||
note: string
|
||||
bodyMarkdown: string
|
||||
makeActive?: boolean
|
||||
updatedBySessionId?: string | null
|
||||
}
|
||||
|
||||
export type NoteSearchArgs = {
|
||||
projectId: string
|
||||
worktreeId?: string | null
|
||||
query: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type NoteLinkArgs = {
|
||||
projectId: string
|
||||
worktreeId: string
|
||||
note: string
|
||||
kind: NoteLinkKind
|
||||
}
|
||||
|
||||
export type NotesPanelStateArgs = {
|
||||
projectId?: string | null
|
||||
worktreeId?: string | null
|
||||
}
|
||||
+2
-9
@@ -229,15 +229,9 @@ export type TabGroupLayoutNode =
|
||||
}
|
||||
|
||||
// ─── Unified Tab ────────────────────────────────────────────────────
|
||||
export type TabContentType =
|
||||
| 'terminal'
|
||||
| 'editor'
|
||||
| 'diff'
|
||||
| 'conflict-review'
|
||||
| 'browser'
|
||||
| 'notes'
|
||||
export type TabContentType = 'terminal' | 'editor' | 'diff' | 'conflict-review' | 'browser'
|
||||
|
||||
export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser' | 'notes'
|
||||
export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser'
|
||||
|
||||
export type Tab = {
|
||||
id: string // UUID for terminals, filePath for editors (preserves current convention)
|
||||
@@ -252,7 +246,6 @@ export type Tab = {
|
||||
createdAt: number
|
||||
isPreview?: boolean // preview tabs get replaced by next single-click open
|
||||
isPinned?: boolean // pinned tabs survive "close others"
|
||||
isDirty?: boolean // unsaved tab-local content, currently used by Project Notes
|
||||
}
|
||||
|
||||
export type TabGroup = {
|
||||
|
||||
@@ -65,16 +65,9 @@ const terminalTabSchema = z.object({
|
||||
|
||||
// ─── Unified tab model ──────────────────────────────────────────────
|
||||
|
||||
const tabContentTypeSchema = z.enum([
|
||||
'terminal',
|
||||
'editor',
|
||||
'diff',
|
||||
'conflict-review',
|
||||
'browser',
|
||||
'notes'
|
||||
])
|
||||
const tabContentTypeSchema = z.enum(['terminal', 'editor', 'diff', 'conflict-review', 'browser'])
|
||||
|
||||
const workspaceVisibleTabTypeSchema = z.enum(['terminal', 'editor', 'browser', 'notes'])
|
||||
const workspaceVisibleTabTypeSchema = z.enum(['terminal', 'editor', 'browser'])
|
||||
|
||||
const tabSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -88,8 +81,7 @@ const tabSchema = z.object({
|
||||
sortOrder: z.number(),
|
||||
createdAt: z.number(),
|
||||
isPreview: z.boolean().optional(),
|
||||
isPinned: z.boolean().optional(),
|
||||
isDirty: z.boolean().optional()
|
||||
isPinned: z.boolean().optional()
|
||||
})
|
||||
|
||||
const tabGroupSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user