dsh-code-ui 1.2.0 → 1.3.0

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 CHANGED
@@ -99,6 +99,7 @@ npm publish
99
99
 
100
100
  | 时间 | 版本 | 修复 / 新增 |
101
101
  | --- | --- | --- |
102
+ | 2026-09-02 | v1.3.0 | 里程碑:文件树右键操作(真实磁盘)。右键文件:打开 / 引用给 AI(整个文件)/ 复制路径 / 在资源管理器中显示 / 重命名 / 删除;右键文件夹:新建文件 / 新建文件夹 / 以此文件夹为根 / 刷新 / 复制路径 / 定位 / 重命名 / 删除(递归强确认);空白区右键:根目录新建 / 刷新 / 更换根;新建重命名输入弹窗(Enter 确认)、危险操作确认弹窗、工作区根禁删;重命名自动同步标签页/缓冲/watcher,删除自动清理该路径下全部标签。引用体系升级:整文件引用只传绝对路径由 AI 自行读取;停靠布局下 DSH 原生输入条发送也自动附加引用指针(挂钩 submit,两布局统一)。打开文件弹窗:输入路径回车直接认定(文件夹=设为根 / 文件=打开)。右键菜单视口边界自适应、点击菜单项后自动关闭;⟳ 刷新不再折叠整棵树 |
102
103
  | 2026-09-02 | v1.2.0 | 里程碑:双布局与全面可配置。新增右侧停靠布局(code UI 固定占右侧、DSH 界面推到左侧并排,Cursor 式;左缘拖拽实时调宽,壳层帧用 [data-slot=root] 语义属性定位 + 内联 margin-right 推挤);插件卡片新增「布局模式」(右侧停靠=默认 / 悬浮窗口)、「界面模式」(code UI / dsh UI,状态持久化、所有入口同步记忆)、「切换按钮」(会话头 ⌨ 按钮=默认 / 悬浮模式条,与布局任意组合),全部即时生效 + 持久化;文件树侧栏可收起(« / 📁 三态 / 拖窄收起)+ » 展开把手 + 右缘拖宽;侧栏标题不折行;停靠面板去投影;界面文案更名(Code UI→code UI、原始风格-ui→dsh UI);悬浮布局也获得侧栏收起/调宽能力 |
103
104
  | 2026-09-02 | v1.2.0-beta.1 | 里程碑:右侧停靠模式(feature/native-layout 分支)--会话头「⌨ Code UI」开关按钮,Code UI 固定占右侧、DSH 界面推到左侧并排(Cursor 式布局);左缘拖拽实时调宽;文件树侧栏可收起(« 按钮 / 📁 三态 / 拖到最窄松手收起)+ » 展开把手 + 右缘拖拽调宽;侧栏标题不折行;移除停靠面板投影。推挤采用 [data-slot=root] 语义属性 + 内联 margin-right,不依赖哈希类名 |
104
105
  | 2026-09-01 | v1.1.10 | 调整:底部状态栏不再用反色按钮样式,改用输入框同款背景(bg-overlay)+ 次要文字色 + 顶部分隔线,深浅色模式下均与编辑器融为一体 |
package/lib/client.js CHANGED
@@ -123,13 +123,18 @@ window.__ModuleLoader__.load({
123
123
  .cdex-btn-ghost{border:1px solid var(--dsw-alias-border-l1,#3e3e42);background:var(--dsw-alias-bg-layer-2,#3c3c3c);color:var(--dsw-alias-label-primary,#cccccc);border-radius:4px;padding:3px 8px;cursor:pointer;font-size:12px;}
124
124
  .cdex-btn-ghost:hover{background:var(--dsw-alias-bg-layer-1,#4a4a4a);}
125
125
  .cdex-menu-backdrop{position:fixed;inset:0;z-index:10001;pointer-events:auto;}
126
- .cdex-menu{position:fixed;z-index:10002;background:var(--dsw-alias-bg-overlay,#252526);border:1px solid var(--dsw-alias-border-l2,#454545);border-radius:6px;padding:4px;min-width:180px;box-shadow:0 8px 24px rgba(0,0,0,.5);}
126
+ .cdex-menu{position:fixed;z-index:10002;background:var(--dsw-alias-bg-overlay,#252526);border:1px solid var(--dsw-alias-border-l2,#454545);border-radius:6px;padding:4px;min-width:180px;box-shadow:0 8px 24px rgba(0,0,0,.5);max-height:calc(100vh - 24px);overflow-y:auto;}
127
127
  .cdex-menu-item{display:flex;gap:8px;align-items:center;padding:7px 10px;border-radius:4px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary,#d4d4d4);}
128
128
  .cdex-menu-item:hover{background:var(--cdex-accent-bg,#0f1115);color:var(--cdex-accent-fg,#ffffff);}
129
129
  .cdex-modal{position:fixed;inset:0;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;z-index:10002;pointer-events:auto;}
130
130
  .cdex-modal-box{background:var(--dsw-alias-bg-overlay,#252526);border:1px solid var(--dsw-alias-border-l2,#454545);border-radius:8px;padding:16px;width:560px;max-width:92vw;}
131
131
  .cdex-modal-title{font-weight:600;margin-bottom:10px;color:var(--dsw-alias-label-primary,#e8e8e8);word-break:break-all;}
132
132
  .cdex-textarea2{width:100%;min-height:150px;background:var(--dsw-alias-bg-base,#1e1e1e);color:var(--dsw-alias-label-primary,#d4d4d4);border:1px solid var(--dsw-alias-border-l1,#3e3e42);border-radius:4px;padding:8px;font-family:inherit;font-size:13px;resize:vertical;box-sizing:border-box;}
133
+ .cdex-input{width:100%;background:var(--dsw-alias-bg-base,#1e1e1e);color:var(--dsw-alias-label-primary,#d4d4d4);border:1px solid var(--dsw-alias-border-l1,#3e3e42);border-radius:4px;padding:8px 10px;font-family:inherit;font-size:13px;box-sizing:border-box;outline:none;}
134
+ .cdex-input:focus{border-color:var(--dsw-alias-label-dimmed,#6a6a70);}
135
+ .cdex-confirm-msg{color:var(--dsw-alias-label-primary,#d4d4d4);font-size:13px;line-height:1.6;white-space:pre-wrap;word-break:break-word;}
136
+ .cdex-btn-danger{background:var(--dsw-alias-state-error-primary,#f14c4c);color:#ffffff;border-color:transparent;}
137
+ .cdex-btn-danger:hover{opacity:.9;}
133
138
  .cdex-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:12px;}
134
139
  .cdex-ref{background:var(--dsw-alias-bg-layer-1,#2d2d2d);border:1px solid var(--dsw-alias-border-l1,#3e3e42);border-radius:6px;padding:8px;margin-bottom:8px;}
135
140
  .cdex-ref-head{display:flex;align-items:center;gap:6px;margin-bottom:6px;font-size:12px;}
@@ -252,6 +257,8 @@ window.__ModuleLoader__.load({
252
257
  browsePath: null,
253
258
  browseEntries: [],
254
259
  ctxMenu: null,
260
+ nameDialog: null,
261
+ confirmBox: null,
255
262
  noteEditor: null,
256
263
  toast: null,
257
264
  error: null,
@@ -337,6 +344,7 @@ window.__ModuleLoader__.load({
337
344
  if (r.lineStart && r.lineEnd) {
338
345
  return name + '(' + r.lineStart + '-' + r.lineEnd + ')'
339
346
  }
347
+ if (!r.lineStart && r.kind === 'cite') return name + '(整文件)'
340
348
  return name
341
349
  }
342
350
  function parentPath(p) {
@@ -599,9 +607,8 @@ window.__ModuleLoader__.load({
599
607
  emit()
600
608
  await toggleExpand(store.rootPath)
601
609
  } else {
602
- const node = store.nodes[store.rootPath]
603
- if (node) { node.loaded = false; node.children = []; node.expanded = true }
604
- await toggleExpand(store.rootPath)
610
+ // 修复:刷新不再折叠整棵树,只重载根目录(子目录展开状态保留在 store.nodes)
611
+ await reloadDir(store.rootPath)
605
612
  }
606
613
  }
607
614
  async function toggleExpand(path) {
@@ -719,7 +726,14 @@ window.__ModuleLoader__.load({
719
726
  const text = full.slice(start, end)
720
727
  const lineStart = full.slice(0, start).split('\n').length
721
728
  const lineEnd = full.slice(0, end).split('\n').length
722
- store.ctxMenu = { x: e.clientX, y: e.clientY, sel: { text, lineStart, lineEnd, path: store.activePath, language: langOf(store.activePath) } }
729
+ const sel = { text, lineStart, lineEnd, path: store.activePath, language: langOf(store.activePath) }
730
+ store.ctxMenu = { x: e.clientX, y: e.clientY, items: [
731
+ { icon: '📋', label: '复制', act: () => { copyText(sel.text); showToast('已复制'); closeMenu() } },
732
+ { icon: '📥', label: '粘贴到备注', act: () => { pasteToNote(sel); closeMenu() } },
733
+ { icon: '📝', label: '添加备注', act: () => { openNoteEditor(sel); closeMenu() } },
734
+ { icon: '🌐', label: '翻译', act: () => { doTranslate(sel); closeMenu() } },
735
+ { icon: '🔗', label: '引用(给 AI)', act: () => { doCite(sel); closeMenu() } },
736
+ ] }
723
737
  emit()
724
738
  }
725
739
  function closeMenu() { store.ctxMenu = null; emit() }
@@ -737,6 +751,113 @@ window.__ModuleLoader__.load({
737
751
  store.noteEditor = { path: sel.path, lineStart: sel.lineStart, lineEnd: sel.lineEnd, note: '' }
738
752
  emit()
739
753
  }
754
+ // —— 文件树右键:磁盘操作(真实文件系统,经 host RPC) ——
755
+ function joinPath(dir, name) {
756
+ const sep = String(dir).includes('\\') ? '\\' : '/'
757
+ return String(dir).replace(/[\\/]+$/, '') + sep + name
758
+ }
759
+ // 重载单个目录节点(保持展开状态,不折叠整个树)
760
+ async function reloadDir(path) {
761
+ const n = store.nodes[path]
762
+ if (!n || !n.isDir) return
763
+ n.loaded = false
764
+ n.children = []
765
+ n.expanded = true
766
+ n.loading = true; emit()
767
+ const res = await callHost('list-dir', { path })
768
+ n.loading = false; n.loaded = true
769
+ if (res && res.error) { store.error = res.error; emit(); return }
770
+ const entries = (res && res.entries) || []
771
+ n.children = entries.map((e) => { store.nodes[e.path] = e; return e.path })
772
+ emit()
773
+ }
774
+ // 整文件引用:只给 AI 绝对路径,AI 用自己的读取工具打开
775
+ async function citeWholeFile(path) {
776
+ const ref = await callHost('add-reference', { path: path, language: langOf(path), lineStart: null, lineEnd: null, text: '', kind: 'cite' })
777
+ if (ref && ref.reference) { store.references.push(ref.reference); emit() }
778
+ showToast('已引用整个文件:' + baseName(path) + '(AI 将自行读取)')
779
+ }
780
+ // 重命名后同步标签页/缓冲/激活路径/watcher
781
+ function renameTabSync(fromPath, toPath) {
782
+ const affected = store.tabs.filter((t) => t.path === fromPath)
783
+ affected.forEach((t) => { t.path = toPath; t.name = baseName(toPath) })
784
+ if (toPath in store.buffers === false && fromPath in store.buffers) {
785
+ store.buffers[toPath] = store.buffers[fromPath]
786
+ store.savedContent[toPath] = store.savedContent[fromPath]
787
+ store.dirty[toPath] = store.dirty[fromPath]
788
+ store.externalChanged[toPath] = false
789
+ }
790
+ delete store.buffers[fromPath]; delete store.savedContent[fromPath]; delete store.dirty[fromPath]; delete store.externalChanged[fromPath]
791
+ if (store.activePath === fromPath) store.activePath = toPath
792
+ callHost('unwatch-file', { path: fromPath }).then(() => {})
793
+ if (affected.length > 0) callHost('watch-file', { path: toPath }).then(() => {})
794
+ }
795
+ // 删除后清理:关掉该路径(及其目录下)所有打开的标签
796
+ function closeTabsUnder(path) {
797
+ const sep = String(path).includes('\\') ? '\\' : '/'
798
+ const prefix = String(path).replace(/[\\/]+$/, '') + sep
799
+ store.tabs.filter((t) => t.path === path || t.path.indexOf(prefix) === 0)
800
+ .map((t) => t.path).forEach((p) => closeTab(p))
801
+ }
802
+ async function doRename(path, isDir) {
803
+ const parent = parentPath(path)
804
+ store.nameDialog = { mode: 'rename', path: path, dir: parent, isDir: !!isDir, curName: baseName(path) }
805
+ emit()
806
+ }
807
+ async function doDelete(path, isDir) {
808
+ store.confirmBox = {
809
+ msg: isDir
810
+ ? '确定删除文件夹「' + baseName(path) + '」吗?\n其中所有内容(含子目录)都会被永久删除。'
811
+ : '确定删除文件「' + baseName(path) + '」吗?',
812
+ danger: true,
813
+ onOk: async () => {
814
+ const res = await callHost('fs-delete', { path: path, recursive: !!isDir })
815
+ if (res && res.error) { showToast('删除失败:' + res.error); return }
816
+ closeTabsUnder(path)
817
+ const parent = parentPath(path)
818
+ if (parent && store.nodes[parent]) await reloadDir(parent)
819
+ else await refresh()
820
+ showToast('已删除:' + baseName(path))
821
+ },
822
+ }
823
+ emit()
824
+ }
825
+ // 文件树节点右键菜单
826
+ function openNodeMenu(e, node) {
827
+ e.preventDefault(); e.stopPropagation()
828
+ const items = []
829
+ if (node.isDir) {
830
+ items.push({ icon: '📄', label: '新建文件', act: () => { store.nameDialog = { mode: 'newfile', dir: node.path }; emit() } })
831
+ items.push({ icon: '🗂', label: '新建文件夹', act: () => { store.nameDialog = { mode: 'newdir', dir: node.path }; emit() } })
832
+ items.push({ icon: '🏠', label: '以此文件夹为根', act: () => setWorkspaceRoot(node.path) })
833
+ items.push({ icon: '⟳', label: '刷新', act: () => reloadDir(node.path) })
834
+ } else {
835
+ items.push({ icon: '📄', label: '打开', act: () => openFile(node.path) })
836
+ items.push({ icon: '🔗', label: '引用给 AI(整个文件)', act: () => citeWholeFile(node.path) })
837
+ }
838
+ items.push({ icon: '📋', label: '复制路径', act: () => { copyText(node.path); showToast('已复制路径:' + node.path) } })
839
+ items.push({ icon: '📂', label: '在资源管理器中显示', act: () => callHost('fs-reveal', { path: node.path }).then((r) => { if (r && r.error) showToast('失败:' + r.error) }) })
840
+ items.push({ icon: '✏️', label: '重命名', act: () => doRename(node.path, node.isDir) })
841
+ const isRoot = node.path === store.rootPath
842
+ items.push({ icon: '🗑', label: isRoot ? '删除(工作区根)' : (node.isDir ? '删除文件夹…' : '删除'), act: () => {
843
+ if (isRoot) { showToast('工作区根目录不能在树里删除;请先更换根目录'); return }
844
+ doDelete(node.path, node.isDir)
845
+ } })
846
+ store.ctxMenu = { x: e.clientX, y: e.clientY, items: items }
847
+ emit()
848
+ }
849
+ // 文件树空白区右键
850
+ function openTreeBlankMenu(e) {
851
+ e.preventDefault()
852
+ if (!store.rootPath) return
853
+ store.ctxMenu = { x: e.clientX, y: e.clientY, items: [
854
+ { icon: '📄', label: '新建文件(根目录)', act: () => { store.nameDialog = { mode: 'newfile', dir: store.rootPath }; emit() } },
855
+ { icon: '🗂', label: '新建文件夹(根目录)', act: () => { store.nameDialog = { mode: 'newdir', dir: store.rootPath }; emit() } },
856
+ { icon: '⟳', label: '刷新', act: () => refresh() },
857
+ { icon: '🗃', label: '更换工作区根', act: () => openFilePicker() },
858
+ ] }
859
+ emit()
860
+ }
740
861
  async function pasteToNote(sel) {
741
862
  let clip = ''
742
863
  try {
@@ -762,16 +883,28 @@ window.__ModuleLoader__.load({
762
883
  function buildSendPayload(draft) {
763
884
  // 引用文本不再粘贴进消息(明面上不展示):引用统一存 host 端,AI 通过 get_referenced_snippets
764
885
  // 工具读取;消息里只附一行「标签级」指针,让 AI 知道有引用可读。
886
+ // 整文件引用(无行号):直接给出绝对路径,AI 用自己的读取工具打开。
765
887
  const text = draft || ''
888
+ if (text.indexOf('[code UI] 已标记') >= 0) return text // 幂等:已带指针不重复附加
766
889
  const citeRefs = store.references.filter((r) => r.kind === 'cite')
767
890
  const trRefs = store.references.filter((r) => r.kind === 'translate')
891
+ const codeRefs = citeRefs.filter((r) => r.lineStart)
892
+ const fileRefs = citeRefs.filter((r) => !r.lineStart)
768
893
  const parts = []
769
- if (citeRefs.length > 0) parts.push(citeRefs.length + ' 段代码引用')
894
+ if (codeRefs.length > 0) parts.push(codeRefs.length + ' 段代码引用')
895
+ if (fileRefs.length > 0) parts.push(fileRefs.length + ' 个文件引用')
770
896
  if (trRefs.length > 0) parts.push(trRefs.length + ' 条翻译请求')
771
897
  if (parts.length === 0) return text
898
+ let ptr = '[code UI] 已标记 ' + parts.join('、') + '。'
899
+ if (fileRefs.length > 0) {
900
+ ptr += '\n文件引用(整个文件,请用你的文件读取工具打开以下绝对路径):\n'
901
+ + fileRefs.map((r) => '- ' + r.path).join('\n')
902
+ }
903
+ if (codeRefs.length > 0 || trRefs.length > 0) {
904
+ ptr += '\n(代码/翻译引用内容未粘贴在消息里,请调用 get_referenced_snippets 工具读取)。'
905
+ }
772
906
  const sep = text.trim() ? '\n\n' : ''
773
- return text + sep + '[code UI] 已标记 ' + parts.join('、')
774
- + '(引用内容未粘贴在消息里,请调用 get_referenced_snippets 工具读取)。'
907
+ return text + sep + ptr
775
908
  }
776
909
 
777
910
  /* ============ §6 Pointer 处理层(窗口拖拽 / 缩放 / 模式条移动) ============ */
@@ -842,6 +975,7 @@ window.__ModuleLoader__.load({
842
975
  }
843
976
 
844
977
  /* ============ §7 组件层(V.* 命名空间) ============ */
978
+ let selfSending = false // AiInput 副本自己发送时置位,让挂钩的 submit 不重复附加指针
845
979
  function InputBridge(props) {
846
980
  const useInput = props.useInput
847
981
  const inputState = typeof useInput === 'function' ? useInput((s) => s) : undefined
@@ -854,6 +988,27 @@ window.__ModuleLoader__.load({
854
988
  }
855
989
  emit()
856
990
  })
991
+ // 挂钩 DSH 原生输入条的发送:存在引用时把指针附进草稿再发(整文件=绝对路径)。
992
+ // actions 对象「每会话一个稳定身份」,补丁安全;卸载/换会话时还原原方法。
993
+ React.useEffect(() => {
994
+ if (!inputActions || typeof inputActions.submit !== 'function') return
995
+ if (inputActions.__cdexPatched) return
996
+ const orig = inputActions.submit
997
+ inputActions.__cdexPatched = true
998
+ inputActions.submit = function () {
999
+ try {
1000
+ const hasRefs = store.references.length > 0
1001
+ const draft = store.inputDraft || ''
1002
+ if (hasRefs && !selfSending && draft.indexOf('[code UI]') < 0) {
1003
+ inputActions.setDraft(buildSendPayload(draft))
1004
+ }
1005
+ } catch (e) {}
1006
+ return orig.apply(inputActions, arguments)
1007
+ }
1008
+ return () => {
1009
+ try { inputActions.submit = orig; delete inputActions.__cdexPatched } catch (e) {}
1010
+ }
1011
+ }, [inputActions])
857
1012
  return null
858
1013
  }
859
1014
  function AiInput() {
@@ -873,8 +1028,11 @@ window.__ModuleLoader__.load({
873
1028
  const text = taEl.value
874
1029
  if (!text.trim() && refs.length === 0) return
875
1030
  const payload = buildSendPayload(text)
876
- st.inputActions.setDraft(payload)
877
- st.inputActions.submit()
1031
+ selfSending = true
1032
+ try {
1033
+ st.inputActions.setDraft(payload)
1034
+ st.inputActions.submit()
1035
+ } finally { selfSending = false }
878
1036
  taEl.value = ''
879
1037
  }
880
1038
  return h('div', { className: 'cdex-aiinput-wrap' },
@@ -933,7 +1091,8 @@ window.__ModuleLoader__.load({
933
1091
  if (!p) return
934
1092
  callHost('stat-path', { path: p }).then((res) => {
935
1093
  if (!res || res.error) { showToast('无法访问该路径:' + ((res && res.error) || '未知错误')); return }
936
- if (res.isDir) { browseDir(res.path || p) }
1094
+ // 手动输入 + 回车 = 直接认定:目录设为工作区根,文件直接打开
1095
+ if (res.isDir) { setWorkspaceRoot(res.path || p) }
937
1096
  else if (res.isFile) { pickFile(res.path || p) }
938
1097
  // 兼容回退:宿主 isDir 字段缺失但路径确实存在时,按目录尝试浏览(list-dir 对非目录会给出真实错误)
939
1098
  else if (res.exists) { browseDir(p) }
@@ -955,7 +1114,7 @@ window.__ModuleLoader__.load({
955
1114
  ),
956
1115
  h('div', { className: 'cdex-fp-bar' },
957
1116
  h('button', { className: 'cdex-btn-ghost', onClick: goUp, title: '上级目录' }, '↑'),
958
- h('input', { className: 'cdex-fp-input', defaultValue: curDir || '', placeholder: '输入绝对路径后回车跳转', onKeyDown: goTo }),
1117
+ h('input', { className: 'cdex-fp-input', defaultValue: curDir || '', placeholder: '输入路径后回车:文件夹=设为根,文件=打开', onKeyDown: goTo }),
959
1118
  ),
960
1119
  h('div', { className: 'cdex-fp-list' },
961
1120
  dirs.map((d) => h('div', { className: 'cdex-fp-item', key: d.path, onClick: () => browseDir(d.path) },
@@ -967,7 +1126,7 @@ window.__ModuleLoader__.load({
967
1126
  dirs.length === 0 && files.length === 0 ? h('div', { className: 'cdex-empty' }, st.error ? String(st.error) : '空目录') : null,
968
1127
  ),
969
1128
  h('div', { className: 'cdex-fp-actions' },
970
- h('span', { className: 'cdex-fp-hint' }, '浏览到目标文件夹后,点「以此文件夹为根」'),
1129
+ h('span', { className: 'cdex-fp-hint' }, '输入路径回车即认定:文件夹设为根、文件直接打开;点条目仅浏览,此时用右下按钮确认'),
971
1130
  h('button', { className: 'cdex-btn-ghost', onClick: close }, '取消'),
972
1131
  h('button', { className: 'cdex-btn', onClick: confirm, disabled: !curDir }, '以此文件夹为根'),
973
1132
  ),
@@ -1001,7 +1160,7 @@ window.__ModuleLoader__.load({
1001
1160
  const cls = 'cdex-node' + (st.activePath === node.path ? ' active' : '')
1002
1161
  const children = node.isDir && node.expanded ? (node.children || []).map((c) => h(TreeNode, { key: c, path: c, depth: props.depth + 1 })) : null
1003
1162
  return h('div', null,
1004
- h('div', { className: cls, style: style, onClick: () => { node.isDir ? toggleExpand(node.path) : openFile(node.path) } },
1163
+ h('div', { className: cls, style: style, onClick: () => { node.isDir ? toggleExpand(node.path) : openFile(node.path) }, onContextMenu: (e) => openNodeMenu(e, node) },
1005
1164
  node.isDir ? h('span', { className: 'cdex-arrow' }, node.expanded ? '▾' : '▸') : h('span', { className: 'cdex-arrow' }, ''),
1006
1165
  h('span', { className: node.isDir ? 'cdex-folder' : 'cdex-file' }, node.isDir ? '📁 ' : '📄 ', node.name),
1007
1166
  ),
@@ -1019,7 +1178,7 @@ window.__ModuleLoader__.load({
1019
1178
  h('button', { className: 'cdex-btn-ghost', onClick: () => { store.sidebarCollapsed = true; emit() }, title: '收起侧栏' }, '«'),
1020
1179
  ),
1021
1180
  ),
1022
- h('div', { className: 'cdex-tree' },
1181
+ h('div', { className: 'cdex-tree', onContextMenu: (e) => { if (!e.target.closest || !e.target.closest('.cdex-node')) openTreeBlankMenu(e) } },
1023
1182
  st.error ? h('div', { className: 'cdex-error' }, String(st.error)) : null,
1024
1183
  st.rootPath ? h(TreeNode, { path: st.rootPath, depth: 0 }) : h('div', { className: 'cdex-empty' }, '正在加载目录…'),
1025
1184
  ),
@@ -1028,10 +1187,10 @@ window.__ModuleLoader__.load({
1028
1187
  function RefItem(r) {
1029
1188
  return h('div', { className: 'cdex-ref', key: r.id },
1030
1189
  h('div', { className: 'cdex-ref-head' },
1031
- h('span', { className: 'cdex-ref-path' }, r.path, r.lineStart ? (' :' + r.lineStart + '-' + r.lineEnd) : ''),
1190
+ h('span', { className: 'cdex-ref-path' }, r.path, r.lineStart ? (' :' + r.lineStart + '-' + r.lineEnd) : (r.kind === 'cite' ? ' (整个文件)' : '')),
1032
1191
  h('button', { className: 'cdex-btn-ghost', onClick: () => delReference(r.id) }, '✕'),
1033
1192
  ),
1034
- h('div', { className: 'cdex-ref-code' }, r.text.slice(0, 500)),
1193
+ h('div', { className: 'cdex-ref-code' }, r.text ? r.text.slice(0, 500) : '(AI 将按绝对路径自行读取整个文件)'),
1035
1194
  )
1036
1195
  }
1037
1196
  function ReferencesView() {
@@ -1158,22 +1317,90 @@ window.__ModuleLoader__.load({
1158
1317
  const st = useStore()
1159
1318
  const m = st.ctxMenu
1160
1319
  if (!m) return null
1161
- const sel = m.sel
1162
- const items = [
1163
- { icon: '📋', label: '复制', act: () => { copyText(sel.text); showToast('已复制'); closeMenu() } },
1164
- { icon: '📥', label: '粘贴到备注', act: () => { pasteToNote(sel); closeMenu() } },
1165
- { icon: '📝', label: '添加备注', act: () => { openNoteEditor(sel); closeMenu() } },
1166
- { icon: '🌐', label: '翻译', act: () => { doTranslate(sel); closeMenu() } },
1167
- { icon: '🔗', label: '引用(给 AI)', act: () => { doCite(sel); closeMenu() } },
1168
- ]
1320
+ const items = m.items || []
1321
+ // 视口边界自适应:菜单在点击点放不下时收进视口(底部/右侧溢出钳位),
1322
+ // 避免在最后一行右键时菜单被浏览器底边截断
1323
+ const ITEM_H = 33
1324
+ const estH = items.length * ITEM_H + 10
1325
+ const estW = 230
1326
+ let mx = m.x, my = m.y
1327
+ try {
1328
+ const vw = (typeof window !== 'undefined' && window.innerWidth) || 1200
1329
+ const vh = (typeof window !== 'undefined' && window.innerHeight) || 800
1330
+ if (my + estH > vh - 8) my = Math.max(8, vh - estH - 8)
1331
+ if (mx + estW > vw - 8) mx = Math.max(8, vw - estW - 8)
1332
+ } catch (e) {}
1169
1333
  return h('div', { className: 'cdex-menu-backdrop', onClick: closeMenu },
1170
- h('div', { className: 'cdex-menu', style: { left: m.x, top: m.y }, onClick: (e) => e.stopPropagation() },
1171
- items.map((it, i) => h('div', { key: i, className: 'cdex-menu-item', onClick: it.act },
1334
+ h('div', { className: 'cdex-menu', style: { left: mx, top: my }, onClick: (e) => e.stopPropagation() },
1335
+ items.map((it, i) => h('div', { key: i, className: 'cdex-menu-item', onClick: () => { try { it.act() } finally { closeMenu() } } },
1172
1336
  h('span', null, it.icon), h('span', null, it.label),
1173
1337
  )),
1174
1338
  ),
1175
1339
  )
1176
1340
  }
1341
+ let nameInputEl = null
1342
+ // 新建文件/文件夹、重命名 的输入弹窗(Enter 确认 / Esc 取消)
1343
+ function NameDialog() {
1344
+ const st = useStore()
1345
+ const d = st.nameDialog
1346
+ if (!d) return null
1347
+ const close = () => { st.nameDialog = null; nameInputEl = null; emit() }
1348
+ const titles = { newfile: '新建文件', newdir: '新建文件夹', rename: d.isDir ? '重命名文件夹' : '重命名文件' }
1349
+ const confirm = async () => {
1350
+ const name = (nameInputEl && nameInputEl.value || '').trim()
1351
+ if (!name) { showToast('请输入名称'); return }
1352
+ if (name.indexOf('/') >= 0 || name.indexOf('\\') >= 0) { showToast('名称不能包含路径分隔符'); return }
1353
+ if (d.mode === 'rename') {
1354
+ const to = joinPath(d.dir, name)
1355
+ const res = await callHost('fs-rename', { from: d.path, to: to })
1356
+ if (res && res.error) { showToast('重命名失败:' + res.error); close(); return }
1357
+ renameTabSync(d.path, to)
1358
+ if (store.nodes[d.dir]) await reloadDir(d.dir)
1359
+ else await refresh()
1360
+ showToast('已重命名为:' + name)
1361
+ } else {
1362
+ const full = joinPath(d.dir, name)
1363
+ const res = await callHost(d.mode === 'newfile' ? 'fs-createfile' : 'fs-mkdir', { path: full })
1364
+ if (res && res.error) { showToast('创建失败:' + res.error); close(); return }
1365
+ if (store.nodes[d.dir]) await reloadDir(d.dir)
1366
+ else await refresh()
1367
+ if (d.mode === 'newfile') await openFile(full)
1368
+ showToast('已创建:' + name)
1369
+ }
1370
+ close()
1371
+ }
1372
+ React.useEffect(() => {
1373
+ if (nameInputEl) { try { nameInputEl.focus(); nameInputEl.select() } catch (e) {} }
1374
+ }, [])
1375
+ return h('div', { className: 'cdex-modal', onClick: close },
1376
+ h('div', { className: 'cdex-modal-box', onClick: (e) => e.stopPropagation() },
1377
+ h('div', { className: 'cdex-modal-title' }, titles[d.mode] || '输入名称', h('span', { style: { color: '#9d9d9d', fontWeight: 400 } }, ' — ', d.mode === 'rename' ? d.dir : d.dir || '')),
1378
+ h('input', { className: 'cdex-input', ref: (el) => { nameInputEl = el }, defaultValue: d.curName || '', placeholder: d.mode === 'rename' ? '输入新名称' : (d.mode === 'newfile' ? '例如 utils.js' : '例如 components'), onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); confirm() } if (e.key === 'Escape') { e.preventDefault(); close() } } }),
1379
+ h('div', { className: 'cdex-modal-actions' },
1380
+ h('button', { className: 'cdex-btn-ghost', onClick: close }, '取消'),
1381
+ h('button', { className: 'cdex-btn', onClick: confirm }, d.mode === 'rename' ? '重命名' : '创建'),
1382
+ ),
1383
+ ),
1384
+ )
1385
+ }
1386
+ // 危险操作确认弹窗(删除等)
1387
+ function ConfirmBox() {
1388
+ const st = useStore()
1389
+ const d = st.confirmBox
1390
+ if (!d) return null
1391
+ const close = () => { st.confirmBox = null; emit() }
1392
+ const ok = async () => { const fn = d.onOk; close(); if (typeof fn === 'function') { try { await fn() } catch (e) {} } }
1393
+ return h('div', { className: 'cdex-modal', onClick: close },
1394
+ h('div', { className: 'cdex-modal-box', onClick: (e) => e.stopPropagation() },
1395
+ h('div', { className: 'cdex-modal-title' }, '⚠ 确认操作'),
1396
+ h('div', { className: 'cdex-confirm-msg' }, d.msg),
1397
+ h('div', { className: 'cdex-modal-actions' },
1398
+ h('button', { className: 'cdex-btn-ghost', onClick: close }, '取消'),
1399
+ h('button', { className: 'cdex-btn cdex-btn-danger', onClick: ok }, '确定'),
1400
+ ),
1401
+ ),
1402
+ )
1403
+ }
1177
1404
  let noteTa = null
1178
1405
  function NoteEditor() {
1179
1406
  const st = useStore()
@@ -1243,6 +1470,8 @@ window.__ModuleLoader__.load({
1243
1470
  st.filePickerOpen ? h(FilePicker) : null,
1244
1471
  st.ctxMenu ? h(ContextMenu) : null,
1245
1472
  st.noteEditor ? h(NoteEditor) : null,
1473
+ st.nameDialog ? h(NameDialog) : null,
1474
+ st.confirmBox ? h(ConfirmBox) : null,
1246
1475
  st.toast ? h('div', { className: 'cdex-toast' }, st.toast.msg) : null,
1247
1476
  )
1248
1477
  }
@@ -1355,6 +1584,8 @@ window.__ModuleLoader__.load({
1355
1584
  st.filePickerOpen ? h(FilePicker) : null,
1356
1585
  st.ctxMenu ? h(ContextMenu) : null,
1357
1586
  st.noteEditor ? h(NoteEditor) : null,
1587
+ st.nameDialog ? h(NameDialog) : null,
1588
+ st.confirmBox ? h(ConfirmBox) : null,
1358
1589
  st.toast ? h('div', { className: 'cdex-toast' }, st.toast.msg) : null,
1359
1590
  )
1360
1591
  }
package/lib/index.js CHANGED
@@ -7,7 +7,7 @@ import { defineTool } from "@deepseek-ai/dsh-tools";
7
7
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
8
8
  import z from "@deepseek-ai/schemastery";
9
9
  import { spawn } from "node:child_process";
10
- import { existsSync, readFileSync, watch } from "node:fs";
10
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
11
11
  import { dirname, isAbsolute, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
 
@@ -473,6 +473,74 @@ function rpcHandler(ctx) {
473
473
  return ok({ events: events });
474
474
  }
475
475
 
476
+ // —— 文件树磁盘操作(真实文件系统,右键菜单用) ——
477
+ if (endpoint === "fs-mkdir") {
478
+ if (!args.path) return ok({ error: "no-path" });
479
+ try {
480
+ mkdirSync(args.path, { recursive: true });
481
+ return ok({ ok: true, path: args.path });
482
+ } catch (err) {
483
+ return ok({ error: "创建目录失败:" + ((err && err.message) || String(err)) });
484
+ }
485
+ }
486
+
487
+ if (endpoint === "fs-createfile") {
488
+ if (!args.path) return ok({ error: "no-path" });
489
+ if (existsSync(args.path)) return ok({ error: "已存在同名文件/目录" });
490
+ try {
491
+ writeSuppress.set(args.path, Date.now() + 1500);
492
+ writeFileSync(args.path, "", "utf8");
493
+ return ok({ ok: true, path: args.path });
494
+ } catch (err) {
495
+ return ok({ error: "创建文件失败:" + ((err && err.message) || String(err)) });
496
+ }
497
+ }
498
+
499
+ if (endpoint === "fs-rename") {
500
+ if (!args.from || !args.to) return ok({ error: "需要 from 与 to 路径" });
501
+ if (!existsSync(args.from)) return ok({ error: "源路径不存在" });
502
+ if (existsSync(args.to)) return ok({ error: "目标已存在同名文件/目录" });
503
+ try {
504
+ // 若重命名的正是被监听的文件:停掉旧 watcher(客户端重开时会按新路径重新 watch)
505
+ unwatchFilePath(args.from);
506
+ writeSuppress.set(args.from, Date.now() + 1500);
507
+ renameSync(args.from, args.to);
508
+ return ok({ ok: true, from: args.from, to: args.to });
509
+ } catch (err) {
510
+ return ok({ error: "重命名失败:" + ((err && err.message) || String(err)) });
511
+ }
512
+ }
513
+
514
+ if (endpoint === "fs-delete") {
515
+ if (!args.path) return ok({ error: "no-path" });
516
+ if (!existsSync(args.path)) return ok({ error: "路径不存在" });
517
+ try {
518
+ // 被监听的文件被删除:停 watcher + 抑制回声事件
519
+ if (fileWatchers.has(args.path)) unwatchFilePath(args.path);
520
+ writeSuppress.set(args.path, Date.now() + 1500);
521
+ rmSync(args.path, { recursive: !!args.recursive, force: false });
522
+ return ok({ ok: true });
523
+ } catch (err) {
524
+ return ok({ error: "删除失败:" + ((err && err.message) || String(err)) });
525
+ }
526
+ }
527
+
528
+ if (endpoint === "fs-reveal") {
529
+ if (!args.path) return ok({ error: "no-path" });
530
+ try {
531
+ if (process.platform === "win32") {
532
+ spawn("explorer", ["/select," + args.path], { detached: true, stdio: "ignore" }).unref();
533
+ } else if (process.platform === "darwin") {
534
+ spawn("open", ["-R", args.path], { detached: true, stdio: "ignore" }).unref();
535
+ } else {
536
+ spawn("xdg-open", [dirname(args.path)], { detached: true, stdio: "ignore" }).unref();
537
+ }
538
+ return ok({ ok: true });
539
+ } catch (err) {
540
+ return ok({ error: "打开文件管理器失败:" + ((err && err.message) || String(err)) });
541
+ }
542
+ }
543
+
476
544
  if (endpoint === "add-reference") {
477
545
  const id = "r" + (idSeq++);
478
546
  const ref = {
@@ -691,7 +759,7 @@ function registerSnippetsTool(tools) {
691
759
  defineTool({
692
760
  name: "get_referenced_snippets",
693
761
  description:
694
- "读取用户在代码编辑器中通过「引用/翻译」标记的代码片段(含文件路径、行号、语言与内容),供 AI 直接使用。当用户在对话框提到「我引用的代码」「翻译我引用的内容」时调用。",
762
+ "读取用户在代码编辑器中通过「引用/翻译」标记的代码片段(含文件路径、行号、语言与内容),供 AI 直接使用。文件树右键「引用给 AI」标记的整文件引用只含绝对路径,请用你自己的文件读取工具打开。当用户在对话框提到「我引用的代码」「翻译我引用的内容」时调用。",
695
763
  parameters: {
696
764
  kind: { type: "string", description: "筛选类型:cite(代码引用) / translate(翻译请求) / all(全部,默认)。" },
697
765
  },
@@ -702,10 +770,14 @@ function registerSnippetsTool(tools) {
702
770
  const refs = Array.isArray(v.references) ? v.references : [];
703
771
  if (refs.length === 0) return [{ type: "text", text: "当前没有已标记的代码片段。" }];
704
772
  const parts = refs.map((r) => {
705
- const label = r.kind === "translate" ? "翻译请求" : "代码引用";
706
- const loc = (r.path || "") + (r.lineStart ? ":" + r.lineStart + "-" + r.lineEnd : "");
773
+ const whole = !r.lineStart;
774
+ const label = r.kind === "translate" ? "翻译请求" : whole ? "文件引用" : "代码引用";
775
+ const loc = (r.path || "") + (whole ? "(整个文件)" : ":" + r.lineStart + "-" + r.lineEnd);
707
776
  const lang = r.language ? " [" + r.language + "]" : "";
708
- return "【" + label + "】" + loc + lang + "\n" + (r.text || "");
777
+ const body = whole
778
+ ? "整个文件的引用:请用你的文件读取工具打开上述绝对路径自行读取。"
779
+ : (r.text || "");
780
+ return "【" + label + "】" + loc + lang + "\n" + body;
709
781
  });
710
782
  return [{ type: "text", text: "共 " + refs.length + " 条标记:\n\n" + parts.join("\n\n") }];
711
783
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-code-ui",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Cursor 风格的 AI 代码编辑器工作台插件(DSH):文件树、多标签页编辑、语法高亮、引用/翻译给 AI、右侧停靠/悬浮窗口双布局",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",