fix(editor): keep file basename in @-reference links

The explorer @ button appended plain text (@folder/file.ts), which DSH's
composer only decorates as a folder ref (@folder/) — the file name stayed
outside the link. Insert FILE references through DSH's own structured
insert-reference event instead, minting one chip for the whole reference
(displayed as @basename, serialized as the full @path). Directories keep the
plain @dir/ mention so folder completion still works.
This commit is contained in:
Ch3n4y
2026-09-03 10:00:13 +08:00
committed by Menghuan1918
parent 396017b459
commit 94ffac0224
8 changed files with 129 additions and 21 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ export function EditorHost(props: {
expanded: string[]
revealed: string[]
onToggleDir: (path: string) => void
onReferenceFile: (path: string) => void
onReferenceFile: (path: string, isDir: boolean) => void
}) {
const { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile } = props
const path = tab.path ?? ''
+4 -4
View File
@@ -131,8 +131,8 @@ export function FileTree(props: {
onOpenWith?: (targetId: string, path: string) => void
/** Toggle one target's pinned state (the submenu row's pushpin). */
onToggleOpenWithPin?: (targetId: string) => void
/** Insert `@<relative path>` into the composer draft. */
onReferenceFile: (path: string) => void
/** Insert `@<relative path>` into the composer draft (file vs directory). */
onReferenceFile: (path: string, isDir: boolean) => void
/** Bump to wipe the level cache and reload the visible set. */
refreshTick: number
/** Upload into `dir` (absolute, inside the workspace); runs in the caller. */
@@ -332,7 +332,7 @@ export function FileTree(props: {
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(entry.path)
onReferenceFile(entry.path, entry.isDir)
}}
>
{t('referenceFile')}
@@ -570,7 +570,7 @@ export function FileTree(props: {
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(root)
onReferenceFile(root, true)
}}
>
{t('referenceFile')}
+20 -11
View File
@@ -33,7 +33,7 @@ import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarSessionList } from '../context-types.ts'
import { appendToDraft } from './conversation-draft.ts'
import { appendToDraft, insertFileReference } from './conversation-draft.ts'
import {
BOTTOM_MIN, PANEL_MIN, activateTab, agentUuidOf, allLeaves, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab,
floatWithTab, isAgentTabId, leafWithTab, migrateBottomTabs,
@@ -128,7 +128,7 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
* pane; sessionId/cwd cover onReferenceFile). */
interface TabContentProps extends TabContentMemoKey {
onToggleDir: (path: string) => void
onReferenceFile: (path: string) => void
onReferenceFile: (path: string, isDir: boolean) => void
ctx: Context
store: SidebarStore
/** Fired before a topology node jumps to its child session (see Sidebar). */
@@ -1376,17 +1376,26 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
/**
* The explorer's @-reference button: append `@<relative path>` to the
* session's composer draft (space-separated). The conversation service is
* resolved lazily through `ctx.get` (the inject-free read — the app's own
* plugins read 'conversation' the same way); a missing service or scope
* degrades to a logged no-op, never a crash. Defined above the no-session
* early return — a hook must never sit behind a conditional return
* (React counts hooks per render).
* The explorer's @-reference button. Directories append the folder mention
* (`@dir/`) as plain text so DSH's folder decoration and completion keep
* working; files insert a structured chip like the native `@` picker, so
* the whole reference stays one link instead of decorating only the
* leading folder. Resolves the session-scope ctx and the conversation
* input service at click time; a missing service or scope degrades to a
* logged no-op, never a crash. Defined above the no-session early return
* — a hook must never sit behind a conditional return (React counts hooks
* per render).
*/
const referenceInChat = useCallback((path: string): void => {
const referenceInChat = useCallback((path: string, isDir: boolean): void => {
if (sessionId === undefined) return
appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
const rel = relativeTo(cwd ?? '', path)
if (isDir) {
appendToDraft(ctx, sessionId, `@${rel === '.' ? './' : `${rel}/`}`)
return
}
if (!insertFileReference(ctx, sessionId, rel)) {
appendToDraft(ctx, sessionId, `@${rel}`)
}
}, [ctx, sessionId, cwd])
if (state === undefined || sessionId === undefined) {
+1 -1
View File
@@ -62,7 +62,7 @@ export function TreePanel(props: {
openWithSsh?: boolean
onOpenWith?: (targetId: string, path: string) => void
onToggleOpenWithPin?: (targetId: string) => void
onReferenceFile: (path: string) => void
onReferenceFile: (path: string, isDir: boolean) => void
/** Full-window presentation: the panel fills its host instead of docking
* at a fixed width. */
full?: boolean
+68
View File
@@ -5,6 +5,14 @@
* through `ctx.get` (the inject-free read the app's own plugins use); a
* missing service or scope degrades to a logged no-op, never a crash.
*
* File references additionally use DSH's own structured insert event
* (`slash/input-insert-reference`, see `insertFileReference`) instead of
* plain draft text: the native `@file` picker emits this event, and the
* conversation input machine mints one occurrence whose chip covers the
* whole reference. Plain text `@folder/file.ts` only ever gets DSH's
* folder-ref decoration (`@folder/`) — the file name stays undecorated — so
* plain append is the fallback, not the primary path, for files.
*
* Insert position (fixes upstream issue #425): the draft store only exposes
* the whole string (`getSnapshot().draft` + `setDraft(text)`) — there is no
* caret API on the conversation service. The composer's `<textarea>` keeps
@@ -185,3 +193,63 @@ export function appendToDraft(ctx: Context, sessionId: string, text: string): bo
return false
}
}
/**
* The DSH `@file` spelling for one relative path, mirroring the host grammar
* (`formatFileMention` in `@deepseek-ai/dsh-file-reference`): plain when
* there is no whitespace, quoted when there is, and `undefined` when the
* path contains a control character or an embedded quote the editor grammar
* cannot represent.
*/
export function fileMention(relativePath: string): { mention: string; label: string } | undefined {
const path = relativePath.replace(/[\\/]+$/, '')
if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined
const mention = /\s/u.test(path) ? `@"${path}"` : `@${path}`
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
const label = at === -1 ? path : path.slice(at + 1)
return { mention, label }
}
/**
* Insert one FILE reference as a structured chip (like DSH's own `@` picker).
* The chip displays `@<basename>` but serializes to `@<relative path>` on
* send, so the reference stays a single link from trigger to basename.
*
* Directories are NOT handled here: DSH's folder grammar wants the trailing
* slash as plain text (`@dir/`) so completion can descend, which
* `appendToDraft` already covers.
*/
export function insertFileReference(ctx: Context, sessionId: string, relativePath: string): boolean {
const reference = fileMention(relativePath)
if (reference === undefined) return false
try {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) return false
const conversation = ctx.get('conversation') as SidebarConversation | undefined
if (conversation === undefined) return false
const input = conversation.input.for(actx)
const before = input.state.getSnapshot()
if (before.draftRev === undefined) return false
// The session-scope Context's typed `emit` is keyed to DSH's closed event
// map; this internal composer event is deliberately string-loose at runtime.
;(actx as unknown as { emit(name: string, payload: unknown): void }).emit('slash/input-insert-reference', {
reference: {
source: 'reference',
ref: reference.mention,
label: reference.label,
appearance: 'file',
clipboardText: reference.mention,
},
span: {
draftRev: before.draftRev,
start: before.draft.length,
end: before.draft.length,
},
})
const after = input.state.getSnapshot()
return after.draftRev !== before.draftRev
} catch (error) {
console.warn('[dsh-better-sidebar] file-reference insert failed:', error)
return false
}
}
+1 -1
View File
@@ -151,7 +151,7 @@ export interface TabComponentProps {
/** The explorer's reveal-highlight set (ExplorerView; "Show in folder" targets). */
revealed?: string[]
onToggleDir?: (path: string) => void
onReferenceFile?: (path: string) => void
onReferenceFile?: (path: string, isDir: boolean) => void
onOpenFile?: (path: string) => void
onOpenDiff?: (tab: SidebarTab) => void
onSubagentJump?: (childSessionId: string) => void
+3 -2
View File
@@ -391,9 +391,10 @@ export interface SidebarLocaleService {
/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
export interface SidebarSessionInput {
/** The live input store (draft read for append). */
/** The live input store (draft read for append). `draftRev` is the machine's
* span-CAS revision — required to mint a structured file-reference chip. */
state: {
getSnapshot(): { draft: string }
getSnapshot(): { draft: string; draftRev?: number }
}
/** Replace the draft text (the input machine's single public write path). */
setDraft(text: string): void
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { fileMention } from '../src/client/conversation-draft.ts'
describe('fileMention', () => {
it('formats a plain relative file path as an @file mention', () => {
expect(fileMention('src/client/paths.ts')).toEqual({
mention: '@src/client/paths.ts',
label: 'paths.ts',
})
})
it('quotes a path containing whitespace and keeps the full basename', () => {
expect(fileMention('docs/plan files/design notes.md')).toEqual({
mention: '@"docs/plan files/design notes.md"',
label: 'design notes.md',
})
})
it('trims a trailing separator before deriving the basename', () => {
expect(fileMention('src/client/')).toEqual({
mention: '@src/client',
label: 'client',
})
})
it('rejects embedded quotes and control characters', () => {
expect(fileMention('src/a"b.ts')).toBeUndefined()
expect(fileMention('src/a\u0000b.ts')).toBeUndefined()
})
})