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:
@@ -100,7 +100,7 @@ export function EditorHost(props: {
|
|||||||
expanded: string[]
|
expanded: string[]
|
||||||
revealed: string[]
|
revealed: string[]
|
||||||
onToggleDir: (path: string) => void
|
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 { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile } = props
|
||||||
const path = tab.path ?? ''
|
const path = tab.path ?? ''
|
||||||
|
|||||||
@@ -131,8 +131,8 @@ export function FileTree(props: {
|
|||||||
onOpenWith?: (targetId: string, path: string) => void
|
onOpenWith?: (targetId: string, path: string) => void
|
||||||
/** Toggle one target's pinned state (the submenu row's pushpin). */
|
/** Toggle one target's pinned state (the submenu row's pushpin). */
|
||||||
onToggleOpenWithPin?: (targetId: string) => void
|
onToggleOpenWithPin?: (targetId: string) => void
|
||||||
/** Insert `@<relative path>` into the composer draft. */
|
/** Insert `@<relative path>` into the composer draft (file vs directory). */
|
||||||
onReferenceFile: (path: string) => void
|
onReferenceFile: (path: string, isDir: boolean) => void
|
||||||
/** Bump to wipe the level cache and reload the visible set. */
|
/** Bump to wipe the level cache and reload the visible set. */
|
||||||
refreshTick: number
|
refreshTick: number
|
||||||
/** Upload into `dir` (absolute, inside the workspace); runs in the caller. */
|
/** Upload into `dir` (absolute, inside the workspace); runs in the caller. */
|
||||||
@@ -332,7 +332,7 @@ export function FileTree(props: {
|
|||||||
title={t('referenceFile')}
|
title={t('referenceFile')}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
onReferenceFile(entry.path)
|
onReferenceFile(entry.path, entry.isDir)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('referenceFile')}
|
{t('referenceFile')}
|
||||||
@@ -570,7 +570,7 @@ export function FileTree(props: {
|
|||||||
title={t('referenceFile')}
|
title={t('referenceFile')}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
onReferenceFile(root)
|
onReferenceFile(root, true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('referenceFile')}
|
{t('referenceFile')}
|
||||||
|
|||||||
+20
-11
@@ -33,7 +33,7 @@ import { useSyncExternalStore } from 'react'
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { Context, SidebarSessionList } from '../context-types.ts'
|
import type { Context, SidebarSessionList } from '../context-types.ts'
|
||||||
import { appendToDraft } from './conversation-draft.ts'
|
import { appendToDraft, insertFileReference } from './conversation-draft.ts'
|
||||||
import {
|
import {
|
||||||
BOTTOM_MIN, PANEL_MIN, activateTab, agentUuidOf, allLeaves, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab,
|
BOTTOM_MIN, PANEL_MIN, activateTab, agentUuidOf, allLeaves, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab,
|
||||||
floatWithTab, isAgentTabId, leafWithTab, migrateBottomTabs,
|
floatWithTab, isAgentTabId, leafWithTab, migrateBottomTabs,
|
||||||
@@ -128,7 +128,7 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
|
|||||||
* pane; sessionId/cwd cover onReferenceFile). */
|
* pane; sessionId/cwd cover onReferenceFile). */
|
||||||
interface TabContentProps extends TabContentMemoKey {
|
interface TabContentProps extends TabContentMemoKey {
|
||||||
onToggleDir: (path: string) => void
|
onToggleDir: (path: string) => void
|
||||||
onReferenceFile: (path: string) => void
|
onReferenceFile: (path: string, isDir: boolean) => void
|
||||||
ctx: Context
|
ctx: Context
|
||||||
store: SidebarStore
|
store: SidebarStore
|
||||||
/** Fired before a topology node jumps to its child session (see Sidebar). */
|
/** 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])
|
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The explorer's @-reference button: append `@<relative path>` to the
|
* The explorer's @-reference button. Directories append the folder mention
|
||||||
* session's composer draft (space-separated). The conversation service is
|
* (`@dir/`) as plain text so DSH's folder decoration and completion keep
|
||||||
* resolved lazily through `ctx.get` (the inject-free read — the app's own
|
* working; files insert a structured chip like the native `@` picker, so
|
||||||
* plugins read 'conversation' the same way); a missing service or scope
|
* the whole reference stays one link instead of decorating only the
|
||||||
* degrades to a logged no-op, never a crash. Defined above the no-session
|
* leading folder. Resolves the session-scope ctx and the conversation
|
||||||
* early return — a hook must never sit behind a conditional return
|
* input service at click time; a missing service or scope degrades to a
|
||||||
* (React counts hooks per render).
|
* 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
|
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])
|
}, [ctx, sessionId, cwd])
|
||||||
|
|
||||||
if (state === undefined || sessionId === undefined) {
|
if (state === undefined || sessionId === undefined) {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function TreePanel(props: {
|
|||||||
openWithSsh?: boolean
|
openWithSsh?: boolean
|
||||||
onOpenWith?: (targetId: string, path: string) => void
|
onOpenWith?: (targetId: string, path: string) => void
|
||||||
onToggleOpenWithPin?: (targetId: 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
|
/** Full-window presentation: the panel fills its host instead of docking
|
||||||
* at a fixed width. */
|
* at a fixed width. */
|
||||||
full?: boolean
|
full?: boolean
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
* through `ctx.get` (the inject-free read the app's own plugins use); a
|
* 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.
|
* 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
|
* Insert position (fixes upstream issue #425): the draft store only exposes
|
||||||
* the whole string (`getSnapshot().draft` + `setDraft(text)`) — there is no
|
* the whole string (`getSnapshot().draft` + `setDraft(text)`) — there is no
|
||||||
* caret API on the conversation service. The composer's `<textarea>` keeps
|
* caret API on the conversation service. The composer's `<textarea>` keeps
|
||||||
@@ -184,4 +192,64 @@ export function appendToDraft(ctx: Context, sessionId: string, text: string): bo
|
|||||||
console.warn('[dsh-better-sidebar] draft insert failed:', error)
|
console.warn('[dsh-better-sidebar] draft insert failed:', error)
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export interface TabComponentProps {
|
|||||||
/** The explorer's reveal-highlight set (ExplorerView; "Show in folder" targets). */
|
/** The explorer's reveal-highlight set (ExplorerView; "Show in folder" targets). */
|
||||||
revealed?: string[]
|
revealed?: string[]
|
||||||
onToggleDir?: (path: string) => void
|
onToggleDir?: (path: string) => void
|
||||||
onReferenceFile?: (path: string) => void
|
onReferenceFile?: (path: string, isDir: boolean) => void
|
||||||
onOpenFile?: (path: string) => void
|
onOpenFile?: (path: string) => void
|
||||||
onOpenDiff?: (tab: SidebarTab) => void
|
onOpenDiff?: (tab: SidebarTab) => void
|
||||||
onSubagentJump?: (childSessionId: string) => void
|
onSubagentJump?: (childSessionId: string) => void
|
||||||
|
|||||||
@@ -391,9 +391,10 @@ export interface SidebarLocaleService {
|
|||||||
|
|
||||||
/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
|
/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
|
||||||
export interface SidebarSessionInput {
|
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: {
|
state: {
|
||||||
getSnapshot(): { draft: string }
|
getSnapshot(): { draft: string; draftRev?: number }
|
||||||
}
|
}
|
||||||
/** Replace the draft text (the input machine's single public write path). */
|
/** Replace the draft text (the input machine's single public write path). */
|
||||||
setDraft(text: string): void
|
setDraft(text: string): void
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user