parent
a7eb3e7179
commit
c73fd2c90b
|
|
@ -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' &&
|
||||
|
|
|
|||
|
|
@ -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<string, string | boolean>
|
||||
|
|
@ -40,7 +41,8 @@ function buildHandlers(): Map<string, CommandHandler> {
|
|||
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)) {
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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<string, string | boolean>,
|
||||
cwd: string,
|
||||
client: Parameters<CommandHandler>[0]['client']
|
||||
): Promise<string> {
|
||||
const worktree = await getBrowserWorktreeSelector(flags, cwd, client)
|
||||
if (!worktree) {
|
||||
throw new RuntimeClientError(
|
||||
'selector_not_found',
|
||||
'No Orca-managed worktree contains the current directory. Pass --worktree.'
|
||||
)
|
||||
}
|
||||
return worktree
|
||||
}
|
||||
|
||||
function readBody(flags: Map<string, string | boolean>, required: boolean): string | undefined {
|
||||
const body = getOptionalStringFlag(flags, 'body')
|
||||
const bodyFile = getOptionalStringFlag(flags, 'body-file')
|
||||
const bodyStdin = flags.get('body-stdin') === true
|
||||
const specified = [body !== undefined, bodyFile !== undefined, bodyStdin].filter(Boolean).length
|
||||
if (specified > 1) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Pass only one of --body, --body-file, or --body-stdin'
|
||||
)
|
||||
}
|
||||
if (body !== undefined) {
|
||||
return body
|
||||
}
|
||||
if (bodyFile) {
|
||||
return readFileSync(bodyFile, 'utf8')
|
||||
}
|
||||
if (bodyStdin) {
|
||||
return readFileSync(0, 'utf8')
|
||||
}
|
||||
if (required) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing note body')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const NOTE_HANDLERS: Record<string, CommandHandler> = {
|
||||
'note list': async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<NoteListResult>('note.list', {
|
||||
worktree: await getNoteWorktreeSelector(flags, cwd, client),
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
|
||||
})
|
||||
printResult(result, json, formatNoteList)
|
||||
},
|
||||
'note show': async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<NoteShowResult>('note.show', {
|
||||
worktree: await getNoteWorktreeSelector(flags, cwd, client),
|
||||
note: getRequiredStringFlag(flags, 'note')
|
||||
})
|
||||
printResult(result, json, formatNoteShow)
|
||||
},
|
||||
'note create': async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<NoteMutationResult>('note.create', {
|
||||
worktree: await getNoteWorktreeSelector(flags, cwd, client),
|
||||
title: getRequiredStringFlag(flags, 'title'),
|
||||
bodyMarkdown: readBody(flags, false),
|
||||
makeActive: true
|
||||
})
|
||||
printResult(result, json, formatNoteMutation)
|
||||
},
|
||||
'note append': async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<NoteMutationResult>('note.append', {
|
||||
worktree: await getNoteWorktreeSelector(flags, cwd, client),
|
||||
note: getRequiredStringFlag(flags, 'note'),
|
||||
bodyMarkdown: readBody(flags, true),
|
||||
makeActive: true
|
||||
})
|
||||
printResult(result, json, formatNoteMutation)
|
||||
},
|
||||
'note search': async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<NoteListResult>('note.search', {
|
||||
worktree: await getNoteWorktreeSelector(flags, cwd, client),
|
||||
query: getRequiredStringFlag(flags, 'query'),
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
|
||||
})
|
||||
printResult(result, json, formatNoteList)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 <selector>] [--limit <n>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'limit']
|
||||
},
|
||||
{
|
||||
path: ['note', 'show'],
|
||||
summary: 'Show a project note',
|
||||
usage: 'orca note show --note <selector> [--worktree <selector>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'note', 'worktree']
|
||||
},
|
||||
{
|
||||
path: ['note', 'create'],
|
||||
summary: 'Create a project note',
|
||||
usage:
|
||||
'orca note create --title <title> [--body <text>|--body-file <path>|--body-stdin] [--worktree <selector>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'title', 'body', 'body-file', 'body-stdin', 'worktree']
|
||||
},
|
||||
{
|
||||
path: ['note', 'append'],
|
||||
summary: 'Append Markdown to a project note',
|
||||
usage:
|
||||
'orca note append --note <selector> [--body <text>|--body-file <path>|--body-stdin] [--worktree <selector>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'note', 'body', 'body-file', 'body-stdin', 'worktree']
|
||||
},
|
||||
{
|
||||
path: ['note', 'search'],
|
||||
summary: 'Search project notes',
|
||||
usage: 'orca note search --query <text> [--worktree <selector>] [--limit <n>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'query', 'worktree', 'limit']
|
||||
}
|
||||
]
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
]
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type TabCycleType = 'terminal' | 'editor' | 'browser'
|
||||
export type TabCycleType = 'terminal' | 'editor' | 'browser' | 'notes'
|
||||
|
||||
export type TypeCyclableTab = {
|
||||
type: TabCycleType
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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)`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export const NOTES_ACTIVE_CHANGED_EVENT = 'orca:notes-active-changed'
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue