From c73fd2c90b30d4b5f19f80ca895d99c420df4b68 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 14 May 2026 16:33:24 -0400 Subject: [PATCH] Add project notes (#1861) Co-authored-by: Orca --- src/cli/args.ts | 5 +- src/cli/dispatch.ts | 4 +- src/cli/format.ts | 34 + src/cli/handlers/note.ts | 95 +++ src/cli/specs/index.ts | 4 +- src/cli/specs/note.ts | 37 + src/main/ipc/notes.ts | 40 + src/main/ipc/register-core-handlers.test.ts | 7 + src/main/ipc/register-core-handlers.ts | 2 + src/main/ipc/worktrees.test.ts | 4 +- src/main/ipc/worktrees.ts | 3 + src/main/notes/notes-markdown-store.test.ts | 76 ++ src/main/notes/notes-markdown-store.ts | 586 +++++++++++++++ src/main/runtime/orca-runtime.test.ts | 264 +++++++ src/main/runtime/orca-runtime.ts | 261 ++++++- src/main/runtime/rpc/errors.ts | 3 + src/main/runtime/rpc/methods/index.ts | 4 +- src/main/runtime/rpc/methods/notes.ts | 85 +++ src/preload/api-types.ts | 30 + src/preload/index.ts | 13 + src/renderer/src/components/Terminal.tsx | 20 + .../src/components/WorktreeJumpPalette.tsx | 2 +- .../FloatingTerminalPanel.tsx | 150 +++- .../notes/ProjectNotesTabContent.tsx | 681 ++++++++++++++++++ .../components/right-sidebar/NotesPanel.tsx | 358 +++++++++ .../src/components/right-sidebar/index.tsx | 10 +- .../src/components/sidebar/WorktreeCard.tsx | 5 + .../src/components/sidebar/WorktreeList.tsx | 5 + .../sidebar/visible-worktrees.test.ts | 33 +- .../components/sidebar/visible-worktrees.ts | 14 +- .../components/tab-bar/ProjectNotesTab.tsx | 131 ++++ .../tab-bar/TabBar.context-menu.test.ts | 13 + .../src/components/tab-bar/TabBar.tsx | 188 ++++- .../src/components/tab-bar/TabDragPreview.tsx | 10 +- .../src/components/tab-bar/group-tab-order.ts | 18 +- .../src/components/tab-bar/reconcile-order.ts | 7 +- .../components/tab-group/TabGroupPanel.tsx | 58 +- .../components/tab-group/useTabDragSplit.ts | 2 +- .../tab-group/useTabGroupWorkspaceModel.ts | 69 ++ .../src/components/terminal/tab-type-cycle.ts | 2 +- .../src/components/ui/dropdown-menu.tsx | 8 +- src/renderer/src/hooks/ipc-tab-switch.ts | 3 + src/renderer/src/hooks/resolve-zoom-target.ts | 4 +- src/renderer/src/hooks/useIpcEvents.ts | 10 + src/renderer/src/lib/notes-events.ts | 1 + .../src/lib/open-project-notes-tab.ts | 62 ++ .../lib/project-notes-close-request.test.ts | 47 ++ .../src/lib/project-notes-close-request.ts | 28 + src/renderer/src/lib/worktree-status.test.ts | 24 + src/renderer/src/lib/worktree-status.ts | 4 + src/renderer/src/store/slices/editor.ts | 8 +- .../src/store/slices/tabs-hydration.ts | 3 +- src/renderer/src/store/slices/tabs.test.ts | 50 ++ src/renderer/src/store/slices/tabs.ts | 25 +- src/renderer/src/store/slices/worktrees.ts | 8 +- src/shared/notes-types.ts | 130 ++++ src/shared/types.ts | 11 +- src/shared/workspace-session-schema.ts | 14 +- 58 files changed, 3695 insertions(+), 78 deletions(-) create mode 100644 src/cli/handlers/note.ts create mode 100644 src/cli/specs/note.ts create mode 100644 src/main/ipc/notes.ts create mode 100644 src/main/notes/notes-markdown-store.test.ts create mode 100644 src/main/notes/notes-markdown-store.ts create mode 100644 src/main/runtime/rpc/methods/notes.ts create mode 100644 src/renderer/src/components/notes/ProjectNotesTabContent.tsx create mode 100644 src/renderer/src/components/right-sidebar/NotesPanel.tsx create mode 100644 src/renderer/src/components/tab-bar/ProjectNotesTab.tsx create mode 100644 src/renderer/src/lib/notes-events.ts create mode 100644 src/renderer/src/lib/open-project-notes-tab.ts create mode 100644 src/renderer/src/lib/project-notes-close-request.test.ts create mode 100644 src/renderer/src/lib/project-notes-close-request.ts create mode 100644 src/shared/notes-types.ts diff --git a/src/cli/args.ts b/src/cli/args.ts index de0aff3c3..ece4215d8 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -62,7 +62,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean { if (['open', 'status'].includes(commandPath[0])) { return false } - if (['repo', 'worktree', 'terminal', 'computer'].includes(commandPath[0])) { + if (['repo', 'worktree', 'terminal', 'computer', 'note'].includes(commandPath[0])) { return false } return ![ @@ -92,7 +92,8 @@ export function isCommandGroup(commandPath: string[]): boolean { 'dialog', 'storage', 'orchestration', - 'computer' + 'computer', + 'note' ].includes(commandPath[0])) || (commandPath.length === 2 && commandPath[0] === 'storage' && diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 8ea7abf72..1937a4593 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -14,6 +14,7 @@ import { BROWSER_ENV_HANDLERS } from './handlers/browser-env' import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage' import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' import { COMPUTER_HANDLERS } from './handlers/computer' +import { NOTE_HANDLERS } from './handlers/note' export type HandlerContext = { flags: Map @@ -40,7 +41,8 @@ function buildHandlers(): Map { BROWSER_ENV_HANDLERS, BROWSER_STORAGE_HANDLERS, ORCHESTRATION_HANDLERS, - COMPUTER_HANDLERS + COMPUTER_HANDLERS, + NOTE_HANDLERS ] for (const group of groups) { for (const [key, handler] of Object.entries(group)) { diff --git a/src/cli/format.ts b/src/cli/format.ts index 93bc9d6fb..b8cce4771 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -33,6 +33,7 @@ import type { RuntimeWorktreePsResult, RuntimeWorktreeRecord } from '../shared/runtime-types' +import type { NoteListResult, NoteMutationResult, NoteShowResult } from '../shared/notes-types' import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client' import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' @@ -179,6 +180,39 @@ 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.' diff --git a/src/cli/handlers/note.ts b/src/cli/handlers/note.ts new file mode 100644 index 000000000..12ac16d10 --- /dev/null +++ b/src/cli/handlers/note.ts @@ -0,0 +1,95 @@ +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, + cwd: string, + client: Parameters[0]['client'] +): Promise { + 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, 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 = { + 'note list': async ({ flags, client, cwd, json }) => { + const result = await client.call('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('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('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('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('note.search', { + worktree: await getNoteWorktreeSelector(flags, cwd, client), + query: getRequiredStringFlag(flags, 'query'), + limit: getOptionalPositiveIntegerFlag(flags, 'limit') + }) + printResult(result, json, formatNoteList) + } +} diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index dad04e7cc..0828ef9b5 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -4,11 +4,13 @@ import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic' import { CORE_COMMAND_SPECS } from './core' import { ORCHESTRATION_COMMAND_SPECS } from './orchestration' import { COMPUTER_COMMAND_SPECS } from './computer' +import { NOTE_COMMAND_SPECS } from './note' export const COMMAND_SPECS: CommandSpec[] = [ ...CORE_COMMAND_SPECS, ...BROWSER_BASIC_COMMAND_SPECS, ...BROWSER_ADVANCED_COMMAND_SPECS, ...ORCHESTRATION_COMMAND_SPECS, - ...COMPUTER_COMMAND_SPECS + ...COMPUTER_COMMAND_SPECS, + ...NOTE_COMMAND_SPECS ] diff --git a/src/cli/specs/note.ts b/src/cli/specs/note.ts new file mode 100644 index 000000000..6b4d6e412 --- /dev/null +++ b/src/cli/specs/note.ts @@ -0,0 +1,37 @@ +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 ] [--limit ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'limit'] + }, + { + path: ['note', 'show'], + summary: 'Show a project note', + usage: 'orca note show --note [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'note', 'worktree'] + }, + { + path: ['note', 'create'], + summary: 'Create a project note', + usage: + 'orca note create --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'] + } +] diff --git a/src/main/ipc/notes.ts b/src/main/ipc/notes.ts new file mode 100644 index 000000000..88829ead8 --- /dev/null +++ b/src/main/ipc/notes.ts @@ -0,0 +1,40 @@ +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) + ) +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 8ad0f613b..3ba0dabaf 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -10,6 +10,7 @@ const { registerStatsHandlersMock, registerMemoryHandlersMock, registerNotebookHandlersMock, + registerNotesHandlersMock, registerNotificationHandlersMock, registerDeveloperPermissionHandlersMock, registerComputerUsePermissionHandlersMock, @@ -46,6 +47,7 @@ const { registerStatsHandlersMock: vi.fn(), registerMemoryHandlersMock: vi.fn(), registerNotebookHandlersMock: vi.fn(), + registerNotesHandlersMock: vi.fn(), registerNotificationHandlersMock: vi.fn(), registerDeveloperPermissionHandlersMock: vi.fn(), registerComputerUsePermissionHandlersMock: vi.fn(), @@ -118,6 +120,10 @@ vi.mock('./notebook', () => ({ registerNotebookHandlers: registerNotebookHandlersMock })) +vi.mock('./notes', () => ({ + registerNotesHandlers: registerNotesHandlersMock +})) + vi.mock('./notifications', () => ({ registerNotificationHandlers: registerNotificationHandlersMock })) @@ -218,6 +224,7 @@ describe('registerCoreHandlers', () => { registerStatsHandlersMock.mockReset() registerMemoryHandlersMock.mockReset() registerNotebookHandlersMock.mockReset() + registerNotesHandlersMock.mockReset() registerNotificationHandlersMock.mockReset() registerDeveloperPermissionHandlersMock.mockReset() registerComputerUsePermissionHandlersMock.mockReset() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index e15bc3132..490011b8f 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -16,6 +16,7 @@ import { registerStatsHandlers } from './stats' import { registerMemoryHandlers } from './memory' import { registerRateLimitHandlers } from './rate-limits' import { registerRuntimeHandlers } from './runtime' +import { registerNotesHandlers } from './notes' import { registerNotificationHandlers } from './notifications' import { registerNotebookHandlers } from './notebook' import { registerOnboardingHandlers } from './onboarding' @@ -112,6 +113,7 @@ export function registerCoreHandlers( registerFilesystemHandlers(store) registerFilesystemWatcherHandlers() registerRuntimeHandlers(runtime) + registerNotesHandlers(runtime) registerClipboardHandlers() registerUpdaterHandlers(store) warmSystemFontFamilies() diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index d7c81e657..25ff76c4d 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -166,6 +166,7 @@ describe('registerWorktreeHandlers', () => { recordOptimisticReconcileToken: ReturnType<typeof vi.fn> reconcileWorktreeBaseStatus: ReturnType<typeof vi.fn> clearOptimisticReconcileToken: ReturnType<typeof vi.fn> + unlinkNotesWorktree: ReturnType<typeof vi.fn> } beforeEach(() => { @@ -301,7 +302,8 @@ describe('registerWorktreeHandlers', () => { emitWorktreeBaseStatus: vi.fn(), recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'), reconcileWorktreeBaseStatus: vi.fn(), - clearOptimisticReconcileToken: vi.fn() + clearOptimisticReconcileToken: vi.fn(), + unlinkNotesWorktree: vi.fn().mockResolvedValue(undefined) } registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 4975ef6b0..8c7a1c0bc 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -442,6 +442,7 @@ export function registerWorktreeHandlers( } await provider.removeWorktree(worktreePath, args.force) runtime.clearOptimisticReconcileToken(args.worktreeId) + await runtime.unlinkNotesWorktree(repoId, args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) notifyWorktreesChanged(mainWindow, repoId) @@ -479,6 +480,7 @@ 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() @@ -488,6 +490,7 @@ export function registerWorktreeHandlers( throw new Error(formatWorktreeRemovalError(error, worktreePath, args.force ?? false)) } runtime.clearOptimisticReconcileToken(args.worktreeId) + await runtime.unlinkNotesWorktree(repoId, args.worktreeId) store.removeWorktreeMeta(args.worktreeId) deleteWorktreeHistoryDir(args.worktreeId) invalidateAuthorizedRootsCache() diff --git a/src/main/notes/notes-markdown-store.test.ts b/src/main/notes/notes-markdown-store.test.ts new file mode 100644 index 000000000..62793f2f4 --- /dev/null +++ b/src/main/notes/notes-markdown-store.test.ts @@ -0,0 +1,76 @@ +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([]) + }) +}) diff --git a/src/main/notes/notes-markdown-store.ts b/src/main/notes/notes-markdown-store.ts new file mode 100644 index 000000000..1cac4546c --- /dev/null +++ b/src/main/notes/notes-markdown-store.ts @@ -0,0 +1,586 @@ +/* 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] + } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 092ab1261..d04e6d6ef 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -11,6 +11,19 @@ import { import { OrchestrationDb } from './orchestration/db' import { OrcaRuntimeService } from './orca-runtime' +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, @@ -1607,6 +1620,120 @@ 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() @@ -1790,6 +1917,79 @@ 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')) @@ -1890,6 +2090,70 @@ 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 diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 4a9e5a66f..ad5f0aa47 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9,7 +9,7 @@ import { import type { AgentStatus } from '../../shared/agent-detection' import { gitExecFileAsync } from '../git/runner' import { isWslPath, parseWslPath, getWslHome } from '../wsl' -import { randomUUID } from 'crypto' +import { createHash, randomUUID } from 'crypto' import { join, posix, win32 } from 'path' import { open, rm, stat } from 'fs/promises' import { OrchestrationDb } from './orchestration/db' @@ -108,7 +108,7 @@ import type { BrowserConsoleResult, BrowserNetworkLogResult } from '../../shared/runtime-types' -import { BrowserWindow, ipcMain } from 'electron' +import { app, BrowserWindow, ipcMain } from 'electron' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import { browserManager } from '../browser/browser-manager' import { BrowserError } from '../browser/cdp-bridge' @@ -163,6 +163,24 @@ 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, + NoteListArgs, + NoteListResult, + NoteMutationResult, + NoteRenameArgs, + NoteSaveArgs, + NoteSearchArgs, + NoteShowArgs, + NoteShowResult, + NotesPanelOpenState, + NotesPanelStateArgs +} from '../../shared/notes-types' type RuntimeAccountServices = { claudeAccounts: ClaudeAccountService @@ -711,6 +729,7 @@ export class OrcaRuntimeService { private optimisticReconcileTokens = new Map<string, string>() private readonly getLocalProviderFn: (() => IPtyProvider) | null private accountServices: RuntimeAccountServices | null = null + private _notesStore: NotesMarkdownStore | null = null constructor( store: RuntimeStore | null = null, @@ -755,6 +774,17 @@ 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 } @@ -3694,6 +3724,134 @@ 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 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 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 @@ -3898,20 +4056,65 @@ export class OrcaRuntimeService { // unknown repository or worktree path". invalidateAuthorizedRootsCache() this.notifier?.worktreesChanged(repo.id) - const shouldActivate = args.activate === true || args.runHooks === true || Boolean(args.startup) + const shouldActivate = args.activate === true || args.runHooks === true + let didSpawnStartup = false + let didSpawnSetup = false + if (args.startup && this.ptyController?.spawn) { + try { + // Why: automation startup must not depend on a renderer TerminalPane + // mounting. Runtime-spawned PTYs run immediately and the UI adopts the + // session later, matching `orca terminal create` background semantics. + await this.createTerminal(`path:${worktree.path}`, { + command: args.startup.command, + env: args.startup.env + }) + didSpawnStartup = true + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warning = warning + ? `${warning} Also failed to create the startup terminal for ${worktreePath}: ${message}` + : `Failed to create the startup terminal for ${worktreePath}: ${message}` + console.warn(`[worktree-create] ${warning}`) + } + } + if (didSpawnStartup && setup && this.ptyController?.spawn) { + try { + // Why: reveal-on-adopt can create the startup tab before renderer + // activation handles setup. Spawn setup in runtime too so startup+setup + // cannot be skipped by the renderer's "terminal already exists" guard. + await this.createTerminal(`path:${worktree.path}`, { + title: 'Setup', + command: buildSetupRunnerCommand( + setup.runnerScriptPath, + process.platform === 'win32' ? 'windows' : 'posix' + ), + env: setup.envVars + }) + didSpawnSetup = true + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + warning = warning + ? `${warning} Also failed to create the setup terminal for ${worktreePath}: ${message}` + : `Failed to create the setup terminal for ${worktreePath}: ${message}` + console.warn(`[worktree-create] ${warning}`) + } + } if (shouldActivate) { // Why: plain CLI creates should not steal the user's current workspace. - // Startup launches still use renderer activation because they are an - // explicit request to start visible work in the new worktree. - if (args.startup) { - this.notifier?.activateWorktree(repo.id, worktree.id, setup, args.startup) + // Explicit activation and hook-running still use renderer activation so + // the user can watch prompts/output in a visible pane. + const activationSetup = didSpawnSetup ? undefined : setup + if (args.startup && !didSpawnStartup) { + this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup, args.startup) } else { - this.notifier?.activateWorktree(repo.id, worktree.id, setup) + this.notifier?.activateWorktree(repo.id, worktree.id, activationSetup) } } else if (this.ptyController?.spawn) { try { - await this.createTerminal(`path:${worktree.path}`) - if (setup) { + if (!didSpawnStartup) { + await this.createTerminal(`path:${worktree.path}`) + } + if (setup && !didSpawnSetup) { await this.createTerminal(`path:${worktree.path}`, { title: 'Setup', command: buildSetupRunnerCommand( @@ -4367,6 +4570,7 @@ 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() @@ -4379,6 +4583,7 @@ 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() @@ -4994,6 +5199,42 @@ 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') diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index da19f1cba..efb204411 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -43,6 +43,9 @@ 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' ]) diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index f312491ab..85b7b364f 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -12,6 +12,7 @@ import { ACCOUNT_METHODS } from './accounts' import { COMPUTER_METHODS } from './computer' import { SESSION_TAB_METHODS } from './session-tabs' import { FILE_METHODS } from './files' +import { NOTE_METHODS } from './notes' // Why: a flat manifest keeps registration order explicit and provides one // grep-point for "what methods does the RPC server expose?" — useful when @@ -29,5 +30,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...ACCOUNT_METHODS, ...COMPUTER_METHODS, ...SESSION_TAB_METHODS, - ...FILE_METHODS + ...FILE_METHODS, + ...NOTE_METHODS ] diff --git a/src/main/runtime/rpc/methods/notes.ts b/src/main/runtime/rpc/methods/notes.ts new file mode 100644 index 000000000..a4013f766 --- /dev/null +++ b/src/main/runtime/rpc/methods/notes.ts @@ -0,0 +1,85 @@ +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() +}) + +const NoteAppendParams = NoteScopedParams.extend({ + note: requiredString('Missing note selector'), + bodyMarkdown: requiredString('Missing note body'), + makeActive: z.boolean().optional() +}) + +const NoteSearchParams = NoteScopedParams.extend({ + query: requiredString('Missing search query'), + limit: OptionalFiniteNumber +}) + +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 + }) + }), + 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 + }) + }), + defineMethod({ + name: 'note.search', + params: NoteSearchParams, + handler: async (params, { runtime }) => + await runtime.searchNotes({ + worktreeSelector: params.worktree, + query: params.query, + limit: params.limit + }) + }) +] diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ea6ee2a14..529ac0ea4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -178,6 +178,24 @@ 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: { @@ -1062,6 +1080,18 @@ 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> diff --git a/src/preload/index.ts b/src/preload/index.ts index 4bdb579ec..04962277a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -324,6 +324,19 @@ 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'), diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index e6980c8ba..4d5f987b1 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -29,6 +29,7 @@ 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' @@ -49,6 +50,7 @@ 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 @@ -649,6 +651,16 @@ 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() @@ -969,6 +981,13 @@ 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 } @@ -1187,6 +1206,7 @@ function Terminal(): React.JSX.Element | null { onNewTerminalWithShell={handleNewTab} onNewBrowserTab={handleNewBrowserTab} onNewFileTab={handleNewFile} + onNewNotesTab={handleNewNotesTab} wslAvailable={wslAvailable} onSetCustomTitle={setTabCustomTitle} onSetTabColor={setTabColor} diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index cfa7f2a6d..1589c5553 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -172,7 +172,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'>('terminal') + const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal' | 'notes'>('terminal') const previousBrowserPageIdRef = useRef<string | null>(null) const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview') const wasVisibleRef = useRef(false) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 6cde9a008..ff78f85a6 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -1,3 +1,4 @@ +/* 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 { Maximize2, Minimize2, Minus } from 'lucide-react' import TabBar from '@/components/tab-bar/TabBar' @@ -11,10 +12,13 @@ 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 { useAppStore } from '@/store' 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' export { FloatingTerminalToggleButton } from './FloatingTerminalToggleButton' import { @@ -24,6 +28,7 @@ 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 @@ -46,6 +51,7 @@ 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()) @@ -54,6 +60,9 @@ 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) @@ -144,6 +153,7 @@ 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] ?? [] @@ -162,6 +172,7 @@ export function FloatingTerminalPanel({ const closeFloatingTab = useCallback( (tabId: string) => { closeTab(tabId) + setActiveSurface('terminal') }, [closeTab] ) @@ -190,6 +201,72 @@ 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 + let label = 'Project Notes' + if (noteId && projectId) { + try { + const result = await window.api.notes.show({ + projectId, + worktreeId: activeWorktreeId, + note: noteId + }) + label = result.note.title + } catch { + label = 'Project Notes' + } + if (projectId) { + await window.api.notes.link({ + projectId, + worktreeId: activeWorktreeId, + note: noteId, + kind: 'active' + }) + notifyProjectNotesSelectionChanged() + } + } + const id = `floating-project-notes:${globalThis.crypto.randomUUID()}` + 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) { @@ -292,19 +369,31 @@ export function FloatingTerminalPanel({ activeTabId={activeTab?.id ?? null} worktreeId={FLOATING_TERMINAL_WORKTREE_ID} expandedPaneByTabId={expandedPaneByTabId} - onActivate={(tabId) => setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tabId)} + onActivate={(tabId) => { + setActiveSurface('terminal') + 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) } - activeTabType="terminal" + notesTabs={activeWorktreeId ? notesTabs : []} + activeNotesTabId={activeSurface === 'notes' ? activeNotesTabId : null} + onActivateNotesTab={(tabId) => { + setActiveNotesTabId(tabId) + setActiveSurface('notes') + }} + onCloseNotesTab={closeFloatingNotesTab} + activeTabType={activeSurface} tabBarOrder={tabBarOrder} /> </div> @@ -352,27 +441,42 @@ export function FloatingTerminalPanel({ </div> <div className="relative min-h-0 flex-1 overflow-hidden bg-background"> - {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} + {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} </div> </div> {showOrchestrationSetup ? ( diff --git a/src/renderer/src/components/notes/ProjectNotesTabContent.tsx b/src/renderer/src/components/notes/ProjectNotesTabContent.tsx new file mode 100644 index 000000000..4f511405a --- /dev/null +++ b/src/renderer/src/components/notes/ProjectNotesTabContent.tsx @@ -0,0 +1,681 @@ +/* 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 { + 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 [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 window.api.notes.list({ projectId, worktreeId, limit: 100 }) + setNotes(result.notes) + }, [projectId, worktreeId]) + + const loadPanelState = useCallback(async (): Promise<void> => { + try { + setError(null) + if (selectedNoteId && projectId) { + const result = await window.api.notes.show({ 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 window.api.notes.panelState({ 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, 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 window.api.notes.show({ projectId, worktreeId, note: noteId }) + await window.api.notes.link({ 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, 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 window.api.notes.save({ + 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, 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 window.api.notes.create({ + 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, 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> + ) +} diff --git a/src/renderer/src/components/right-sidebar/NotesPanel.tsx b/src/renderer/src/components/right-sidebar/NotesPanel.tsx new file mode 100644 index 000000000..6b934f7d0 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/NotesPanel.tsx @@ -0,0 +1,358 @@ +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 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 [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 window.api.notes.list({ projectId, worktreeId, limit: 100 }) + setNotes(result.notes) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + }, [projectId, 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 window.api.notes.rename({ + 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, 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 window.api.notes.delete({ 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, 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> + ) +} diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index bc196b046..66bcaa556 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react' -import { Files, Search, GitBranch, ListChecks, Cable, PanelRight } from 'lucide-react' +import { Files, Search, GitBranch, ListChecks, Cable, PanelRight, FileText } from 'lucide-react' import { useAppStore } from '@/store' import { getRepoMapFromState, useActiveWorktree, useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' @@ -22,6 +22,7 @@ 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 @@ -87,6 +88,12 @@ 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, @@ -245,6 +252,7 @@ function RightSidebarInner(): React.JSX.Element { {effectiveTab === 'source-control' && <SourceControl />} {effectiveTab === 'checks' && <ChecksPanel />} {effectiveTab === 'ports' && <PortsPanel />} + {effectiveTab === 'notes' && <NotesPanel />} </div> </div> ) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index b783c467e..22d08d02f 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -153,6 +153,9 @@ const WorktreeCard = React.memo(function WorktreeCard({ // ── GRANULAR selectors: only subscribe to THIS worktree's data ── const tabs = useAppStore((s) => s.tabsByWorktree[worktree.id] ?? EMPTY_TABS) const browserTabs = useAppStore((s) => s.browserTabsByWorktree[worktree.id] ?? EMPTY_BROWSER_TABS) + const hasNotesSurface = useAppStore((s) => + (s.unifiedTabsByWorktree[worktree.id] ?? []).some((tab) => tab.contentType === 'notes') + ) // Why: keep these as separate shallow selectors. Combining them into one // returned object nests freshly-created maps under fresh keys, so Zustand's // shallow memoization sees every unrelated store write as a change and @@ -287,6 +290,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ browserTabs, ptyIdsByTabId: ptyIdsForWorktree, runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree, + hasNotesSurface, hasPermission, hasLiveDone, hasRetainedDone @@ -296,6 +300,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ browserTabs, ptyIdsForWorktree, runtimePaneTitlesForWorktree, + hasNotesSurface, hasPermission, hasLiveDone, hasRetainedDone diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index ef2250db6..a58b538d1 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -581,6 +581,9 @@ 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 ) @@ -828,6 +831,7 @@ const WorktreeList = React.memo(function WorktreeList() { showActiveOnly, tabsByWorktree, ptyIdsByTabId, + unifiedTabsByWorktree, browserTabsByWorktree, activeWorktreeId, hideDefaultBranchWorkspace, @@ -842,6 +846,7 @@ const WorktreeList = React.memo(function WorktreeList() { repoMap, tabsByWorktree, ptyIdsByTabId, + unifiedTabsByWorktree, browserTabsByWorktree, sortedIds, worktreeMap, diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index d53d8095d..becd27e9d 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -6,7 +6,7 @@ import { isDefaultBranchWorkspace, sidebarHasActiveFilters } from './visible-worktrees' -import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' +import type { Repo, Tab, TerminalTab, Worktree } from '../../../../shared/types' function makeTab(id: string, worktreeId: string, ptyId: string | null): TerminalTab { return { @@ -21,6 +21,21 @@ 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, @@ -95,6 +110,22 @@ 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') diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 2f6a632b8..78cccf01c 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -1,4 +1,4 @@ -import type { Worktree, Repo, TerminalTab } from '../../../../shared/types' +import type { Worktree, Repo, Tab, TerminalTab } from '../../../../shared/types' import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { useAppStore } from '@/store' @@ -81,6 +81,7 @@ 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 @@ -114,11 +115,15 @@ 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, - // and the currently selected worktree should never vanish from the list. - return hasLiveTerminal || hasBrowserTabs || opts.activeWorktreeId === w.id + // 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 }) } @@ -202,6 +207,7 @@ 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, diff --git a/src/renderer/src/components/tab-bar/ProjectNotesTab.tsx b/src/renderer/src/components/tab-bar/ProjectNotesTab.tsx new file mode 100644 index 000000000..415bf6d85 --- /dev/null +++ b/src/renderer/src/components/tab-bar/ProjectNotesTab.tsx @@ -0,0 +1,131 @@ +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> + </> + ) +} diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 1170506a3..e76a2c69e 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -26,6 +26,7 @@ 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 @@ -36,6 +37,9 @@ vi.mock('lucide-react', () => ({ FilePlus: function FilePlus() { return null }, + FileText: function FileText() { + return null + }, Globe: function Globe() { return null }, @@ -123,6 +127,15 @@ 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 } } diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 7629827a5..7f15947cf 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -5,7 +5,7 @@ * more clarity than the ~5 lines of bloat is worth. */ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { SortableContext } from '@dnd-kit/sortable' -import { FilePlus, Globe, Plus, TerminalSquare } from 'lucide-react' +import { FilePlus, Globe, Plus, TerminalSquare, FileText } from 'lucide-react' import type { BrowserTab as BrowserTabState, TerminalTab, @@ -17,6 +17,7 @@ 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' @@ -32,8 +33,12 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import type { NoteSummary } from '../../../../shared/notes-types' const isMac = navigator.userAgent.includes('Mac') const isWindows = navigator.userAgent.includes('Windows') @@ -58,6 +63,8 @@ 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 @@ -66,13 +73,17 @@ 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 @@ -98,6 +109,7 @@ 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') { @@ -106,6 +118,9 @@ 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) } @@ -125,18 +140,24 @@ 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, @@ -153,6 +174,10 @@ function TabBarInner({ (s) => s.settings?.terminalWindowsPowerShellImplementation ?? 'auto' ) 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) @@ -162,6 +187,8 @@ function TabBarInner({ void window.api.pwsh.isAvailable().then(setPwshAvailable) }, []) const resolvedGroupId = groupId ?? worktreeId + const targetNotesWorktreeId = notesWorktreeId ?? worktreeId + const statusByRelativePath = useMemo( () => buildStatusMap(gitStatusByWorktree[worktreeId] ?? []), [worktreeId, gitStatusByWorktree] @@ -182,6 +209,49 @@ 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 window.api.notes.list({ + 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, 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])), @@ -191,14 +261,22 @@ 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) + const ids = reconcileTabOrder( + tabBarOrder, + terminalIds, + editorFileIds, + browserTabIds, + notesTabIds + ) const items: TabItem[] = [] for (const id of ids) { const terminal = terminalMap.get(id) @@ -224,10 +302,25 @@ function TabBarInner({ unifiedTabId: browserTab.tabId ?? browserTab.id, data: browserTab }) + continue + } + const notesTab = notesMap.get(id) + if (notesTab) { + items.push({ type: 'notes', id, unifiedTabId: notesTab.id, data: notesTab }) } } return items - }, [tabBarOrder, terminalIds, editorFileIds, browserTabIds, terminalMap, editorMap, browserMap]) + }, [ + tabBarOrder, + terminalIds, + editorFileIds, + browserTabIds, + notesTabIds, + terminalMap, + editorMap, + browserMap, + notesMap + ]) const sortableIds = useMemo(() => orderedItems.map((item) => item.id), [orderedItems]) @@ -426,6 +519,24 @@ 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} @@ -564,6 +675,77 @@ 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 /> diff --git a/src/renderer/src/components/tab-bar/TabDragPreview.tsx b/src/renderer/src/components/tab-bar/TabDragPreview.tsx index ad0e51591..314dad985 100644 --- a/src/renderer/src/components/tab-bar/TabDragPreview.tsx +++ b/src/renderer/src/components/tab-bar/TabDragPreview.tsx @@ -1,4 +1,4 @@ -import { FileCode, Globe, Terminal as TerminalIcon } from 'lucide-react' +import { FileCode, Globe, Terminal as TerminalIcon, FileText } from 'lucide-react' import type { TabDragItemData } from '../tab-group/useTabDragSplit' // Why: rendered inside dnd-kit's DragOverlay (a document-level portal), so @@ -9,7 +9,13 @@ 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 : TerminalIcon + drag.tabType === 'browser' + ? Globe + : drag.tabType === 'editor' + ? FileCode + : drag.tabType === 'notes' + ? FileText + : 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" /> diff --git a/src/renderer/src/components/tab-bar/group-tab-order.ts b/src/renderer/src/components/tab-bar/group-tab-order.ts index dd4475cb8..983e51d08 100644 --- a/src/renderer/src/components/tab-bar/group-tab-order.ts +++ b/src/renderer/src/components/tab-bar/group-tab-order.ts @@ -3,7 +3,7 @@ import type { AppState } from '../../store/types' import { reconcileTabOrder } from './reconcile-order' export type VisibleTabRef = { - type: 'terminal' | 'editor' | 'browser' + type: 'terminal' | 'editor' | 'browser' | 'notes' id: string tabId?: string } @@ -56,6 +56,7 @@ 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) { @@ -73,6 +74,12 @@ 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 @@ -114,6 +121,9 @@ export function getActiveTabNavOrder( const terminalIds = (state.tabsByWorktree[worktreeId] ?? []).map((t) => t.id) const editorIds = state.openFiles.filter((f) => f.worktreeId === worktreeId).map((f) => f.id) const browserIds = (state.browserTabsByWorktree[worktreeId] ?? []).map((t) => t.id) + const notesIds = (state.unifiedTabsByWorktree[worktreeId] ?? []) + .filter((tab) => tab.contentType === 'notes') + .map((tab) => tab.id) const activeGroupId = state.activeGroupIdByWorktree[worktreeId] const group = activeGroupId @@ -138,11 +148,13 @@ export function getActiveTabNavOrder( state.tabBarOrderByWorktree[worktreeId], terminalIds, editorIds, - browserIds + browserIds, + notesIds ) 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)) { @@ -151,6 +163,8 @@ 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 diff --git a/src/renderer/src/components/tab-bar/reconcile-order.ts b/src/renderer/src/components/tab-bar/reconcile-order.ts index 6fce831d9..af2b4629a 100644 --- a/src/renderer/src/components/tab-bar/reconcile-order.ts +++ b/src/renderer/src/components/tab-bar/reconcile-order.ts @@ -7,9 +7,10 @@ export function reconcileTabOrder( storedOrder: string[] | undefined, terminalIds: string[], editorIds: string[], - browserIds: string[] = [] + browserIds: string[] = [], + notesIds: string[] = [] ): string[] { - const validIds = new Set([...terminalIds, ...editorIds, ...browserIds]) + const validIds = new Set([...terminalIds, ...editorIds, ...browserIds, ...notesIds]) // 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 @@ -24,7 +25,7 @@ export function reconcileTabOrder( inResult.add(id) } } - for (const id of [...terminalIds, ...editorIds, ...browserIds]) { + for (const id of [...terminalIds, ...editorIds, ...browserIds, ...notesIds]) { if (!inResult.has(id)) { result.push(id) inResult.add(id) diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index bd86341ad..ec92e9748 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -1,3 +1,4 @@ +/* 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' @@ -19,8 +20,13 @@ 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, @@ -56,7 +62,8 @@ export default function TabGroupPanel({ }, []) const model = useTabGroupWorkspaceModel({ groupId, worktreeId }) - const { activeTab, browserItems, commands, editorItems, tabBarOrder, terminalTabs } = model + const { activeTab, browserItems, commands, editorItems, notesItems, tabBarOrder, terminalTabs } = + model const { setNodeRef: setBodyDropRef } = useDroppable({ id: getTabPaneBodyDroppableId(groupId), data: { @@ -119,23 +126,30 @@ 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 === 'terminal' || + activeTab?.contentType === 'browser' || + activeTab?.contentType === 'notes' ? 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' - : 'editor' + : activeTab?.contentType === 'notes' + ? 'notes' + : 'editor' } onActivateFile={commands.activateEditor} onCloseFile={commands.closeItem} @@ -148,6 +162,15 @@ 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) => { @@ -338,9 +361,36 @@ 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 !== 'browser' && + activeTab.contentType !== 'notes' && ( <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 diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index 22c7f5d4d..838e9deac 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -34,7 +34,7 @@ export type TabDragItemData = { groupId: string unifiedTabId: string visibleTabId: string - tabType: 'terminal' | 'editor' | 'browser' + tabType: 'terminal' | 'editor' | 'browser' | 'notes' /** 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 diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index c6ca76c84..dabf84525 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -14,9 +14,12 @@ 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' 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[] = [] @@ -140,6 +143,19 @@ 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( @@ -193,6 +209,14 @@ 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) { @@ -226,6 +250,10 @@ 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) { @@ -283,6 +311,21 @@ 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 = @@ -421,6 +464,7 @@ export function useTabGroupWorkspaceModel({ activeTab, browserItems, editorItems, + notesItems, terminalTabs, tabBarOrder, groupTabs, @@ -431,6 +475,7 @@ export function useTabGroupWorkspaceModel({ }, activateBrowser, activateEditor, + activateNotes, activateTerminal, closeAllEditorTabsInGroup, closeGroup, @@ -474,6 +519,30 @@ 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 result = await window.api.notes.show({ projectId, worktreeId, note: noteId }) + label = result.note.title + await window.api.notes.link({ 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) diff --git a/src/renderer/src/components/terminal/tab-type-cycle.ts b/src/renderer/src/components/terminal/tab-type-cycle.ts index af86198f3..1fcd48d62 100644 --- a/src/renderer/src/components/terminal/tab-type-cycle.ts +++ b/src/renderer/src/components/terminal/tab-type-cycle.ts @@ -1,4 +1,4 @@ -export type TabCycleType = 'terminal' | 'editor' | 'browser' +export type TabCycleType = 'terminal' | 'editor' | 'browser' | 'notes' export type TypeCyclableTab = { type: TabCycleType diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx index 0861a7d57..cd3dad0ba 100644 --- a/src/renderer/src/components/ui/dropdown-menu.tsx +++ b/src/renderer/src/components/ui/dropdown-menu.tsx @@ -32,7 +32,7 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - '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', + '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', 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( - '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', + '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', className )} - // Why: same no-drag fix as DropdownMenuContent — titlebar drag region - // would otherwise capture clicks when submenu overlaps it. + // Why: submenus must escape the parent menu's scroll clipping; the + // portal also needs no-drag for titlebar-overlapping menus in Electron. style={{ ...style, WebkitAppRegion: 'no-drag' } as React.CSSProperties} {...props} /> diff --git a/src/renderer/src/hooks/ipc-tab-switch.ts b/src/renderer/src/hooks/ipc-tab-switch.ts index 632d65e53..88fac6dd7 100644 --- a/src/renderer/src/hooks/ipc-tab-switch.ts +++ b/src/renderer/src/hooks/ipc-tab-switch.ts @@ -67,6 +67,9 @@ 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)` diff --git a/src/renderer/src/hooks/resolve-zoom-target.ts b/src/renderer/src/hooks/resolve-zoom-target.ts index 19b52857a..255fbf386 100644 --- a/src/renderer/src/hooks/resolve-zoom-target.ts +++ b/src/renderer/src/hooks/resolve-zoom-target.ts @@ -4,7 +4,7 @@ */ export function resolveZoomTarget(args: { activeView: 'terminal' | 'settings' | 'tasks' | 'activity' | 'automations' - activeTabType: 'terminal' | 'editor' | 'browser' + activeTabType: 'terminal' | 'editor' | 'browser' | 'notes' 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' || editorFocused) { + if (activeTabType === 'editor' || activeTabType === 'notes' || editorFocused) { return 'editor' } // Why: terminal tabs should keep using per-pane terminal font zoom even when diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 5c0a130fa..1c3e120b6 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -34,6 +34,7 @@ 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' @@ -766,6 +767,15 @@ 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) + }) + } } }) ) diff --git a/src/renderer/src/lib/notes-events.ts b/src/renderer/src/lib/notes-events.ts new file mode 100644 index 000000000..ede0a9b96 --- /dev/null +++ b/src/renderer/src/lib/notes-events.ts @@ -0,0 +1 @@ +export const NOTES_ACTIVE_CHANGED_EVENT = 'orca:notes-active-changed' diff --git a/src/renderer/src/lib/open-project-notes-tab.ts b/src/renderer/src/lib/open-project-notes-tab.ts new file mode 100644 index 000000000..f072ecd22 --- /dev/null +++ b/src/renderer/src/lib/open-project-notes-tab.ts @@ -0,0 +1,62 @@ +import { useAppStore } from '@/store' +import { NOTES_ACTIVE_CHANGED_EVENT } from '@/lib/notes-events' + +export function getProjectNotesEntityId(projectId: string, noteId?: string): string { + if (noteId) { + return `notes:${projectId}:note:${noteId}` + } + return `notes:${projectId}:new:${globalThis.crypto.randomUUID()}` +} + +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 + + if (noteId && projectId) { + await window.api.notes.link({ projectId, worktreeId, note: noteId, kind: 'active' }) + } + + let label = 'Project Notes' + if (noteId && projectId) { + try { + const result = await window.api.notes.show({ 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)) +} diff --git a/src/renderer/src/lib/project-notes-close-request.test.ts b/src/renderer/src/lib/project-notes-close-request.test.ts new file mode 100644 index 000000000..afdafb769 --- /dev/null +++ b/src/renderer/src/lib/project-notes-close-request.test.ts @@ -0,0 +1,47 @@ +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) + }) +}) diff --git a/src/renderer/src/lib/project-notes-close-request.ts b/src/renderer/src/lib/project-notes-close-request.ts new file mode 100644 index 000000000..3984d665d --- /dev/null +++ b/src/renderer/src/lib/project-notes-close-request.ts @@ -0,0 +1,28 @@ +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() + } +} diff --git a/src/renderer/src/lib/worktree-status.test.ts b/src/renderer/src/lib/worktree-status.test.ts index 681bc1e74..8b16fa295 100644 --- a/src/renderer/src/lib/worktree-status.test.ts +++ b/src/renderer/src/lib/worktree-status.test.ts @@ -188,4 +188,28 @@ 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') + }) }) diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 1f2380d68..afec4bd72 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -98,6 +98,7 @@ 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 @@ -125,5 +126,8 @@ export function resolveWorktreeStatus(args: { if (args.hasLiveDone || args.hasRetainedDone) { return 'done' } + if (heuristic === 'inactive' && args.hasNotesSurface) { + return 'active' + } return heuristic } diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index e2c0ec0fb..04f77a127 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -124,7 +124,13 @@ export type OpenFile = { mode: 'edit' | 'diff' | 'conflict-review' | 'markdown-preview' } -export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks' | 'ports' +export type RightSidebarTab = + | 'explorer' + | 'search' + | 'source-control' + | 'checks' + | 'ports' + | 'notes' export type ActivityBarPosition = 'top' | 'side' export type MarkdownViewMode = 'source' | 'rich' | 'preview' diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index 2a50381d2..886fecc0b 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -61,7 +61,8 @@ function hydrateUnifiedFormat( tabsByWorktree[worktreeId] = [...tabs] .map((tab) => ({ ...tab, - entityId: tab.entityId ?? tab.id + entityId: tab.entityId ?? tab.id, + isDirty: false })) .filter((tab) => { if (!isTransientEditorContentType(tab.contentType)) { diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index e979ce342..fa0b67b5a 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -962,6 +962,14 @@ 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 ──────────────────────────────────────────── @@ -1491,5 +1499,47 @@ 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') + }) }) }) diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 01b1951e6..5882d970c 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -60,7 +60,9 @@ 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 @@ -227,7 +229,13 @@ function collapseGroupLayout( } function toVisibleTabType(contentType: TabContentType): WorkspaceVisibleTabType { - return contentType === 'browser' ? 'browser' : contentType === 'terminal' ? 'terminal' : 'editor' + return contentType === 'browser' + ? 'browser' + : contentType === 'terminal' + ? 'terminal' + : contentType === 'notes' + ? 'notes' + : 'editor' } function deriveActiveSurfaceForWorktree( @@ -430,7 +438,8 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set, sortOrder: nextOrder.length, createdAt: Date.now(), isPreview: init?.isPreview, - isPinned: init?.isPinned + isPinned: init?.isPinned, + isDirty: false } nextOrder = dedupeTabOrder([...nextOrder, created.id]) @@ -728,6 +737,12 @@ 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 }) ?? {}), @@ -1359,6 +1374,12 @@ 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) } diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 2304f93ff..d804e49c4 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -59,7 +59,13 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b } function toVisibleTabType(contentType: string): WorkspaceVisibleTabType { - return contentType === 'browser' ? 'browser' : contentType === 'terminal' ? 'terminal' : 'editor' + return contentType === 'browser' + ? 'browser' + : contentType === 'terminal' + ? 'terminal' + : contentType === 'notes' + ? 'notes' + : 'editor' } export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> = (set, get) => ({ diff --git a/src/shared/notes-types.ts b/src/shared/notes-types.ts new file mode 100644 index 000000000..447beae89 --- /dev/null +++ b/src/shared/notes-types.ts @@ -0,0 +1,130 @@ +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 +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 8895ea9d1..5c92a781f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -201,9 +201,15 @@ export type TabGroupLayoutNode = } // ─── Unified Tab ──────────────────────────────────────────────────── -export type TabContentType = 'terminal' | 'editor' | 'diff' | 'conflict-review' | 'browser' +export type TabContentType = + | 'terminal' + | 'editor' + | 'diff' + | 'conflict-review' + | 'browser' + | 'notes' -export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser' +export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser' | 'notes' export type Tab = { id: string // UUID for terminals, filePath for editors (preserves current convention) @@ -218,6 +224,7 @@ 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 = { diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index cb17b297a..40bb23453 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -65,9 +65,16 @@ const terminalTabSchema = z.object({ // ─── Unified tab model ────────────────────────────────────────────── -const tabContentTypeSchema = z.enum(['terminal', 'editor', 'diff', 'conflict-review', 'browser']) +const tabContentTypeSchema = z.enum([ + 'terminal', + 'editor', + 'diff', + 'conflict-review', + 'browser', + 'notes' +]) -const workspaceVisibleTabTypeSchema = z.enum(['terminal', 'editor', 'browser']) +const workspaceVisibleTabTypeSchema = z.enum(['terminal', 'editor', 'browser', 'notes']) const tabSchema = z.object({ id: z.string(), @@ -81,7 +88,8 @@ const tabSchema = z.object({ sortOrder: z.number(), createdAt: z.number(), isPreview: z.boolean().optional(), - isPinned: z.boolean().optional() + isPinned: z.boolean().optional(), + isDirty: z.boolean().optional() }) const tabGroupSchema = z.object({