dsh-coding-sidebar 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/lib/client-editor.js +436 -227
- package/lib/client-registry.js +627 -7391
- package/lib/client-terminal.js +234 -182
- package/lib/client.js +634 -7398
- package/lib/index.js +367 -67
- package/lib/types/agent-pty.d.ts +62 -4
- package/lib/types/bundle-route.d.ts +1 -1
- package/lib/types/client/EditorHost.d.ts +1 -1
- package/lib/types/client/FileTree.d.ts +2 -2
- package/lib/types/client/TreePanel.d.ts +1 -1
- package/lib/types/client/chunk-loader.d.ts +1 -1
- package/lib/types/client/chunks/locale.d.ts +3 -0
- package/lib/types/client/conversation-draft.d.ts +85 -4
- package/lib/types/client/locales.d.ts +1 -20
- package/lib/types/client/selection-popup.d.ts +58 -0
- package/lib/types/client/service.d.ts +1 -1
- package/lib/types/client/terminal-font.d.ts +25 -2
- package/lib/types/context-types.d.ts +3 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/prefs-shared.d.ts +7 -5
- package/lib/types/pty-manager.d.ts +53 -0
- package/lib/types/wire.d.ts +6 -2
- package/package.json +1 -1
- package/src/agent-pty.ts +221 -33
- package/src/bundle-route.ts +1 -1
- package/src/client/EditorHost.tsx +1 -1
- package/src/client/FileTree.tsx +4 -4
- package/src/client/Sidebar.tsx +41 -22
- package/src/client/TerminalView.tsx +13 -0
- package/src/client/TextEditor.tsx +27 -45
- package/src/client/TreePanel.tsx +16 -1
- package/src/client/chunk-loader.ts +1 -1
- package/src/client/chunks/locale.tsx +60 -0
- package/src/client/conversation-draft.ts +233 -7
- package/src/client/index.tsx +19 -8
- package/src/client/locales-ar.ts +5 -4
- package/src/client/locales-de.ts +5 -4
- package/src/client/locales-fr.ts +5 -4
- package/src/client/locales-hi.ts +5 -4
- package/src/client/locales-id.ts +5 -4
- package/src/client/locales-it.ts +5 -4
- package/src/client/locales-ja.ts +5 -4
- package/src/client/locales-ko.ts +5 -4
- package/src/client/locales-nl.ts +5 -4
- package/src/client/locales-pl.ts +5 -4
- package/src/client/locales-pt.ts +5 -4
- package/src/client/locales-ru.ts +5 -4
- package/src/client/locales-sv.ts +5 -4
- package/src/client/locales-th.ts +5 -4
- package/src/client/locales-tr.ts +5 -4
- package/src/client/locales-vi.ts +5 -4
- package/src/client/locales-zh-HK.ts +5 -4
- package/src/client/locales-zh-MO.ts +5 -4
- package/src/client/locales-zh-TW.ts +5 -4
- package/src/client/locales.ts +17 -51
- package/src/client/selection-popup.ts +155 -0
- package/src/client/service.ts +1 -1
- package/src/client/state.ts +14 -7
- package/src/client/terminal-font.ts +34 -3
- package/src/context-types.ts +3 -2
- package/src/git.ts +17 -1
- package/src/index.ts +39 -6
- package/src/open-external.ts +3 -4
- package/src/prefs-shared.ts +7 -5
- package/src/pty-manager.ts +176 -5
- package/src/sidechat-routes.ts +13 -1
- package/src/tools.ts +24 -6
- package/src/wire.ts +3 -0
|
@@ -26,6 +26,7 @@ import { languageForPath } from './lang.ts'
|
|
|
26
26
|
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
|
27
27
|
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
|
28
28
|
import { appendToDraft } from './conversation-draft.ts'
|
|
29
|
+
import { useSelectionPopup } from './selection-popup.ts'
|
|
29
30
|
import { buildSelectionInsert } from './selection-payload.ts'
|
|
30
31
|
import { t } from './locales.ts'
|
|
31
32
|
import type { EditorToolbarControls, EditorToolbarState } from './service.ts'
|
|
@@ -50,13 +51,6 @@ export interface TextEditorProps {
|
|
|
50
51
|
onToolbarControls?: (controls: EditorToolbarControls | null) => void
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
/** The floating "add to conversation" action: payload + viewport anchor. */
|
|
54
|
-
interface SelectionPopup {
|
|
55
|
-
insert: string
|
|
56
|
-
left: number
|
|
57
|
-
top: number
|
|
58
|
-
}
|
|
59
|
-
|
|
60
54
|
export function TextEditor(props: TextEditorProps) {
|
|
61
55
|
const { ctx, scope, path, content, truncated } = props
|
|
62
56
|
const [dirty, setDirty] = useState(false)
|
|
@@ -68,34 +62,21 @@ export function TextEditor(props: TextEditorProps) {
|
|
|
68
62
|
const themeCompRef = useRef<CmThemeCompartment | null>(null)
|
|
69
63
|
/** The app's resolved color scheme; the editor re-themes in place on flips. */
|
|
70
64
|
const [dark, setDark] = useState(() => isDarkScheme())
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
top,
|
|
87
|
-
}
|
|
88
|
-
popupRef.current = next
|
|
89
|
-
setPopup(next)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** The popup button's click: insert the stored payload into the draft. */
|
|
93
|
-
const commitPopup = (): void => {
|
|
94
|
-
const current = popupRef.current
|
|
95
|
-
if (current === null) return
|
|
96
|
-
appendToDraft(ctx, scope.sessionId, current.insert)
|
|
97
|
-
hidePopup()
|
|
98
|
-
}
|
|
65
|
+
/**
|
|
66
|
+
* The floating "add to conversation" popup (viewport-anchored; null =
|
|
67
|
+
* hidden). The hook owns show/hide/commit plus the global dismissal
|
|
68
|
+
* listeners (outside mousedown, Escape, hidden tab/window, and the editor
|
|
69
|
+
* surface leaving the viewport — the tab-switch/panel-collapse paths have
|
|
70
|
+
* no DOM events of their own) — see selection-popup.ts.
|
|
71
|
+
*/
|
|
72
|
+
const selectionPopup = useSelectionPopup({
|
|
73
|
+
onCommit: (insert) => { appendToDraft(ctx, scope.sessionId, insert) },
|
|
74
|
+
// The surface that must stay on screen: the CodeMirror host. This
|
|
75
|
+
// component has no preview surface (file preview is retired); without
|
|
76
|
+
// the geometry signal a tab switch (display:none) or a panel collapse
|
|
77
|
+
// (translated off-screen) would leave the fixed portaled button behind.
|
|
78
|
+
getSurface: () => hostRef.current,
|
|
79
|
+
})
|
|
99
80
|
|
|
100
81
|
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
|
|
101
82
|
|
|
@@ -103,7 +84,7 @@ export function TextEditor(props: TextEditorProps) {
|
|
|
103
84
|
useEffect(() => {
|
|
104
85
|
setDirty(false)
|
|
105
86
|
setSaveState('idle')
|
|
106
|
-
|
|
87
|
+
selectionPopup.hide()
|
|
107
88
|
}, [content])
|
|
108
89
|
|
|
109
90
|
// Create the CodeMirror editor once the content is loaded. The view owns
|
|
@@ -149,33 +130,33 @@ export function TextEditor(props: TextEditorProps) {
|
|
|
149
130
|
// selection and hides it too.
|
|
150
131
|
CodeMirrorView.updateListener.of((update) => {
|
|
151
132
|
if (update.geometryChanged || update.viewportChanged) {
|
|
152
|
-
|
|
133
|
+
selectionPopup.hide()
|
|
153
134
|
return
|
|
154
135
|
}
|
|
155
136
|
if (!update.view.hasFocus) {
|
|
156
|
-
|
|
137
|
+
selectionPopup.hide()
|
|
157
138
|
return
|
|
158
139
|
}
|
|
159
140
|
if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
|
|
160
141
|
const sel = update.state.selection.main
|
|
161
142
|
if (sel.empty) {
|
|
162
|
-
|
|
143
|
+
selectionPopup.hide()
|
|
163
144
|
return
|
|
164
145
|
}
|
|
165
146
|
const text = update.state.sliceDoc(sel.from, sel.to)
|
|
166
147
|
if (text.trim() === '') {
|
|
167
|
-
|
|
148
|
+
selectionPopup.hide()
|
|
168
149
|
return
|
|
169
150
|
}
|
|
170
151
|
// Page coordinates (the document root may scroll); the popup is
|
|
171
152
|
// position:fixed, so convert to viewport coordinates.
|
|
172
153
|
const rect = update.view.coordsAtPos(sel.head)
|
|
173
154
|
if (rect === null) {
|
|
174
|
-
|
|
155
|
+
selectionPopup.hide()
|
|
175
156
|
return
|
|
176
157
|
}
|
|
177
158
|
const doc = update.state.doc
|
|
178
|
-
|
|
159
|
+
selectionPopup.show(
|
|
179
160
|
buildSelectionInsert(path, scope.cwd, {
|
|
180
161
|
start: doc.lineAt(sel.from).number,
|
|
181
162
|
end: doc.lineAt(sel.to).number,
|
|
@@ -272,15 +253,16 @@ export function TextEditor(props: TextEditorProps) {
|
|
|
272
253
|
<div className={css.editorCm} ref={hostRef} />
|
|
273
254
|
</>
|
|
274
255
|
)}
|
|
275
|
-
{popup !== null && createPortal(
|
|
256
|
+
{selectionPopup.popup !== null && createPortal(
|
|
276
257
|
<button
|
|
277
258
|
type="button"
|
|
259
|
+
ref={selectionPopup.buttonRef}
|
|
278
260
|
className={css.selectionPopup}
|
|
279
|
-
style={{ left: popup.left, top: popup.top }}
|
|
261
|
+
style={{ left: selectionPopup.popup.left, top: selectionPopup.popup.top }}
|
|
280
262
|
// Keep the selection (and CodeMirror focus) alive until the click
|
|
281
263
|
// commits — without this the popup unmounts before click lands.
|
|
282
264
|
onMouseDown={(event) => { event.preventDefault() }}
|
|
283
|
-
onClick={
|
|
265
|
+
onClick={selectionPopup.commit}
|
|
284
266
|
>
|
|
285
267
|
{t('addToConversation')}
|
|
286
268
|
</button>,
|
package/src/client/TreePanel.tsx
CHANGED
|
@@ -59,7 +59,7 @@ export function TreePanel(props: {
|
|
|
59
59
|
openWithSsh?: boolean
|
|
60
60
|
onOpenWith?: (targetId: string, path: string) => void
|
|
61
61
|
onToggleOpenWithPin?: (targetId: string) => void
|
|
62
|
-
onReferenceFile: (path: string) => void
|
|
62
|
+
onReferenceFile: (path: string, isDir: boolean) => void
|
|
63
63
|
/** Full-window presentation: the panel fills its host instead of docking
|
|
64
64
|
* at a fixed width. */
|
|
65
65
|
full?: boolean
|
|
@@ -69,6 +69,21 @@ export function TreePanel(props: {
|
|
|
69
69
|
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
|
|
70
70
|
const [error, setError] = useState<string | null>(null)
|
|
71
71
|
const [refreshTick, setRefreshTick] = useState(0)
|
|
72
|
+
|
|
73
|
+
// The tree caches loaded directories per refresh tick, so content changed
|
|
74
|
+
// outside DSH (another editor, a sync tool) stays stale until the manual
|
|
75
|
+
// refresh click. Re-focusing the window bumps the tick automatically, and
|
|
76
|
+
// integrations can force a refresh by dispatching a bubbling
|
|
77
|
+
// `dsh-sidebar:refresh-files` event on `window`.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const bump = (): void => { setRefreshTick(tick => tick + 1) }
|
|
80
|
+
window.addEventListener('focus', bump)
|
|
81
|
+
window.addEventListener('dsh-sidebar:refresh-files', bump)
|
|
82
|
+
return () => {
|
|
83
|
+
window.removeEventListener('focus', bump)
|
|
84
|
+
window.removeEventListener('dsh-sidebar:refresh-files', bump)
|
|
85
|
+
}
|
|
86
|
+
}, [])
|
|
72
87
|
/** One-line upload status under the search row ('' hides the hint). */
|
|
73
88
|
const [uploadStatus, setUploadStatus] = useState('')
|
|
74
89
|
/** Whether the status line is a failure/cancel (error color, stays visible). */
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
* client.js); an edit that does land while a core HMR happens is caught by
|
|
49
49
|
* the ETag comparison on the next activation.
|
|
50
50
|
*/
|
|
51
|
-
export type ChunkName = 'terminal' | 'editor'
|
|
51
|
+
export type ChunkName = 'terminal' | 'editor' | 'locale'
|
|
52
52
|
|
|
53
53
|
/** The module exports a chunk factory provides (namespace-ish record). */
|
|
54
54
|
export type ChunkExports = Record<string, unknown>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy chunk entry: the 19 non-zh/en dictionaries (ja/de/fr/…/zh-HK/MO/TW —
|
|
3
|
+
* ~640KB of source). The sidebar's own `t()` only ever consults zh/en plus
|
|
4
|
+
* the better-locale override store, so these dicts are needed ONLY when
|
|
5
|
+
* @huanlin/dsh-plugin-better-locale is installed: they register into its
|
|
6
|
+
* override store from there (see the client apply's better-locale
|
|
7
|
+
* integration). Built as `lib/client-locale.js` and fetched from the
|
|
8
|
+
* plugin's /sidebar/bundle route on first need — never import this module
|
|
9
|
+
* from the core bundle: it would drag every language back into the startup
|
|
10
|
+
* path.
|
|
11
|
+
*/
|
|
12
|
+
import { ja } from '../locales-ja.ts'
|
|
13
|
+
import { de } from '../locales-de.ts'
|
|
14
|
+
import { fr } from '../locales-fr.ts'
|
|
15
|
+
import { pt } from '../locales-pt.ts'
|
|
16
|
+
import { ko } from '../locales-ko.ts'
|
|
17
|
+
import { ar } from '../locales-ar.ts'
|
|
18
|
+
import { hi } from '../locales-hi.ts'
|
|
19
|
+
import { id } from '../locales-id.ts'
|
|
20
|
+
import { tr } from '../locales-tr.ts'
|
|
21
|
+
import { vi } from '../locales-vi.ts'
|
|
22
|
+
import { th } from '../locales-th.ts'
|
|
23
|
+
import { ru } from '../locales-ru.ts'
|
|
24
|
+
import { it } from '../locales-it.ts'
|
|
25
|
+
import { nl } from '../locales-nl.ts'
|
|
26
|
+
import { sv } from '../locales-sv.ts'
|
|
27
|
+
import { pl } from '../locales-pl.ts'
|
|
28
|
+
import { zhHK } from '../locales-zh-HK.ts'
|
|
29
|
+
import { zhTW } from '../locales-zh-TW.ts'
|
|
30
|
+
import { zhMO } from '../locales-zh-MO.ts'
|
|
31
|
+
import type { CopyKey } from '../locales.ts'
|
|
32
|
+
|
|
33
|
+
/** Key-set check against zh (type-only import — erased before bundling, so
|
|
34
|
+
* this adds no runtime tie to the core bundle): a dictionary missing or
|
|
35
|
+
* adding a key fails the build instead of silently falling back to en. */
|
|
36
|
+
const checked = (dict: Record<CopyKey, string>): Record<string, string> => dict
|
|
37
|
+
|
|
38
|
+
/** Every non-zh/en dictionary keyed by its override id, ready for the
|
|
39
|
+
* better-locale store's `register(ns, dicts)`. */
|
|
40
|
+
export const localeDicts: Record<string, Record<string, string>> = {
|
|
41
|
+
ja: checked(ja),
|
|
42
|
+
de: checked(de),
|
|
43
|
+
fr: checked(fr),
|
|
44
|
+
pt: checked(pt),
|
|
45
|
+
ko: checked(ko),
|
|
46
|
+
ar: checked(ar),
|
|
47
|
+
hi: checked(hi),
|
|
48
|
+
id: checked(id),
|
|
49
|
+
tr: checked(tr),
|
|
50
|
+
vi: checked(vi),
|
|
51
|
+
th: checked(th),
|
|
52
|
+
ru: checked(ru),
|
|
53
|
+
it: checked(it),
|
|
54
|
+
nl: checked(nl),
|
|
55
|
+
sv: checked(sv),
|
|
56
|
+
pl: checked(pl),
|
|
57
|
+
'zh-HK': checked(zhHK),
|
|
58
|
+
'zh-TW': checked(zhTW),
|
|
59
|
+
'zh-MO': checked(zhMO),
|
|
60
|
+
}
|
|
@@ -1,29 +1,255 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Insert text into the current session's composer draft through the
|
|
3
3
|
* conversation service — the shared path behind the explorer's @-reference
|
|
4
4
|
* button and the viewer selection popup. The service is resolved lazily
|
|
5
5
|
* through `ctx.get` (the inject-free read the app's own plugins use); a
|
|
6
6
|
* missing service or scope degrades to a logged no-op, never a crash.
|
|
7
|
+
*
|
|
8
|
+
* File references additionally use DSH's own structured insert event
|
|
9
|
+
* (`slash/input-insert-reference`, see `insertFileReference`) instead of
|
|
10
|
+
* plain draft text: the native `@file` picker emits this event, and the
|
|
11
|
+
* conversation input machine mints one occurrence whose chip covers the
|
|
12
|
+
* whole reference. Plain text `@folder/file.ts` only ever gets DSH's
|
|
13
|
+
* folder-ref decoration (`@folder/`) — the file name stays undecorated — so
|
|
14
|
+
* plain append is the fallback, not the primary path, for files.
|
|
15
|
+
*
|
|
16
|
+
* Insert position (upstream issue #425): the draft store only exposes the
|
|
17
|
+
* whole string (`getSnapshot().draft` + `setDraft(text)`) — there is no
|
|
18
|
+
* caret API on the conversation service. The composer's `<textarea>` keeps
|
|
19
|
+
* its last selection even while unfocused, so the live caret is probed from
|
|
20
|
+
* the DOM (guarded by a value-sync check), and the text is spliced at that
|
|
21
|
+
* position, replacing any live selection — with whitespace-aware joins, an
|
|
22
|
+
* insert into the middle of a sentence keeps single-space separation like
|
|
23
|
+
* the append path. An unknown/stale caret falls back to appending at the
|
|
24
|
+
* end (the pre-fix behavior).
|
|
25
|
+
*
|
|
26
|
+
* The caret is also *restored* after the insert: committing a programmatic
|
|
27
|
+
* draft change resets the controlled textarea's caret (observed landing at
|
|
28
|
+
* the start of the value), which would make every later insert probe the
|
|
29
|
+
* reset position and drift the stack (A|B + C + D ended up as |DACB). The
|
|
30
|
+
* placement (`placeComposerCaretAfterInsert`) puts the caret right after the
|
|
31
|
+
* inserted text once the value commit lands — the index accounts for the
|
|
32
|
+
* separating space on the left, so stacked inserts stay at their running
|
|
33
|
+
* position (A|B + C + D → ACD|B).
|
|
7
34
|
*/
|
|
8
35
|
import type { Context, SidebarConversation } from '../context-types.ts'
|
|
9
36
|
|
|
37
|
+
/** A resolved composer caret/selection in draft coordinates. */
|
|
38
|
+
export interface DraftCaret {
|
|
39
|
+
start: number
|
|
40
|
+
end: number
|
|
41
|
+
}
|
|
42
|
+
|
|
10
43
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
44
|
+
* The spliced draft plus the caret index (in that draft) right after the
|
|
45
|
+
* inserted text — the left-side separating space shifts the caret by one,
|
|
46
|
+
* which naive `start + text.length` misses (it would land before the tail).
|
|
47
|
+
*/
|
|
48
|
+
interface SpliceResult {
|
|
49
|
+
draft: string
|
|
50
|
+
caretAfter: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Splice `text` into `draft` at `caret` (replacing any live selection) with
|
|
55
|
+
* whitespace-aware joins and report the caret position right after the
|
|
56
|
+
* inserted text. `caret === null` (position unknown) appends at the end,
|
|
57
|
+
* exactly like the original behavior.
|
|
58
|
+
*/
|
|
59
|
+
function spliceInsert(draft: string, text: string, caret: DraftCaret | null): SpliceResult {
|
|
60
|
+
if (caret === null || draft === '') {
|
|
61
|
+
const next = draft.trim() === '' ? text : `${draft} ${text}`
|
|
62
|
+
return { draft: next, caretAfter: next.length }
|
|
63
|
+
}
|
|
64
|
+
const prefix = draft.slice(0, caret.start)
|
|
65
|
+
const suffix = draft.slice(caret.end)
|
|
66
|
+
if (prefix === '' && suffix === '') return { draft: text, caretAfter: text.length }
|
|
67
|
+
// One separating space, but never doubled against adjacent whitespace
|
|
68
|
+
// (or the string edges) — mirrors how typing in the middle of a sentence
|
|
69
|
+
// behaves.
|
|
70
|
+
const left = prefix === '' || /\s$/.test(prefix) ? '' : ' '
|
|
71
|
+
const right = suffix === '' || /^\s/.test(suffix) ? '' : ' '
|
|
72
|
+
return {
|
|
73
|
+
draft: `${prefix}${left}${text}${right}${suffix}`,
|
|
74
|
+
caretAfter: prefix.length + left.length + text.length,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The spliced draft string (see {@link spliceInsert}); pure string math.
|
|
80
|
+
*/
|
|
81
|
+
export function insertAtCaret(draft: string, text: string, caret: DraftCaret | null): string {
|
|
82
|
+
return spliceInsert(draft, text, caret).draft
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Locate the composer `<textarea>` in the conversation column: prefer the
|
|
87
|
+
* `data-phase`-tagged textarea (the composer's marker), falling back to any
|
|
88
|
+
* textarea in the column, then to a bare data-phase textarea (older host
|
|
89
|
+
* layouts without the column attribute). Null in jsdom-less hosts.
|
|
90
|
+
*/
|
|
91
|
+
function findComposerTextarea(): HTMLTextAreaElement | null {
|
|
92
|
+
if (typeof document === 'undefined') return null
|
|
93
|
+
const column = document.querySelector('#root [data-slot="conversation"]')
|
|
94
|
+
const find = (scope: ParentNode): HTMLTextAreaElement | null =>
|
|
95
|
+
scope.querySelector('textarea[data-phase]') ?? scope.querySelector('textarea')
|
|
96
|
+
return column !== null
|
|
97
|
+
? find(column)
|
|
98
|
+
: document.querySelector<HTMLTextAreaElement>('textarea[data-phase]')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve the composer's live caret from its DOM `<textarea>`. The draft
|
|
103
|
+
* store has no caret API, so the sidebar reads the composed input's selection
|
|
104
|
+
* directly; the value-sync check (`el.value === draft`) discards stale or
|
|
105
|
+
* wrong-composer reads — a caret must never be applied against a draft it
|
|
106
|
+
* was not measured on.
|
|
107
|
+
*
|
|
108
|
+
* Returns null when the composer is missing, disabled/read-only, out of
|
|
109
|
+
* sync with the store draft, or has no measurable selection (odd hosts
|
|
110
|
+
* report null selectionStart/End).
|
|
111
|
+
*/
|
|
112
|
+
export function probeComposerCaret(draft: string): DraftCaret | null {
|
|
113
|
+
const el = findComposerTextarea()
|
|
114
|
+
if (el === null || el.disabled || el.readOnly) return null
|
|
115
|
+
if (el.value !== draft) return null
|
|
116
|
+
let start = el.selectionStart
|
|
117
|
+
let end = el.selectionEnd
|
|
118
|
+
if (typeof start !== 'number' || typeof end !== 'number') return null
|
|
119
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) return null
|
|
120
|
+
start = Math.max(0, Math.min(start, draft.length))
|
|
121
|
+
end = Math.max(start, Math.min(end, draft.length))
|
|
122
|
+
return { start, end }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Restore the composer caret to `caretIndex` after a programmatic
|
|
127
|
+
* `setDraft` commit. A controlled textarea update resets the caret (React
|
|
128
|
+
* commits the value asynchronously and the browser moves the caret to the
|
|
129
|
+
* start/end), so the placement is scheduled and retried across at most two
|
|
130
|
+
* animation frames (setTimeout fallback), and only applied when the textarea
|
|
131
|
+
* still matches `expectedDraft` — a newer edit or a different composer wins
|
|
132
|
+
* the race untouched. The caret is clamped into the value bounds, mirroring
|
|
133
|
+
* how browsers clamp type-in positions.
|
|
134
|
+
*/
|
|
135
|
+
export function placeComposerCaretAfterInsert(expectedDraft: string, caretIndex: number): void {
|
|
136
|
+
let remaining = 2
|
|
137
|
+
let scheduled = false
|
|
138
|
+
const schedule = (fn: () => void): void => {
|
|
139
|
+
if (scheduled) return
|
|
140
|
+
scheduled = true
|
|
141
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fn)
|
|
142
|
+
else setTimeout(fn, 0)
|
|
143
|
+
}
|
|
144
|
+
const place = (): void => {
|
|
145
|
+
scheduled = false
|
|
146
|
+
if (remaining <= 0) return
|
|
147
|
+
remaining -= 1
|
|
148
|
+
const el = findComposerTextarea()
|
|
149
|
+
if (el === null || el.disabled || el.readOnly) return
|
|
150
|
+
if (el.value !== expectedDraft) {
|
|
151
|
+
// The commit has not landed yet (or a competing edit won) — one more
|
|
152
|
+
// frame before giving up.
|
|
153
|
+
schedule(place)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
const clamped = Math.max(0, Math.min(caretIndex, el.value.length))
|
|
157
|
+
el.setSelectionRange(clamped, clamped)
|
|
158
|
+
}
|
|
159
|
+
schedule(place)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Insert `text` into the session's composer draft at the composer's live
|
|
164
|
+
* caret (see {@link probeComposerCaret}), falling back to appending at the
|
|
165
|
+
* end when the caret cannot be resolved. Returns false — and logs — when the
|
|
166
|
+
* conversation service or the session scope is unavailable.
|
|
14
167
|
*/
|
|
15
168
|
export function appendToDraft(ctx: Context, sessionId: string, text: string): boolean {
|
|
16
169
|
try {
|
|
17
170
|
const actx = ctx.sessions.scope(sessionId)
|
|
18
|
-
if (actx === undefined)
|
|
171
|
+
if (actx === undefined) {
|
|
172
|
+
console.warn('[dsh-coding-sidebar] draft insert skipped: no session scope', sessionId)
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
19
175
|
const conversation = ctx.get('conversation') as SidebarConversation | undefined
|
|
20
|
-
if (conversation === undefined)
|
|
176
|
+
if (conversation === undefined) {
|
|
177
|
+
console.warn('[dsh-coding-sidebar] draft insert skipped: conversation service unavailable')
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
21
180
|
const input = conversation.input.for(actx)
|
|
22
181
|
const draft = input.state.getSnapshot().draft
|
|
23
|
-
|
|
182
|
+
const caret = probeComposerCaret(draft)
|
|
183
|
+
const { draft: next, caretAfter } = spliceInsert(draft, text, caret)
|
|
184
|
+
input.setDraft(next)
|
|
185
|
+
// Put the caret right after the inserted text once the value commit
|
|
186
|
+
// lands — see the module doc for why this keeps stacked inserts at the
|
|
187
|
+
// running position.
|
|
188
|
+
placeComposerCaretAfterInsert(next, caretAfter)
|
|
24
189
|
return true
|
|
25
190
|
} catch (error) {
|
|
26
191
|
console.warn('[dsh-coding-sidebar] draft insert failed:', error)
|
|
27
192
|
return false
|
|
28
193
|
}
|
|
29
194
|
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The DSH `@file` spelling for one relative path, mirroring the host grammar
|
|
198
|
+
* (`formatFileMention` in `@deepseek-ai/dsh-file-reference`): plain when
|
|
199
|
+
* there is no whitespace, quoted when there is, and `undefined` when the
|
|
200
|
+
* path contains a control character or an embedded quote the editor grammar
|
|
201
|
+
* cannot represent.
|
|
202
|
+
*/
|
|
203
|
+
export function fileMention(relativePath: string): { mention: string; label: string } | undefined {
|
|
204
|
+
const path = relativePath.replace(/[\\/]+$/, '')
|
|
205
|
+
// eslint-disable-next-line no-control-regex -- rejecting control characters is the point of this guard
|
|
206
|
+
if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined
|
|
207
|
+
const mention = /\s/u.test(path) ? `@"${path}"` : `@${path}`
|
|
208
|
+
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
|
209
|
+
const label = at === -1 ? path : path.slice(at + 1)
|
|
210
|
+
return { mention, label }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Insert one FILE reference as a structured chip (like DSH's own `@` picker).
|
|
215
|
+
* The chip displays `@<basename>` but serializes to `@<relative path>` on
|
|
216
|
+
* send, so the reference stays a single link from trigger to basename.
|
|
217
|
+
*
|
|
218
|
+
* Directories are NOT handled here: DSH's folder grammar wants the trailing
|
|
219
|
+
* slash as plain text (`@dir/`) so completion can descend, which
|
|
220
|
+
* `appendToDraft` already covers.
|
|
221
|
+
*/
|
|
222
|
+
export function insertFileReference(ctx: Context, sessionId: string, relativePath: string): boolean {
|
|
223
|
+
const reference = fileMention(relativePath)
|
|
224
|
+
if (reference === undefined) return false
|
|
225
|
+
try {
|
|
226
|
+
const actx = ctx.sessions.scope(sessionId)
|
|
227
|
+
if (actx === undefined) return false
|
|
228
|
+
const conversation = ctx.get('conversation') as SidebarConversation | undefined
|
|
229
|
+
if (conversation === undefined) return false
|
|
230
|
+
const input = conversation.input.for(actx)
|
|
231
|
+
const before = input.state.getSnapshot()
|
|
232
|
+
if (before.draftRev === undefined) return false
|
|
233
|
+
// The session-scope Context's typed `emit` is keyed to DSH's closed event
|
|
234
|
+
// map; this internal composer event is deliberately string-loose at runtime.
|
|
235
|
+
;(actx as unknown as { emit(name: string, payload: unknown): void }).emit('slash/input-insert-reference', {
|
|
236
|
+
reference: {
|
|
237
|
+
source: 'reference',
|
|
238
|
+
ref: reference.mention,
|
|
239
|
+
label: reference.label,
|
|
240
|
+
appearance: 'file',
|
|
241
|
+
clipboardText: reference.mention,
|
|
242
|
+
},
|
|
243
|
+
span: {
|
|
244
|
+
draftRev: before.draftRev,
|
|
245
|
+
start: before.draft.length,
|
|
246
|
+
end: before.draft.length,
|
|
247
|
+
},
|
|
248
|
+
})
|
|
249
|
+
const after = input.state.getSnapshot()
|
|
250
|
+
return after.draftRev !== before.draftRev
|
|
251
|
+
} catch (error) {
|
|
252
|
+
console.warn('[dsh-coding-sidebar] file-reference insert failed:', error)
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
}
|
package/src/client/index.tsx
CHANGED
|
@@ -25,10 +25,8 @@ import { registerSettingsNavIcon } from './settings-nav-icon.ts'
|
|
|
25
25
|
import { loadExternalDisable, loadPrefs } from './prefs.ts'
|
|
26
26
|
import { SideCardSection } from './SideCardSection.tsx'
|
|
27
27
|
import { api } from './api.ts'
|
|
28
|
-
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en
|
|
29
|
-
|
|
30
|
-
zhHK, zhTW, zhMO,
|
|
31
|
-
} from './locales.ts'
|
|
28
|
+
import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en } from './locales.ts'
|
|
29
|
+
import { loadChunk } from './chunk-loader.ts'
|
|
32
30
|
import css from './sidebar.module.css'
|
|
33
31
|
import './layout.css'
|
|
34
32
|
|
|
@@ -87,7 +85,12 @@ export function apply(ctx: Context): void {
|
|
|
87
85
|
// the ja dict once the store becomes available.
|
|
88
86
|
ctx.effect(() => {
|
|
89
87
|
let dispose: (() => void) | undefined
|
|
88
|
+
// Guards the async chunk registration below: a sync() re-run (or fiber
|
|
89
|
+
// disposal) that lands while the chunk is still in flight must render
|
|
90
|
+
// that registration moot.
|
|
91
|
+
let generation = 0
|
|
90
92
|
const sync = (): void => {
|
|
93
|
+
generation += 1
|
|
91
94
|
dispose?.()
|
|
92
95
|
dispose = undefined
|
|
93
96
|
const store = ctx.get('betterLocale') as
|
|
@@ -101,10 +104,17 @@ export function apply(ctx: Context): void {
|
|
|
101
104
|
| undefined
|
|
102
105
|
attachBetterLocale(store)
|
|
103
106
|
if (store !== undefined) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
// The 19 override dictionaries ride the lazy `locale` chunk: until
|
|
108
|
+
// it lands, the store has no betterSidebar entries and t() keeps
|
|
109
|
+
// the zh/en chain; the store's own revision bump on register
|
|
110
|
+
// re-renders the chrome once the dicts arrive.
|
|
111
|
+
const myGeneration = generation
|
|
112
|
+
void loadChunk('locale')
|
|
113
|
+
.then(mod => {
|
|
114
|
+
if (myGeneration !== generation) return
|
|
115
|
+
dispose = store.register(LOCALE_NS, mod.localeDicts as Record<string, Record<string, string>>)
|
|
116
|
+
})
|
|
117
|
+
.catch(() => { /* the dicts stay unregistered; the zh/en chain runs */ })
|
|
108
118
|
}
|
|
109
119
|
}
|
|
110
120
|
// Initial check (picks up the store if better-locale activated first).
|
|
@@ -113,6 +123,7 @@ export function apply(ctx: Context): void {
|
|
|
113
123
|
// activates with a persisted override, and when the user switches).
|
|
114
124
|
const unsubscribe = ctx.locale.subscribe(sync)
|
|
115
125
|
return () => {
|
|
126
|
+
generation += 1
|
|
116
127
|
unsubscribe()
|
|
117
128
|
dispose?.()
|
|
118
129
|
attachBetterLocale(undefined)
|
package/src/client/locales-ar.ts
CHANGED
|
@@ -89,6 +89,7 @@ export const ar: Record<string, string> = {
|
|
|
89
89
|
terminalDepsFailed: 'فشل تحميل تبعية الطرفية node-pty',
|
|
90
90
|
terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):',
|
|
91
91
|
terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})',
|
|
92
|
+
terminalShellNotFound: 'لم يتم العثور على الصدفة المُعدَّة: {name} — تحقق من مسار الصدفة في الإعدادات → Side card → Terminal',
|
|
92
93
|
preview: 'معاينة',
|
|
93
94
|
edit: 'تحرير',
|
|
94
95
|
refresh: 'تحديث',
|
|
@@ -214,10 +215,10 @@ export const ar: Record<string, string> = {
|
|
|
214
215
|
settingsConflict: 'تغيّر الإعداد في نافذة أخرى — يُرجى إعادة المحاولة',
|
|
215
216
|
binaryNoPreview: 'لا يمكن معاينة هذا النوع من الملفات',
|
|
216
217
|
downloadToView: 'تنزيل للعرض',
|
|
217
|
-
settingsSubagentTitle: '
|
|
218
|
-
settingsSubagentDesc: '
|
|
219
|
-
settingsJobsTitle: '
|
|
220
|
-
settingsJobsDesc: '
|
|
218
|
+
settingsSubagentTitle: 'تفعيل صفحة المهام تلقائيًا عند ظهور وكيل فرعي',
|
|
219
|
+
settingsSubagentDesc: 'ينشّط صفحة المهام عندما تنشئ المحادثة الحالية وكيلًا فرعيًا جديدًا؛ على الشاشات الواسعة تتوسع البطاقة الجانبية أيضًا، ولا تفرض الشاشات الضيقة الدرج بملء الشاشة؛ أوقفه للفتح يدويًا',
|
|
220
|
+
settingsJobsTitle: 'تفعيل صفحة المهام الخلفية تلقائيًا عند مهمة خلفية جديدة',
|
|
221
|
+
settingsJobsDesc: 'ينشّط صفحة المهام الخلفية كلما ظهرت مهمة خلفية جديدة للمحادثة الحالية (كل مهمة جديدة تُفعّله)؛ على الشاشات الواسعة تتوسع البطاقة الجانبية أيضًا، ولا تفرض الشاشات الضيقة الدرج بملء الشاشة؛ أوقفه للفتح يدويًا',
|
|
221
222
|
settingsToolsTitle: 'حقن أدوات الطرفية للنموذج',
|
|
222
223
|
settingsToolsDesc: 'عند التفعيل، يمكن للنموذج إنشاء وتشغيل طرفيات الشريط الجانبي عبر أدوات terminal_* الثمانية (معطّل افتراضياً)',
|
|
223
224
|
settingsFontFamilyTitle: 'عائلة خط الطرفية',
|
package/src/client/locales-de.ts
CHANGED
|
@@ -74,6 +74,7 @@ export const de: Record<string, string> = {
|
|
|
74
74
|
terminalDepsFailed: 'Die Terminal-Abhängigkeit node-pty konnte nicht geladen werden',
|
|
75
75
|
terminalDepsHint: 'Führen Sie den folgenden Befehl in einem Terminal oder in cmd auf dem DSH-System aus, um dies zu beheben, und klicken Sie dann auf „Erneut versuchen“ (node-pty bleibt mit der DSH-Core-Version synchron):',
|
|
76
76
|
terminalDepsProfile: ' (erkanntes Profil: {profile})',
|
|
77
|
+
terminalShellNotFound: 'Konfigurierte Shell nicht gefunden: {name} — Shell-Pfad unter Einstellungen → Side card → Terminal prüfen',
|
|
77
78
|
preview: 'Vorschau',
|
|
78
79
|
edit: 'Bearbeiten',
|
|
79
80
|
refresh: 'Aktualisieren',
|
|
@@ -199,10 +200,10 @@ export const de: Record<string, string> = {
|
|
|
199
200
|
settingsConflict: 'Die Einstellung wurde in einem anderen Fenster geändert – bitte erneut versuchen',
|
|
200
201
|
binaryNoPreview: 'Dieser Dateityp kann nicht in der Vorschau angezeigt werden',
|
|
201
202
|
downloadToView: 'Zum Ansehen herunterladen',
|
|
202
|
-
settingsSubagentTitle: '
|
|
203
|
-
settingsSubagentDesc: '
|
|
204
|
-
settingsJobsTitle: '
|
|
205
|
-
settingsJobsDesc: '
|
|
203
|
+
settingsSubagentTitle: 'Aufgabenseite bei neuen Subagenten automatisch aktivieren',
|
|
204
|
+
settingsSubagentDesc: 'Aktiviert die Aufgabenseite, wenn die aktuelle Konversation einen neuen Subagenten erzeugt; auf breiten Bildschirmen wird die Seitenkarte mit ausgeklappt, schmale Bildschirme erzwingen keine Vollbild-Schublade; zum manuellen Öffnen deaktivieren',
|
|
205
|
+
settingsJobsTitle: 'Aufgabenseite bei neuem Hintergrundjob automatisch aktivieren',
|
|
206
|
+
settingsJobsDesc: 'Aktiviert die Aufgabenseite, wenn ein neuer Hintergrundjob für die aktuelle Konversation erscheint (jeder neue Job löst aus); auf breiten Bildschirmen wird die Seitenkarte mit ausgeklappt, schmale Bildschirme erzwingen keine Vollbild-Schublade; zum manuellen Öffnen deaktivieren',
|
|
206
207
|
settingsToolsTitle: 'Terminal-Werkzeuge für das Modell bereitstellen',
|
|
207
208
|
settingsToolsDesc: 'Wenn aktiviert, kann das Modell über die 8 terminal_*-Werkzeuge Terminale in der Seitenleiste erstellen und steuern (standardmäßig deaktiviert)',
|
|
208
209
|
settingsFontFamilyTitle: 'Terminal-Schriftart',
|
package/src/client/locales-fr.ts
CHANGED
|
@@ -81,6 +81,7 @@ export const fr: Record<string, string> = {
|
|
|
81
81
|
terminalDepsFailed: 'Échec du chargement de la dépendance terminal node-pty',
|
|
82
82
|
terminalDepsHint: 'Exécutez la commande suivante dans un terminal ou cmd de l’environnement DSH pour réparer, puis cliquez sur Réessayer (node-pty reste à la même version que le cœur DSH) :',
|
|
83
83
|
terminalDepsProfile: ' (profil détecté : {profile})',
|
|
84
|
+
terminalShellNotFound: 'Shell configuré introuvable : {name} — vérifiez le chemin du shell dans Réglages → Side card → Terminal',
|
|
84
85
|
preview: 'Aperçu',
|
|
85
86
|
edit: 'Modifier',
|
|
86
87
|
refresh: 'Actualiser',
|
|
@@ -206,10 +207,10 @@ export const fr: Record<string, string> = {
|
|
|
206
207
|
settingsConflict: 'Les réglages ont été modifiés par une autre fenêtre, veuillez réessayer',
|
|
207
208
|
binaryNoPreview: 'Ce type de fichier ne prend pas en charge l’aperçu',
|
|
208
209
|
downloadToView: 'Télécharger pour consulter',
|
|
209
|
-
settingsSubagentTitle: '
|
|
210
|
-
settingsSubagentDesc: '
|
|
211
|
-
settingsJobsTitle: '
|
|
212
|
-
settingsJobsDesc: '
|
|
210
|
+
settingsSubagentTitle: 'Activer automatiquement la page des tâches quand un sous-agent apparaît',
|
|
211
|
+
settingsSubagentDesc: 'Active la page des tâches quand la conversation actuelle crée un nouveau sous-agent ; sur les grands écrans la carte latérale se déploie aussi, les petits écrans ne forcent jamais le tiroir plein écran ; désactivez pour ouvrir manuellement',
|
|
212
|
+
settingsJobsTitle: 'Activer automatiquement la page des tâches d’arrière-plan pour une nouvelle tâche',
|
|
213
|
+
settingsJobsDesc: 'Active la page des tâches d’arrière-plan dès qu’une nouvelle tâche apparaît pour la conversation actuelle (chaque nouvelle tâche déclenche) ; sur les grands écrans la carte latérale se déploie aussi, les petits écrans ne forcent jamais le tiroir plein écran ; désactivez pour ouvrir manuellement',
|
|
213
214
|
settingsToolsTitle: 'Injecter des outils de terminal au modèle',
|
|
214
215
|
settingsToolsDesc: 'Une fois activé, le modèle peut créer et piloter des terminaux de la barre latérale via les 8 outils terminal_* (désactivé par défaut)',
|
|
215
216
|
settingsFontFamilyTitle: 'Police du terminal',
|