feat(ui): open Develop artifacts in the OS editor

Develop stays read-first: each artifact whose source is a repository
path gains an Open in editor action routed through a path-validated
main-process handler (shell.openPath), instead of an in-app write path.
Persona and prompt-assembly sources open the governing cordis.yml or
source file directly.
This commit is contained in:
NI0317
2026-07-19 21:39:08 +08:00
parent 97df884937
commit c2fdfae170
8 changed files with 63 additions and 4 deletions
+1 -1
View File
@@ -189,6 +189,6 @@ Start with a desktop package that defines shared UI contracts, then build the El
- **Development build only** — this package ships a usable Electron/Vite app and a real ACP subprocess bridge, but it is not yet packaged as a signed distributable.
- **ACP is the first runtime channel** — direct in-process embedding could make context queries and restarts richer, but would make isolation, teardown, and hot reload harder.
- **Develop is read-first** — it exposes prompts, tools, plugins, config, runtime state, and the change loop as a source browser; direct graphical plugin/config editing is deferred.
- **Develop is read-first** — it exposes prompts, tools, plugins, config, runtime state, and the change loop as a source browser; editing routes to the OS default editor through each artifact's `Open in editor` action (repository paths only), and in-app graphical editing stays deferred.
- **Trace refresh is mixed live/persisted** — chat streams from ACP live updates (rendered incrementally, so composer input, fold state, and scroll survive streaming), while Trajectory and Waterfall read persisted JSONL after turns complete.
- **Context and Compare surfaces are unimplemented** — the session view ships `Chat`, `Trajectory`, and `Waterfall`; the `Context`/`Compare` contracts above and replay remain documented product shape for later work.
+23 -1
View File
@@ -1625,6 +1625,17 @@ async function handleDelegatedClick(event: MouseEvent): Promise<void> {
return
}
const openPath = target.closest<HTMLElement>('[data-open-path]')?.dataset.openPath
if (openPath !== undefined) {
try {
await window.dshDesktop.dev.openPath(openPath)
toast(t('dev.openedInEditor'))
} catch (error) {
toast(`${t('dev.openFailed')}: ${String(error)}`)
}
return
}
const copyTarget = target.closest<HTMLElement>('[data-copy-target]')
if (copyTarget !== null) {
const graphTarget = state.graph.targets.get(copyTarget.dataset.copyTarget ?? '')
@@ -1875,7 +1886,15 @@ function devListSubtitle(artifact: DevArtifact): string {
return artifact.subtitle
}
/** A source is editable through the OS editor only when it is a repository path. */
function editableSourcePath(artifact: DevArtifact): string | undefined {
const source = artifact.source ?? ''
if (/^(packages|examples|plugins|cordis\.yml)/.test(source)) return source.replace(/\/$/, '')
return undefined
}
function renderDevArtifactDetail(artifact: DevArtifact): string {
const editablePath = editableSourcePath(artifact)
return `
<article class="dev-detail-card ${artifact.kind}">
<header class="dev-detail-head">
@@ -1884,7 +1903,10 @@ function renderDevArtifactDetail(artifact: DevArtifact): string {
<h3>${escapeHtml(artifact.title)}</h3>
<p>${escapeHtml(artifact.subtitle)}</p>
</div>
<em>${escapeHtml(artifact.status ?? artifact.kind)}</em>
<div class="dev-detail-actions">
${editablePath === undefined ? '' : `<button type="button" data-open-path="${escapeHtml(editablePath)}">${escapeHtml(t('dev.openInEditor'))}</button>`}
<em>${escapeHtml(artifact.status ?? artifact.kind)}</em>
</div>
</header>
<section class="dev-detail-grid">
${renderDevFact(t('dev.source'), artifact.source ?? t('dev.unknown'))}
+1
View File
@@ -29,6 +29,7 @@ declare global {
}
dev: {
status(): Promise<unknown>
openPath(path: string): Promise<unknown>
}
}
}
+6
View File
@@ -75,6 +75,9 @@ const messages = {
'toast.copied': '已复制',
'feedback.saved': '标注已保存',
'feedback.failed': '标注保存失败',
'dev.openInEditor': '在编辑器中打开',
'dev.openedInEditor': '已在默认编辑器中打开',
'dev.openFailed': '打开失败',
'app.resizeSidebar': '调整侧栏宽度',
'app.resizeInspector': '调整检查器宽度',
'kind.user': '用户',
@@ -348,6 +351,9 @@ const messages = {
'toast.copied': 'Copied',
'feedback.saved': 'Feedback saved',
'feedback.failed': 'Failed to save feedback',
'dev.openInEditor': 'Open in editor',
'dev.openedInEditor': 'Opened in your default editor',
'dev.openFailed': 'Failed to open',
'app.resizeSidebar': 'Resize sidebar',
'app.resizeInspector': 'Resize inspector',
'kind.user': 'User',
+12
View File
@@ -529,6 +529,18 @@ function registerIpc() {
shell.showItemInFolder(file)
return { ok: true, path: file }
})
// Develop stays read-first: editing routes to the user's editor instead of an
// in-app write path. Only repository paths may be opened.
ipcMain.handle('dev:open-path', async (_event, { path }) => {
const target = resolve(repoRoot, String(path))
if (target !== repoRoot && !target.startsWith(`${repoRoot}/`)) {
throw new Error(`path escapes the repository: ${String(path)}`)
}
if (!existsSync(target)) throw new Error(`path not found: ${String(path)}`)
const error = await shell.openPath(target)
if (error.length > 0) throw new Error(error)
return { ok: true, path: target }
})
ipcMain.handle('trace:read', (_event, { sessionId }) => readTrace(String(sessionId)))
ipcMain.handle('feedback:list', (_event, { sessionId, targetId }) => readFeedback(String(sessionId), targetId === undefined ? undefined : String(targetId)))
ipcMain.handle('feedback:add', (_event, entry) => appendFeedback(entry))
+1
View File
@@ -39,6 +39,7 @@ const api = {
},
dev: {
status: () => ipcRenderer.invoke('dev:status'),
openPath: (path) => ipcRenderer.invoke('dev:open-path', { path }),
},
}
+17
View File
@@ -2977,3 +2977,20 @@ dd {
.empty-state p {
text-wrap: pretty;
}
.dev-detail-actions {
display: flex;
align-items: center;
gap: 8px;
}
.dev-detail-actions button {
height: var(--control-md);
padding: 0 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #fff;
color: var(--ink-soft);
font-size: 12px;
font-weight: 650;
}
+2 -2
View File
@@ -93,7 +93,7 @@ describe('desktop renderer chat lifecycle', () => {
},
trace: { read: async () => traceRead },
feedback: { list: async () => [], add: async () => ({}) },
dev: { status: async () => ({ git: {} }) },
dev: { status: async () => ({ git: {} }), openPath: async () => ({}) },
}
await import('../src/app.ts')
@@ -276,7 +276,7 @@ describe('desktop renderer chat lifecycle', () => {
},
trace: { read: async () => traceRead },
feedback: { list: async () => [], add: async () => ({}) },
dev: { status: async () => ({ git: {} }) },
dev: { status: async () => ({ git: {} }), openPath: async () => ({}) },
}
await import('../src/app.ts')