@rooode/dsh-plugin-preview 0.1.2 → 0.1.3

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.
Files changed (3) hide show
  1. package/lib/client.js +876 -47
  2. package/lib/index.js +172 -1
  3. package/package.json +2 -2
package/lib/client.js CHANGED
@@ -23,8 +23,13 @@
23
23
  // =========================================================================
24
24
  const STORAGE_KEY_TABS = 'dsh:preview:tabs:v1';
25
25
  const STORAGE_KEY_WIDTH = 'dsh:preview:panel_width:v1';
26
- const DEFAULT_PANEL_WIDTH = 620;
26
+ const STORAGE_KEY_TREE_OPEN = 'dsh:preview:tree:open:v1';
27
+ const STORAGE_KEY_TREE_WIDTH = 'dsh:preview:tree:width:v1';
28
+ const DEFAULT_PANEL_WIDTH = 760;
27
29
  const MIN_PANEL_WIDTH = 380;
30
+ const DEFAULT_TREE_WIDTH = 240;
31
+ const MIN_TREE_WIDTH = 180;
32
+ const MAX_TREE_WIDTH = 450;
28
33
  const SUPPORTED_EXTENSIONS = new Set([
29
34
  '.md', '.markdown', '.mdown', '.mkdn', '.mdwn',
30
35
  '.txt', '.log', '.json', '.yaml', '.yml',
@@ -39,7 +44,9 @@
39
44
  let globalTabs = [];
40
45
  let globalActiveTabId = null;
41
46
  let globalIsPanelOpen = false;
47
+ let globalIsTreeOpen = true;
42
48
  let globalPanelWidth = DEFAULT_PANEL_WIDTH;
49
+ let globalTreeWidth = DEFAULT_TREE_WIDTH;
43
50
  let globalLastOpenTime = 0;
44
51
  const stateListeners = new Set();
45
52
 
@@ -53,6 +60,23 @@
53
60
  }
54
61
  } catch (e) {}
55
62
 
63
+ try {
64
+ const savedTreeOpen = localStorage.getItem(STORAGE_KEY_TREE_OPEN);
65
+ if (savedTreeOpen !== null) {
66
+ globalIsTreeOpen = savedTreeOpen === 'true';
67
+ }
68
+ } catch (e) {}
69
+
70
+ try {
71
+ const savedTreeWidth = localStorage.getItem(STORAGE_KEY_TREE_WIDTH);
72
+ if (savedTreeWidth) {
73
+ const parsed = parseInt(savedTreeWidth, 10);
74
+ if (!isNaN(parsed) && parsed >= MIN_TREE_WIDTH) {
75
+ globalTreeWidth = Math.min(parsed, MAX_TREE_WIDTH);
76
+ }
77
+ }
78
+ } catch (e) {}
79
+
56
80
  try {
57
81
  const savedTabs = localStorage.getItem(STORAGE_KEY_TABS);
58
82
  if (savedTabs) {
@@ -84,6 +108,22 @@
84
108
  notifyStateChange();
85
109
  }
86
110
 
111
+ function setIsTreeOpen(open) {
112
+ globalIsTreeOpen = open;
113
+ try {
114
+ localStorage.setItem(STORAGE_KEY_TREE_OPEN, String(open));
115
+ } catch (e) {}
116
+ notifyStateChange();
117
+ }
118
+
119
+ function setTreeWidth(width) {
120
+ globalTreeWidth = Math.max(MIN_TREE_WIDTH, Math.min(width, MAX_TREE_WIDTH));
121
+ try {
122
+ localStorage.setItem(STORAGE_KEY_TREE_WIDTH, String(globalTreeWidth));
123
+ } catch (e) {}
124
+ notifyStateChange();
125
+ }
126
+
87
127
  function setPanelOpen(open) {
88
128
  globalIsPanelOpen = open;
89
129
  if (open) {
@@ -112,6 +152,61 @@
112
152
  return SUPPORTED_EXTENSIONS.has(ext);
113
153
  }
114
154
 
155
+ function formatBytes(bytes) {
156
+ if (!bytes || bytes === 0) return '0 B';
157
+ const k = 1024;
158
+ const sizes = ['B', 'KB', 'MB', 'GB'];
159
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
160
+ return (bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 1) + ' ' + sizes[i];
161
+ }
162
+
163
+ function getFileIcon(node) {
164
+ if (node.isDir) {
165
+ return { icon: '📁', color: '#eab308' };
166
+ }
167
+ const ext = (node.extension || getFileExtension(node.name || node.path || '')).toLowerCase();
168
+ if (ext === '.md' || ext === '.markdown') return { icon: '📝', color: '#0284c7' };
169
+ if (ext === '.json') return { icon: '{ }', color: '#10b981' };
170
+ if (ext === '.ts' || ext === '.tsx') return { icon: 'TS', color: '#3b82f6' };
171
+ if (ext === '.js' || ext === '.jsx' || ext === '.mjs') return { icon: 'JS', color: '#eab308' };
172
+ if (ext === '.yaml' || ext === '.yml' || ext === '.toml' || ext === '.ini') return { icon: '⚙️', color: '#f97316' };
173
+ if (ext === '.html' || ext === '.htm') return { icon: '🌐', color: '#ef4444' };
174
+ if (ext === '.css' || ext === '.scss' || ext === '.less') return { icon: '#', color: '#06b6d4' };
175
+ if (ext === '.py') return { icon: '🐍', color: '#38bdf8' };
176
+ if (ext === '.sh' || ext === '.bash' || ext === '.ps1') return { icon: '⌨️', color: '#8b5cf6' };
177
+ if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp'].includes(ext)) return { icon: '🖼️', color: '#ec4899' };
178
+ if (ext === '.log' || ext === '.txt') return { icon: '📄', color: '#64748b' };
179
+ return { icon: '📄', color: '#94a3b8' };
180
+ }
181
+
182
+ function getCurrentWorkspaceRoot() {
183
+ if (clientCtx && clientCtx.workspaces) {
184
+ try {
185
+ if (clientCtx.workspaces.current?.directory) {
186
+ return clientCtx.workspaces.current.directory;
187
+ }
188
+ const snapshot = clientCtx.workspaces.list?.getSnapshot();
189
+ if (snapshot?.items?.length > 0) {
190
+ return snapshot.items[0].directory || snapshot.items[0].path || '';
191
+ }
192
+ } catch (e) {}
193
+ }
194
+ if (clientCtx && clientCtx.sessions) {
195
+ try {
196
+ const snapshot = clientCtx.sessions.list?.getSnapshot();
197
+ const currentSessionId = snapshot?.current;
198
+ if (currentSessionId && snapshot?.byId?.[currentSessionId]?.cwd) {
199
+ return snapshot.byId[currentSessionId].cwd;
200
+ }
201
+ } catch (e) {}
202
+ }
203
+ if (typeof location !== 'undefined' && location.hash) {
204
+ const m = location.hash.match(/workspace=([^&]+)/);
205
+ if (m) return decodeURIComponent(m[1]);
206
+ }
207
+ return '';
208
+ }
209
+
115
210
  function resolveAbsoluteFilePath(targetPath) {
116
211
  if (!targetPath) return '';
117
212
  let cleanPath = targetPath.trim();
@@ -126,24 +221,7 @@
126
221
  if (isWinAbs || isPosixAbs) {
127
222
  return cleanPath;
128
223
  }
129
- let cwd = '';
130
- if (clientCtx && clientCtx.sessions) {
131
- try {
132
- const snapshot = clientCtx.sessions.list.getSnapshot();
133
- const currentSessionId = snapshot.current;
134
- if (currentSessionId && snapshot.byId[currentSessionId]) {
135
- cwd = snapshot.byId[currentSessionId].cwd || '';
136
- }
137
- } catch (e) {}
138
- }
139
- if (!cwd && clientCtx && clientCtx.workspaces) {
140
- try {
141
- const wsList = clientCtx.workspaces.list.getSnapshot();
142
- if (wsList.items && wsList.items.length > 0) {
143
- cwd = wsList.items[0].directory || '';
144
- }
145
- } catch (e) {}
146
- }
224
+ const cwd = getCurrentWorkspaceRoot();
147
225
  if (cwd) {
148
226
  const isWinCwd = /^[a-zA-Z]:[\\/]/.test(cwd);
149
227
  const sep = isWinCwd ? '\\' : '/';
@@ -201,7 +279,6 @@
201
279
  globalActiveTabId = nextTabs[nextIdx].id;
202
280
  } else {
203
281
  globalActiveTabId = null;
204
- globalIsPanelOpen = false;
205
282
  }
206
283
  }
207
284
  saveTabsToStorage();
@@ -211,7 +288,6 @@
211
288
  function closeAllTabs() {
212
289
  globalTabs = [];
213
290
  globalActiveTabId = null;
214
- globalIsPanelOpen = false;
215
291
  saveTabsToStorage();
216
292
  notifyStateChange();
217
293
  }
@@ -686,10 +762,300 @@
686
762
  };
687
763
  }
688
764
 
765
+ // =========================================================================
766
+ // React Components: Workspace File Tree Explorer
767
+ // =========================================================================
768
+ function FileTreeNodeItem({ node, depth = 0, expandedPaths, onToggleExpand, activeFilePath, onOpenFile, searchQuery }) {
769
+ const isDir = node.isDir;
770
+ const isExpanded = expandedPaths.has(node.path);
771
+ const isActive = !isDir && activeFilePath && activeFilePath.toLowerCase() === node.path.toLowerCase();
772
+ const iconInfo = getFileIcon(node);
773
+
774
+ const indentStyle = { paddingLeft: `${depth * 14 + 6}px` };
775
+
776
+ const handleClick = (e) => {
777
+ e.stopPropagation();
778
+ if (isDir) {
779
+ onToggleExpand(node.path);
780
+ } else {
781
+ onOpenFile(node.path);
782
+ }
783
+ };
784
+
785
+ return h(Fragment, null,
786
+ h('div', {
787
+ className: `dsh-tree-node-row ${isDir ? 'is-dir' : 'is-file'} ${isActive ? 'active' : ''}`,
788
+ style: indentStyle,
789
+ onClick: handleClick,
790
+ title: `${node.name}\n${node.path}${node.size ? ' (' + formatBytes(node.size) + ')' : ''}`,
791
+ },
792
+ // Chevron indicator for directory
793
+ isDir ? h('span', {
794
+ className: `dsh-tree-chevron ${isExpanded ? 'expanded' : ''}`,
795
+ }, isExpanded ? '▼' : '▶') : h('span', { className: 'dsh-tree-chevron-placeholder' }),
796
+
797
+ // File / Directory Icon
798
+ h('span', {
799
+ className: 'dsh-tree-icon',
800
+ style: { color: isDir ? (isExpanded ? '#ca8a04' : '#eab308') : iconInfo.color },
801
+ }, isDir ? (isExpanded ? '📂' : '📁') : iconInfo.icon),
802
+
803
+ // File / Directory Name
804
+ h('span', { className: 'dsh-tree-name' }, node.name),
805
+
806
+ // Size / Count badge
807
+ isDir && node.childCount !== undefined ? h('span', { className: 'dsh-tree-badge' }, node.childCount) : (
808
+ !isDir && node.size ? h('span', { className: 'dsh-tree-size-badge' }, formatBytes(node.size)) : null
809
+ )
810
+ ),
811
+
812
+ // Children if expanded
813
+ isDir && isExpanded && node.children && node.children.length > 0 ? (
814
+ node.children.map(child => h(FileTreeNodeItem, {
815
+ key: child.path,
816
+ node: child,
817
+ depth: depth + 1,
818
+ expandedPaths,
819
+ onToggleExpand,
820
+ activeFilePath,
821
+ onOpenFile,
822
+ searchQuery,
823
+ }))
824
+ ) : null
825
+ );
826
+ }
827
+
828
+ function filterTreeNode(node, query) {
829
+ if (!query) return true;
830
+ const q = query.toLowerCase();
831
+ if (node.name.toLowerCase().includes(q)) return true;
832
+ if (node.isDir && node.children) {
833
+ return node.children.some(child => filterTreeNode(child, query));
834
+ }
835
+ return false;
836
+ }
837
+
838
+ function filterTree(tree, query) {
839
+ if (!query) return tree;
840
+ return tree.filter(node => filterTreeNode(node, query)).map(node => {
841
+ if (!node.isDir) return node;
842
+ return {
843
+ ...node,
844
+ children: filterTree(node.children || [], query),
845
+ };
846
+ });
847
+ }
848
+
849
+ function collectAllDirPaths(tree) {
850
+ const paths = [];
851
+ const traverse = (nodes) => {
852
+ for (const n of nodes) {
853
+ if (n.isDir) {
854
+ paths.push(n.path);
855
+ if (n.children) traverse(n.children);
856
+ }
857
+ }
858
+ };
859
+ traverse(tree);
860
+ return paths;
861
+ }
862
+
863
+ function WorkspaceTreeSidebar({
864
+ width,
865
+ activeFilePath,
866
+ onOpenFile,
867
+ onCloseSidebar,
868
+ }) {
869
+ const [treeData, setTreeData] = useState(null);
870
+ const [isLoading, setIsLoading] = useState(false);
871
+ const [error, setError] = useState(null);
872
+ const [searchQuery, setSearchQuery] = useState('');
873
+ const [expandedPaths, setExpandedPaths] = useState(new Set());
874
+ const currentWs = getCurrentWorkspaceRoot();
875
+
876
+ const loadTree = useCallback(async (force = false) => {
877
+ setIsLoading(true);
878
+ setError(null);
879
+ try {
880
+ const rootPath = getCurrentWorkspaceRoot();
881
+ const query = encodeURIComponent(rootPath);
882
+ const res = await fetch(`/api/preview/workspace-tree?path=${query}`);
883
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
884
+ const json = await res.json();
885
+ if (!json.ok) throw new Error(json?.error?.message || '读取工作区目录失败');
886
+ setTreeData(json);
887
+ // By default expand top 1 level directories
888
+ if (json.tree) {
889
+ const topDirs = json.tree.filter(n => n.isDir).map(n => n.path);
890
+ setExpandedPaths(new Set(topDirs));
891
+ }
892
+ } catch (err) {
893
+ setError(err.message || '加载工作空间文件树失败');
894
+ } finally {
895
+ setIsLoading(false);
896
+ }
897
+ }, []);
898
+
899
+ useEffect(() => {
900
+ loadTree();
901
+ }, [loadTree]);
902
+
903
+ const handleToggleExpand = (dirPath) => {
904
+ setExpandedPaths(prev => {
905
+ const next = new Set(prev);
906
+ if (next.has(dirPath)) next.delete(dirPath);
907
+ else next.add(dirPath);
908
+ return next;
909
+ });
910
+ };
911
+
912
+ const handleExpandAll = () => {
913
+ if (treeData?.tree) {
914
+ setExpandedPaths(new Set(collectAllDirPaths(treeData.tree)));
915
+ }
916
+ };
917
+
918
+ const handleCollapseAll = () => {
919
+ setExpandedPaths(new Set());
920
+ };
921
+
922
+ const displayTree = useMemo(() => {
923
+ if (!treeData?.tree) return [];
924
+ return filterTree(treeData.tree, searchQuery.trim());
925
+ }, [treeData, searchQuery]);
926
+
927
+ // If searching, auto expand matching folders
928
+ useEffect(() => {
929
+ if (searchQuery.trim() && treeData?.tree) {
930
+ const allMatchingDirs = collectAllDirPaths(filterTree(treeData.tree, searchQuery.trim()));
931
+ setExpandedPaths(new Set(allMatchingDirs));
932
+ }
933
+ }, [searchQuery, treeData]);
934
+
935
+ return h('div', {
936
+ className: 'dsh-tree-sidebar',
937
+ style: { width: `${width}px` },
938
+ },
939
+ // Sidebar Header
940
+ h('div', { className: 'dsh-tree-sidebar-header' },
941
+ h('div', { className: 'dsh-tree-sidebar-title', title: treeData?.workspaceRoot || currentWs },
942
+ h('span', { className: 'dsh-tree-header-icon' }, '📁'),
943
+ h('span', { className: 'dsh-tree-header-text' }, treeData?.workspaceName || getFileName(currentWs) || '工作区')
944
+ ),
945
+ h('div', { className: 'dsh-tree-header-actions' },
946
+ h('button', {
947
+ type: 'button',
948
+ className: 'dsh-tree-action-btn',
949
+ onClick: () => loadTree(true),
950
+ title: '刷新文件树',
951
+ }, '🔄'),
952
+ h('button', {
953
+ type: 'button',
954
+ className: 'dsh-tree-action-btn',
955
+ onClick: handleExpandAll,
956
+ title: '全部展开',
957
+ }, '📂'),
958
+ h('button', {
959
+ type: 'button',
960
+ className: 'dsh-tree-action-btn',
961
+ onClick: handleCollapseAll,
962
+ title: '全部折叠',
963
+ }, '📁'),
964
+ onCloseSidebar ? h('button', {
965
+ type: 'button',
966
+ className: 'dsh-tree-action-btn',
967
+ onClick: onCloseSidebar,
968
+ title: '收起目录树 (快捷键 Ctrl/Cmd+B)',
969
+ }, '✕') : null
970
+ )
971
+ ),
972
+
973
+ // Search Input
974
+ h('div', { className: 'dsh-tree-sidebar-search' },
975
+ h('div', { className: 'dsh-tree-search-wrapper' },
976
+ h('input', {
977
+ type: 'text',
978
+ className: 'dsh-tree-search-input',
979
+ placeholder: '🔍 过滤文件 (如 .md, ts)...',
980
+ value: searchQuery,
981
+ onChange: (e) => setSearchQuery(e.target.value),
982
+ }),
983
+ searchQuery ? h('button', {
984
+ type: 'button',
985
+ className: 'dsh-tree-search-clear',
986
+ onClick: () => setSearchQuery(''),
987
+ title: '清空过滤条件',
988
+ }, '✕') : null
989
+ )
990
+ ),
991
+
992
+ // Tree Content
993
+ h('div', { className: 'dsh-tree-node-list' },
994
+ isLoading ? (
995
+ h('div', { className: 'dsh-tree-loading' }, '正在扫描工作区文件...')
996
+ ) : error ? (
997
+ h('div', { className: 'dsh-tree-error' },
998
+ h('p', null, `⚠️ ${error}`),
999
+ h('button', {
1000
+ type: 'button',
1001
+ className: 'dsh-tree-retry-btn',
1002
+ onClick: () => loadTree(true),
1003
+ }, '重试')
1004
+ )
1005
+ ) : displayTree.length === 0 ? (
1006
+ h('div', { className: 'dsh-tree-empty' }, searchQuery ? '未匹配到相关文件' : '工作区暂无文件')
1007
+ ) : (
1008
+ displayTree.map(node => h(FileTreeNodeItem, {
1009
+ key: node.path,
1010
+ node,
1011
+ depth: 0,
1012
+ expandedPaths,
1013
+ onToggleExpand: handleToggleExpand,
1014
+ activeFilePath,
1015
+ onOpenFile,
1016
+ searchQuery,
1017
+ }))
1018
+ )
1019
+ ),
1020
+
1021
+ // Sidebar Footer with file count stats
1022
+ treeData ? h('div', { className: 'dsh-tree-sidebar-footer' },
1023
+ h('span', null, `${treeData.totalDirs || 0} 目录`),
1024
+ h('span', { className: 'dsh-tree-footer-dot' }, '•'),
1025
+ h('span', null, `${treeData.totalFiles || 0} 文件`)
1026
+ ) : null
1027
+ );
1028
+ }
1029
+
1030
+ function WorkspaceWelcomeView({ workspaceRoot, workspaceName, onOpenFile, onOpenTree }) {
1031
+ return h('div', { className: 'dsh-preview-welcome-container' },
1032
+ h('div', { className: 'dsh-preview-welcome-card' },
1033
+ h('div', { className: 'dsh-welcome-icon' }, '🗂️'),
1034
+ h('h3', { className: 'dsh-welcome-title' }, `工作空间: ${workspaceName || '当前项目'}`),
1035
+ h('p', { className: 'dsh-welcome-path' }, workspaceRoot || '本地项目目录'),
1036
+ h('p', { className: 'dsh-welcome-desc' },
1037
+ '在左侧目录树中选择并点击任意文件(Markdown 文档、代码文件、配置文件等),即可在此多标签预览、阅读与对照。'
1038
+ ),
1039
+ h('div', { className: 'dsh-welcome-actions' },
1040
+ h('button', {
1041
+ type: 'button',
1042
+ className: 'dsh-welcome-btn primary',
1043
+ onClick: onOpenTree,
1044
+ }, '📁 浏览工作区文件树'),
1045
+ h('button', {
1046
+ type: 'button',
1047
+ className: 'dsh-welcome-btn',
1048
+ onClick: () => triggerRevealInExplorer(workspaceRoot || '.'),
1049
+ }, '🖥️ 在资源管理器中查看')
1050
+ )
1051
+ )
1052
+ );
1053
+ }
1054
+
689
1055
  // =========================================================================
690
1056
  // React Components: MarkdownPreviewView
691
1057
  // =========================================================================
692
- function MarkdownPreviewView({ tab, fileInfo, isLoading, error, onRefresh, onOpenNative, onReveal }) {
1058
+ function MarkdownPreviewView({ tab, fileInfo, isLoading, error, isTreeOpen, onToggleTree, onRefresh, onOpenNative, onReveal }) {
693
1059
  const [viewMode, setViewMode] = useState(tab.viewMode || 'preview');
694
1060
  const [isTocOpen, setIsTocOpen] = useState(false);
695
1061
  const [searchQuery, setSearchQuery] = useState('');
@@ -740,6 +1106,13 @@
740
1106
  h('div', { className: 'dsh-preview-toolbar' },
741
1107
  // Left: View mode toggles
742
1108
  h('div', { className: 'dsh-preview-toolbar-left' },
1109
+ h('button', {
1110
+ type: 'button',
1111
+ className: `dsh-preview-tool-btn ${isTreeOpen ? 'active' : ''}`,
1112
+ onClick: onToggleTree,
1113
+ title: isTreeOpen ? '隐藏工作区目录树 (Ctrl/Cmd+B)' : '展开工作区目录树 (Ctrl/Cmd+B)',
1114
+ }, '🗂️ 目录树'),
1115
+
743
1116
  h('div', { className: 'dsh-preview-mode-group' },
744
1117
  h('button', {
745
1118
  type: 'button',
@@ -933,6 +1306,11 @@
933
1306
  const [activeTabId, setActiveTabId] = useState(globalActiveTabId);
934
1307
  const [isOpen, setIsOpen] = useState(globalIsPanelOpen);
935
1308
  const [width, setWidth] = useState(globalPanelWidth);
1309
+ const [isTreeOpen, setLocalIsTreeOpen] = useState(globalIsTreeOpen);
1310
+ const [treeWidth, setLocalTreeWidth] = useState(globalTreeWidth);
1311
+ const [isTreeDragging, setIsTreeDragging] = useState(false);
1312
+ const treeDragStartX = useRef(0);
1313
+ const treeDragStartWidth = useRef(0);
936
1314
  const [fileData, setFileData] = useState({});
937
1315
  const [loadingMap, setLoadingMap] = useState({});
938
1316
  const [errorMap, setErrorMap] = useState({});
@@ -947,6 +1325,8 @@
947
1325
  setActiveTabId(globalActiveTabId);
948
1326
  setIsOpen(globalIsPanelOpen);
949
1327
  setWidth(globalPanelWidth);
1328
+ setLocalIsTreeOpen(globalIsTreeOpen);
1329
+ setLocalTreeWidth(globalTreeWidth);
950
1330
  };
951
1331
  stateListeners.add(update);
952
1332
  return () => stateListeners.delete(update);
@@ -982,7 +1362,7 @@
982
1362
  }
983
1363
  }, [activeTab, loadTabFile]);
984
1364
 
985
- // Drag Resize Handlers
1365
+ // Drag Resize Handlers (Panel Width)
986
1366
  const onResizePointerDown = useCallback((e) => {
987
1367
  e.preventDefault();
988
1368
  e.currentTarget.setPointerCapture(e.pointerId);
@@ -1005,6 +1385,29 @@
1005
1385
  }
1006
1386
  }, [isDragging]);
1007
1387
 
1388
+ // Drag Resize Handlers (Tree Width)
1389
+ const onTreeResizePointerDown = useCallback((e) => {
1390
+ e.preventDefault();
1391
+ e.currentTarget.setPointerCapture(e.pointerId);
1392
+ treeDragStartX.current = e.clientX;
1393
+ treeDragStartWidth.current = globalTreeWidth;
1394
+ setIsTreeDragging(true);
1395
+ }, []);
1396
+
1397
+ const onTreeResizePointerMove = useCallback((e) => {
1398
+ if (!isTreeDragging) return;
1399
+ const dx = e.clientX - treeDragStartX.current;
1400
+ const newWidth = treeDragStartWidth.current + dx;
1401
+ setTreeWidth(newWidth);
1402
+ }, [isTreeDragging]);
1403
+
1404
+ const onTreeResizePointerUp = useCallback((e) => {
1405
+ if (isTreeDragging) {
1406
+ setIsTreeDragging(false);
1407
+ try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (err) {}
1408
+ }
1409
+ }, [isTreeDragging]);
1410
+
1008
1411
  const drawerRef = useRef(null);
1009
1412
 
1010
1413
  // Outside Click Listener: Click on the chat conversation / dialog box outside the drawer to auto close/hide it
@@ -1015,7 +1418,7 @@
1015
1418
  // 1. Ignore if just opened within 250ms
1016
1419
  if (Date.now() - globalLastOpenTime < 250) return;
1017
1420
  // 2. Ignore during dragging resize handle
1018
- if (isDragging) return;
1421
+ if (isDragging || isTreeDragging) return;
1019
1422
 
1020
1423
  const target = e.target;
1021
1424
  if (!target) return;
@@ -1054,42 +1457,44 @@
1054
1457
  return () => {
1055
1458
  window.removeEventListener('pointerdown', handleOutsidePointerDown, true);
1056
1459
  };
1057
- }, [isOpen, isDragging]);
1460
+ }, [isOpen, isDragging, isTreeDragging]);
1058
1461
 
1059
- // Hotkey: Esc closes preview
1462
+ // Hotkey: Esc closes preview, Ctrl/Cmd+B toggles file tree
1060
1463
  useEffect(() => {
1061
1464
  const onKeyDown = (e) => {
1062
1465
  if (e.key === 'Escape' && globalIsPanelOpen) {
1063
1466
  setPanelOpen(false);
1064
1467
  }
1468
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'b' || e.key === 'B')) {
1469
+ e.preventDefault();
1470
+ setIsTreeOpen(!globalIsTreeOpen);
1471
+ }
1065
1472
  };
1066
1473
  window.addEventListener('keydown', onKeyDown);
1067
1474
  return () => window.removeEventListener('keydown', onKeyDown);
1068
1475
  }, []);
1069
1476
 
1070
- if (!isOpen && tabs.length === 0) return null;
1071
-
1072
1477
  const activeKey = activeTab?.filePath?.toLowerCase();
1073
1478
  const currentFileInfo = activeKey ? fileData[activeKey] : null;
1074
1479
  const currentLoading = activeKey ? !!loadingMap[activeKey] : false;
1075
1480
  const currentError = activeKey ? errorMap[activeKey] : null;
1076
1481
 
1077
1482
  return h(Fragment, null,
1078
- // Floating Mini Button to Reopen (when closed but tabs exist)
1079
- !isOpen && tabs.length > 0 ? h('div', {
1483
+ // Floating Mini Button to Reopen (when closed)
1484
+ !isOpen ? h('div', {
1080
1485
  className: 'dsh-preview-float-pill',
1081
1486
  onClick: () => setPanelOpen(true),
1082
- title: `重新展开文档预览 (${tabs.length} 个标签)`,
1487
+ title: tabs.length > 0 ? `展开文档与工作区文件浏览 (${tabs.length} 个标签)` : '浏览工作空间文件树',
1083
1488
  },
1084
- h('span', { className: 'dsh-float-pill-icon' }, '📝'),
1085
- h('span', { className: 'dsh-float-pill-text' }, `预览: ${activeTab?.title || '文档'}`),
1086
- h('span', { className: 'dsh-float-pill-badge' }, tabs.length)
1489
+ h('span', { className: 'dsh-float-pill-icon' }, '📁'),
1490
+ h('span', { className: 'dsh-float-pill-text' }, tabs.length > 0 ? `预览: ${activeTab?.title || '文档'}` : '工作区文件'),
1491
+ tabs.length > 0 ? h('span', { className: 'dsh-float-pill-badge' }, tabs.length) : null
1087
1492
  ) : null,
1088
1493
 
1089
1494
  // Right-Side Slide Drawer
1090
1495
  isOpen ? h('div', {
1091
1496
  ref: drawerRef,
1092
- className: `dsh-preview-drawer-container ${isDragging ? 'dragging' : ''}`,
1497
+ className: `dsh-preview-drawer-container ${isDragging || isTreeDragging ? 'dragging' : ''}`,
1093
1498
  style: { width: `${width}px` },
1094
1499
  },
1095
1500
  // Drag Handle on Left Border
@@ -1105,7 +1510,7 @@
1105
1510
  h('div', { className: 'dsh-preview-drawer-header' },
1106
1511
  // Tabs Bar
1107
1512
  h('div', { className: 'dsh-preview-tabs-row' },
1108
- tabs.map(tab => {
1513
+ tabs.length > 0 ? tabs.map(tab => {
1109
1514
  const isActive = tab.id === activeTabId;
1110
1515
  const ext = (tab.extension || '').toLowerCase();
1111
1516
  const icon = ext === '.json' ? '{ }' : (ext === '.ts' || ext === '.js' ? 'TS' : '📝');
@@ -1124,11 +1529,20 @@
1124
1529
  title: '关闭此标签',
1125
1530
  }, '✕')
1126
1531
  );
1127
- })
1532
+ }) : h('div', { className: 'dsh-preview-tab-item active' },
1533
+ h('span', { className: 'dsh-tab-icon' }, '📁'),
1534
+ h('span', { className: 'dsh-tab-title' }, '工作空间文件树')
1535
+ )
1128
1536
  ),
1129
1537
 
1130
1538
  // Header Right Window Controls
1131
1539
  h('div', { className: 'dsh-preview-window-controls' },
1540
+ h('button', {
1541
+ type: 'button',
1542
+ className: `dsh-win-ctrl-btn ${isTreeOpen ? 'active' : ''}`,
1543
+ onClick: () => setIsTreeOpen(!globalIsTreeOpen),
1544
+ title: isTreeOpen ? '收起目录树 (Ctrl/Cmd+B)' : '展开目录树 (Ctrl/Cmd+B)',
1545
+ }, '🗂️ 目录树'),
1132
1546
  tabs.length > 1 ? h('button', {
1133
1547
  type: 'button',
1134
1548
  className: 'dsh-win-ctrl-btn',
@@ -1144,16 +1558,43 @@
1144
1558
  )
1145
1559
  ),
1146
1560
 
1147
- // Drawer Body
1148
- activeTab ? h(MarkdownPreviewView, {
1149
- tab: activeTab,
1150
- fileInfo: currentFileInfo,
1151
- isLoading: currentLoading,
1152
- error: currentError,
1153
- onRefresh: () => loadTabFile(activeTab, true),
1154
- onOpenNative: triggerOpenNative,
1155
- onReveal: triggerRevealInExplorer,
1156
- }) : h('div', { className: 'dsh-preview-empty-state' }, '暂无打开的文件')
1561
+ // Drawer Body (Split View between Workspace Tree & Preview View)
1562
+ h('div', { className: 'dsh-preview-drawer-body-split' },
1563
+ // Left: Workspace File Tree
1564
+ isTreeOpen ? h(WorkspaceTreeSidebar, {
1565
+ width: treeWidth,
1566
+ activeFilePath: activeTab?.filePath,
1567
+ onOpenFile: openPreviewFile,
1568
+ onCloseSidebar: () => setIsTreeOpen(false),
1569
+ }) : null,
1570
+
1571
+ // Resizer between Tree and Content
1572
+ isTreeOpen ? h('div', {
1573
+ className: `dsh-tree-splitter ${isTreeDragging ? 'dragging' : ''}`,
1574
+ onPointerDown: onTreeResizePointerDown,
1575
+ onPointerMove: onTreeResizePointerMove,
1576
+ onPointerUp: onTreeResizePointerUp,
1577
+ title: '拖拽调整目录树宽度',
1578
+ }) : null,
1579
+
1580
+ // Right: Content View or Welcome View
1581
+ activeTab ? h(MarkdownPreviewView, {
1582
+ tab: activeTab,
1583
+ fileInfo: currentFileInfo,
1584
+ isLoading: currentLoading,
1585
+ error: currentError,
1586
+ isTreeOpen: isTreeOpen,
1587
+ onToggleTree: () => setIsTreeOpen(!globalIsTreeOpen),
1588
+ onRefresh: () => loadTabFile(activeTab, true),
1589
+ onOpenNative: triggerOpenNative,
1590
+ onReveal: triggerRevealInExplorer,
1591
+ }) : h(WorkspaceWelcomeView, {
1592
+ workspaceRoot: getCurrentWorkspaceRoot(),
1593
+ workspaceName: getFileName(getCurrentWorkspaceRoot()),
1594
+ onOpenFile: openPreviewFile,
1595
+ onOpenTree: () => setIsTreeOpen(true),
1596
+ })
1597
+ )
1157
1598
  ) : null
1158
1599
  );
1159
1600
  }
@@ -2219,6 +2660,394 @@
2219
2660
  .dsh-preview-toast-success .dsh-preview-toast-icon { color: #22c55e; font-weight: bold; }
2220
2661
  .dsh-preview-toast-error { border-color: #ef4444; }
2221
2662
  .dsh-preview-toast-error .dsh-preview-toast-icon { color: #ef4444; font-weight: bold; }
2663
+
2664
+ /* Workspace Tree Sidebar & Split Layout */
2665
+ .dsh-preview-drawer-body-split {
2666
+ display: flex;
2667
+ flex-direction: row;
2668
+ flex: 1;
2669
+ min-height: 0;
2670
+ overflow: hidden;
2671
+ width: 100%;
2672
+ }
2673
+
2674
+ .dsh-tree-sidebar {
2675
+ display: flex;
2676
+ flex-direction: column;
2677
+ overflow: hidden;
2678
+ flex-shrink: 0;
2679
+ background: var(--dsw-alias-bg-layer-2, #f8fafc);
2680
+ border-right: 1px solid var(--dsw-alias-border-l3, #e2e8f0);
2681
+ height: 100%;
2682
+ user-select: none;
2683
+ box-sizing: border-box;
2684
+ }
2685
+ body[data-ds-dark-theme] .dsh-tree-sidebar {
2686
+ background: var(--dsw-alias-bg-layer-2, #151821);
2687
+ border-right-color: var(--dsw-alias-border-l3, #2a3140);
2688
+ }
2689
+
2690
+ .dsh-tree-sidebar-header {
2691
+ height: 36px;
2692
+ min-height: 36px;
2693
+ display: flex;
2694
+ align-items: center;
2695
+ justify-content: space-between;
2696
+ padding: 0 8px;
2697
+ border-bottom: 1px solid var(--dsw-alias-border-l3, #e2e8f0);
2698
+ font-weight: 600;
2699
+ font-size: 12px;
2700
+ color: var(--dsw-alias-label-primary, #0f172a);
2701
+ background: rgba(0, 0, 0, 0.02);
2702
+ box-sizing: border-box;
2703
+ }
2704
+ body[data-ds-dark-theme] .dsh-tree-sidebar-header {
2705
+ border-bottom-color: var(--dsw-alias-border-l3, #2a3140);
2706
+ background: rgba(255, 255, 255, 0.02);
2707
+ color: #e2e8f0;
2708
+ }
2709
+
2710
+ .dsh-tree-sidebar-title {
2711
+ display: flex;
2712
+ align-items: center;
2713
+ gap: 5px;
2714
+ overflow: hidden;
2715
+ text-overflow: ellipsis;
2716
+ white-space: nowrap;
2717
+ font-size: 12px;
2718
+ }
2719
+ .dsh-tree-header-icon { font-size: 13px; }
2720
+ .dsh-tree-header-text {
2721
+ overflow: hidden;
2722
+ text-overflow: ellipsis;
2723
+ white-space: nowrap;
2724
+ }
2725
+
2726
+ .dsh-tree-header-actions {
2727
+ display: flex;
2728
+ align-items: center;
2729
+ gap: 2px;
2730
+ flex-shrink: 0;
2731
+ }
2732
+
2733
+ .dsh-tree-action-btn {
2734
+ width: 22px;
2735
+ height: 22px;
2736
+ border-radius: 4px;
2737
+ border: none;
2738
+ background: transparent;
2739
+ color: var(--dsw-alias-label-secondary, #64748b);
2740
+ cursor: pointer;
2741
+ display: inline-flex;
2742
+ align-items: center;
2743
+ justify-content: center;
2744
+ font-size: 11px;
2745
+ padding: 0;
2746
+ transition: all 0.1s;
2747
+ }
2748
+ .dsh-tree-action-btn:hover {
2749
+ background: rgba(0, 0, 0, 0.06);
2750
+ color: var(--dsw-alias-label-primary, #0f172a);
2751
+ }
2752
+ body[data-ds-dark-theme] .dsh-tree-action-btn:hover {
2753
+ background: rgba(255, 255, 255, 0.08);
2754
+ color: #ffffff;
2755
+ }
2756
+
2757
+ .dsh-tree-sidebar-search {
2758
+ padding: 6px 8px;
2759
+ border-bottom: 1px solid var(--dsw-alias-border-l3, #e2e8f0);
2760
+ background: var(--dsw-alias-bg-layer-1, #ffffff);
2761
+ box-sizing: border-box;
2762
+ }
2763
+ body[data-ds-dark-theme] .dsh-tree-sidebar-search {
2764
+ border-bottom-color: var(--dsw-alias-border-l3, #2a3140);
2765
+ background: var(--dsw-alias-bg-layer-1, #161922);
2766
+ }
2767
+
2768
+ .dsh-tree-search-wrapper {
2769
+ position: relative;
2770
+ display: flex;
2771
+ align-items: center;
2772
+ }
2773
+
2774
+ .dsh-tree-search-input {
2775
+ width: 100%;
2776
+ height: 24px;
2777
+ border-radius: 4px;
2778
+ border: 1px solid var(--dsw-alias-border-l2, #cbd5e1);
2779
+ padding: 0 20px 0 6px;
2780
+ font-size: 11px;
2781
+ background: var(--dsw-alias-bg-base, #f8fafc);
2782
+ color: inherit;
2783
+ box-sizing: border-box;
2784
+ outline: none;
2785
+ transition: border-color 0.15s;
2786
+ }
2787
+ .dsh-tree-search-input:focus {
2788
+ border-color: var(--dsw-static-deepseek-500, #4176e6);
2789
+ background: #ffffff;
2790
+ }
2791
+ body[data-ds-dark-theme] .dsh-tree-search-input {
2792
+ background: #1e2330;
2793
+ border-color: #334155;
2794
+ color: #f1f5f9;
2795
+ }
2796
+ body[data-ds-dark-theme] .dsh-tree-search-input:focus {
2797
+ background: #181b24;
2798
+ border-color: #60a5fa;
2799
+ }
2800
+
2801
+ .dsh-tree-search-clear {
2802
+ position: absolute;
2803
+ right: 4px;
2804
+ width: 16px;
2805
+ height: 16px;
2806
+ border-radius: 50%;
2807
+ border: none;
2808
+ background: rgba(0, 0, 0, 0.1);
2809
+ color: inherit;
2810
+ font-size: 9px;
2811
+ display: flex;
2812
+ align-items: center;
2813
+ justify-content: center;
2814
+ cursor: pointer;
2815
+ padding: 0;
2816
+ }
2817
+
2818
+ .dsh-tree-node-list {
2819
+ flex: 1;
2820
+ min-height: 0;
2821
+ overflow-y: auto;
2822
+ overflow-x: hidden;
2823
+ padding: 4px 2px;
2824
+ box-sizing: border-box;
2825
+ }
2826
+ .dsh-tree-node-list::-webkit-scrollbar {
2827
+ width: 4px;
2828
+ }
2829
+ .dsh-tree-node-list::-webkit-scrollbar-thumb {
2830
+ background: rgba(0, 0, 0, 0.15);
2831
+ border-radius: 2px;
2832
+ }
2833
+
2834
+ .dsh-tree-node-row {
2835
+ display: flex;
2836
+ align-items: center;
2837
+ height: 24px;
2838
+ min-height: 24px;
2839
+ border-radius: 4px;
2840
+ margin: 1px 4px;
2841
+ cursor: pointer;
2842
+ font-size: 12px;
2843
+ color: var(--dsw-alias-label-secondary, #334155);
2844
+ gap: 3px;
2845
+ transition: background 0.1s ease, color 0.1s ease;
2846
+ overflow: hidden;
2847
+ box-sizing: border-box;
2848
+ }
2849
+ body[data-ds-dark-theme] .dsh-tree-node-row {
2850
+ color: #94a3b8;
2851
+ }
2852
+
2853
+ .dsh-tree-node-row:hover {
2854
+ background: var(--dsw-alias-bg-hover, rgba(0, 0, 0, 0.05));
2855
+ color: var(--dsw-alias-label-primary, #0f172a);
2856
+ }
2857
+ body[data-ds-dark-theme] .dsh-tree-node-row:hover {
2858
+ background: rgba(255, 255, 255, 0.06);
2859
+ color: #f1f5f9;
2860
+ }
2861
+
2862
+ .dsh-tree-node-row.active {
2863
+ background: rgba(14, 165, 233, 0.14);
2864
+ color: #0284c7;
2865
+ font-weight: 600;
2866
+ }
2867
+ body[data-ds-dark-theme] .dsh-tree-node-row.active {
2868
+ background: rgba(56, 189, 248, 0.16);
2869
+ color: #38bdf8;
2870
+ }
2871
+
2872
+ .dsh-tree-chevron {
2873
+ width: 14px;
2874
+ height: 14px;
2875
+ display: inline-flex;
2876
+ align-items: center;
2877
+ justify-content: center;
2878
+ font-size: 8px;
2879
+ color: #94a3b8;
2880
+ flex-shrink: 0;
2881
+ user-select: none;
2882
+ }
2883
+
2884
+ .dsh-tree-chevron-placeholder {
2885
+ width: 14px;
2886
+ height: 14px;
2887
+ display: inline-flex;
2888
+ flex-shrink: 0;
2889
+ }
2890
+
2891
+ .dsh-tree-icon {
2892
+ width: 16px;
2893
+ height: 16px;
2894
+ display: inline-flex;
2895
+ align-items: center;
2896
+ justify-content: center;
2897
+ font-size: 12px;
2898
+ flex-shrink: 0;
2899
+ font-family: ui-monospace, monospace;
2900
+ font-weight: 700;
2901
+ }
2902
+
2903
+ .dsh-tree-name {
2904
+ flex: 1;
2905
+ overflow: hidden;
2906
+ text-overflow: ellipsis;
2907
+ white-space: nowrap;
2908
+ font-size: 12px;
2909
+ text-align: left;
2910
+ }
2911
+
2912
+ .dsh-tree-badge {
2913
+ font-size: 10px;
2914
+ color: #94a3b8;
2915
+ background: rgba(0, 0, 0, 0.05);
2916
+ padding: 0 4px;
2917
+ border-radius: 8px;
2918
+ margin-right: 4px;
2919
+ flex-shrink: 0;
2920
+ }
2921
+ body[data-ds-dark-theme] .dsh-tree-badge {
2922
+ background: rgba(255, 255, 255, 0.08);
2923
+ color: #64748b;
2924
+ }
2925
+
2926
+ .dsh-tree-size-badge {
2927
+ font-size: 10px;
2928
+ color: #94a3b8;
2929
+ margin-right: 4px;
2930
+ flex-shrink: 0;
2931
+ }
2932
+
2933
+ .dsh-tree-loading, .dsh-tree-error, .dsh-tree-empty {
2934
+ padding: 24px 12px;
2935
+ text-align: center;
2936
+ font-size: 12px;
2937
+ color: #94a3b8;
2938
+ }
2939
+
2940
+ .dsh-tree-retry-btn {
2941
+ margin-top: 8px;
2942
+ padding: 2px 10px;
2943
+ border-radius: 4px;
2944
+ border: 1px solid var(--dsw-alias-border-l2, #cbd5e1);
2945
+ background: transparent;
2946
+ color: inherit;
2947
+ cursor: pointer;
2948
+ }
2949
+
2950
+ .dsh-tree-sidebar-footer {
2951
+ height: 24px;
2952
+ min-height: 24px;
2953
+ border-top: 1px solid var(--dsw-alias-border-l3, #e2e8f0);
2954
+ display: flex;
2955
+ align-items: center;
2956
+ justify-content: center;
2957
+ gap: 6px;
2958
+ font-size: 11px;
2959
+ color: #94a3b8;
2960
+ background: rgba(0, 0, 0, 0.015);
2961
+ }
2962
+ body[data-ds-dark-theme] .dsh-tree-sidebar-footer {
2963
+ border-top-color: var(--dsw-alias-border-l3, #2a3140);
2964
+ }
2965
+ .dsh-tree-footer-dot { font-size: 8px; }
2966
+
2967
+ /* Splitter between Tree and Preview */
2968
+ .dsh-tree-splitter {
2969
+ width: 4px;
2970
+ cursor: col-resize;
2971
+ background: transparent;
2972
+ transition: background 0.15s;
2973
+ flex-shrink: 0;
2974
+ z-index: 10;
2975
+ }
2976
+ .dsh-tree-splitter:hover, .dsh-tree-splitter.dragging {
2977
+ background: var(--dsw-static-deepseek-500, #4176e6);
2978
+ }
2979
+
2980
+ /* Welcome Empty View */
2981
+ .dsh-preview-welcome-container {
2982
+ flex: 1;
2983
+ display: flex;
2984
+ align-items: center;
2985
+ justify-content: center;
2986
+ padding: 32px;
2987
+ background: var(--dsw-alias-bg-layer-1, #ffffff);
2988
+ color: var(--dsw-alias-label-primary, #0f172a);
2989
+ overflow: auto;
2990
+ }
2991
+ body[data-ds-dark-theme] .dsh-preview-welcome-container {
2992
+ background: var(--dsw-alias-bg-layer-1, #161922);
2993
+ color: #e2e8f0;
2994
+ }
2995
+
2996
+ .dsh-preview-welcome-card {
2997
+ max-width: 420px;
2998
+ text-align: center;
2999
+ display: flex;
3000
+ flex-direction: column;
3001
+ align-items: center;
3002
+ gap: 12px;
3003
+ }
3004
+ .dsh-welcome-icon { font-size: 40px; }
3005
+ .dsh-welcome-title { margin: 0; font-size: 16px; font-weight: 600; }
3006
+ .dsh-welcome-path {
3007
+ margin: 0;
3008
+ font-size: 11px;
3009
+ color: #64748b;
3010
+ background: rgba(0, 0, 0, 0.04);
3011
+ padding: 4px 8px;
3012
+ border-radius: 4px;
3013
+ word-break: break-all;
3014
+ }
3015
+ body[data-ds-dark-theme] .dsh-welcome-path {
3016
+ background: rgba(255, 255, 255, 0.06);
3017
+ color: #94a3b8;
3018
+ }
3019
+ .dsh-welcome-desc {
3020
+ margin: 0;
3021
+ font-size: 12px;
3022
+ color: var(--dsw-alias-label-secondary, #64748b);
3023
+ line-height: 1.6;
3024
+ }
3025
+ .dsh-welcome-actions {
3026
+ display: flex;
3027
+ gap: 8px;
3028
+ margin-top: 8px;
3029
+ }
3030
+ .dsh-welcome-btn {
3031
+ padding: 6px 14px;
3032
+ border-radius: 6px;
3033
+ font-size: 12px;
3034
+ border: 1px solid var(--dsw-alias-border-l2, #cbd5e1);
3035
+ background: transparent;
3036
+ color: inherit;
3037
+ cursor: pointer;
3038
+ transition: all 0.15s;
3039
+ }
3040
+ .dsh-welcome-btn:hover {
3041
+ background: rgba(0, 0, 0, 0.05);
3042
+ }
3043
+ .dsh-welcome-btn.primary {
3044
+ background: var(--dsw-static-deepseek-500, #4176e6);
3045
+ color: #ffffff;
3046
+ border-color: var(--dsw-static-deepseek-500, #4176e6);
3047
+ }
3048
+ .dsh-welcome-btn.primary:hover {
3049
+ opacity: 0.9;
3050
+ }
2222
3051
  `;
2223
3052
  document.head.appendChild(style);
2224
3053
  }
package/lib/index.js CHANGED
@@ -130,6 +130,146 @@ export class PreviewService extends Service {
130
130
  });
131
131
  }
132
132
 
133
+ readWorkspaceTree(targetDir, maxDepth = 4, includeAll = false) {
134
+ try {
135
+ const normalizedRoot = path.resolve(targetDir);
136
+ if (!fs.existsSync(normalizedRoot)) {
137
+ return { ok: false, error: { code: 'DIR_NOT_FOUND', message: '工作区目录不存在: ' + normalizedRoot } };
138
+ }
139
+ const stat = fs.statSync(normalizedRoot);
140
+ if (!stat.isDirectory()) {
141
+ return { ok: false, error: { code: 'NOT_A_DIRECTORY', message: '目标路径不是目录: ' + normalizedRoot } };
142
+ }
143
+ const ignoreDirs = new Set([
144
+ 'node_modules', '.git', '.dsh', '.gemini', '.tempmediastorage', '.idea', '.vscode',
145
+ 'dist', 'build', 'coverage', '.next', '.nuxt', '.output', '.turbo', '.cache',
146
+ '__pycache__', '.pytest_cache', 'target', 'vendor'
147
+ ]);
148
+ let totalFiles = 0;
149
+ let totalDirs = 0;
150
+
151
+ const buildTree = (dirPath, currentDepth) => {
152
+ if (currentDepth > maxDepth) return [];
153
+ let entries = [];
154
+ try {
155
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
156
+ } catch (e) {
157
+ return [];
158
+ }
159
+ const nodes = [];
160
+ for (const entry of entries) {
161
+ const entryName = entry.name;
162
+ const lowerName = entryName.toLowerCase();
163
+ if (!includeAll) {
164
+ if (ignoreDirs.has(lowerName)) continue;
165
+ if (entryName.startsWith('.') && entryName !== '.env' && entryName !== '.gitignore') continue;
166
+ }
167
+ const fullPath = path.join(dirPath, entryName);
168
+ const relPath = path.relative(normalizedRoot, fullPath).replace(/\\/g, '/');
169
+ try {
170
+ if (entry.isDirectory()) {
171
+ totalDirs++;
172
+ const children = buildTree(fullPath, currentDepth + 1);
173
+ nodes.push({
174
+ name: entryName,
175
+ path: fullPath,
176
+ relativePath: relPath,
177
+ isDir: true,
178
+ children,
179
+ childCount: children.length,
180
+ });
181
+ } else if (entry.isFile()) {
182
+ totalFiles++;
183
+ const ext = path.extname(entryName).toLowerCase();
184
+ let size = 0;
185
+ let mtime = 0;
186
+ try {
187
+ const s = fs.statSync(fullPath);
188
+ size = s.size;
189
+ mtime = s.mtimeMs;
190
+ } catch (e) {}
191
+ nodes.push({
192
+ name: entryName,
193
+ path: fullPath,
194
+ relativePath: relPath,
195
+ isDir: false,
196
+ extension: ext,
197
+ size,
198
+ mtime,
199
+ });
200
+ }
201
+ } catch (e) {}
202
+ }
203
+ nodes.sort((a, b) => {
204
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
205
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
206
+ });
207
+ return nodes;
208
+ };
209
+
210
+ const tree = buildTree(normalizedRoot, 1);
211
+ const workspaceName = path.basename(normalizedRoot) || normalizedRoot;
212
+ return {
213
+ ok: true,
214
+ workspaceRoot: normalizedRoot,
215
+ workspaceName,
216
+ tree,
217
+ totalFiles,
218
+ totalDirs,
219
+ };
220
+ } catch (err) {
221
+ return { ok: false, error: { code: 'SCAN_ERROR', message: err.message || '扫描工作区文件树失败' } };
222
+ }
223
+ }
224
+
225
+ listDirectoryEntries(targetDir, includeAll = false) {
226
+ try {
227
+ const normalized = path.resolve(targetDir);
228
+ if (!fs.existsSync(normalized) || !fs.statSync(normalized).isDirectory()) {
229
+ return { ok: false, error: { code: 'DIR_NOT_FOUND', message: '目录不存在' } };
230
+ }
231
+ const ignoreDirs = new Set(['node_modules', '.git', '.dsh', '.gemini', '.tempmediastorage', '.idea', '.vscode']);
232
+ const rawEntries = fs.readdirSync(normalized, { withFileTypes: true });
233
+ const nodes = [];
234
+ for (const entry of rawEntries) {
235
+ const name = entry.name;
236
+ if (!includeAll) {
237
+ if (ignoreDirs.has(name.toLowerCase())) continue;
238
+ if (name.startsWith('.') && name !== '.env' && name !== '.gitignore') continue;
239
+ }
240
+ const fullPath = path.join(normalized, name);
241
+ const isDir = entry.isDirectory();
242
+ let size = 0;
243
+ let mtime = 0;
244
+ let ext = '';
245
+ if (!isDir) {
246
+ ext = path.extname(name).toLowerCase();
247
+ try {
248
+ const s = fs.statSync(fullPath);
249
+ size = s.size;
250
+ mtime = s.mtimeMs;
251
+ } catch (e) {}
252
+ }
253
+ nodes.push({
254
+ name,
255
+ path: fullPath,
256
+ relativePath: name,
257
+ isDir,
258
+ extension: ext,
259
+ size,
260
+ mtime,
261
+ });
262
+ }
263
+ nodes.sort((a, b) => {
264
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
265
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
266
+ });
267
+ return { ok: true, entries: nodes };
268
+ } catch (err) {
269
+ return { ok: false, error: { code: 'LIST_ERROR', message: err.message || '读取目录失败' } };
270
+ }
271
+ }
272
+
133
273
  async handleHttpRequest(req, res) {
134
274
  this.setCorsHeaders(res);
135
275
  if (req.method === 'OPTIONS') {
@@ -144,7 +284,7 @@ export class PreviewService extends Service {
144
284
  try {
145
285
  if (pathname === '/api/preview/status') {
146
286
  res.statusCode = 200;
147
- res.end(JSON.stringify({ ok: true, version: '0.1.0', service: 'preview' }));
287
+ res.end(JSON.stringify({ ok: true, version: '0.1.3', service: 'preview' }));
148
288
  return;
149
289
  }
150
290
  if (pathname === '/api/preview/read' && req.method === 'GET') {
@@ -159,6 +299,37 @@ export class PreviewService extends Service {
159
299
  res.end(JSON.stringify(result));
160
300
  return;
161
301
  }
302
+ if (pathname === '/api/preview/workspace-tree' && req.method === 'GET') {
303
+ const targetPath = url.searchParams.get('path') || process.cwd();
304
+ const depth = parseInt(url.searchParams.get('depth') || '4', 10);
305
+ const includeAll = url.searchParams.get('includeAll') === 'true';
306
+ const result = this.readWorkspaceTree(targetPath, isNaN(depth) ? 4 : depth, includeAll);
307
+ res.statusCode = result.ok ? 200 : 404;
308
+ res.end(JSON.stringify(result));
309
+ return;
310
+ }
311
+ if (pathname === '/api/preview/list-dir' && req.method === 'GET') {
312
+ const targetPath = url.searchParams.get('path') || process.cwd();
313
+ const includeAll = url.searchParams.get('includeAll') === 'true';
314
+ const result = this.listDirectoryEntries(targetPath, includeAll);
315
+ res.statusCode = result.ok ? 200 : 404;
316
+ res.end(JSON.stringify(result));
317
+ return;
318
+ }
319
+ if (pathname === '/api/preview/save' && req.method === 'POST') {
320
+ const body = await this.parseRequestBody(req);
321
+ if (!body.path || typeof body.content !== 'string') {
322
+ res.statusCode = 400;
323
+ res.end(JSON.stringify({ ok: false, error: { code: 'INVALID_PARAMS', message: '参数格式错误' } }));
324
+ return;
325
+ }
326
+ const normalized = path.resolve(body.path);
327
+ fs.writeFileSync(normalized, body.content, 'utf-8');
328
+ const stat = fs.statSync(normalized);
329
+ res.statusCode = 200;
330
+ res.end(JSON.stringify({ ok: true, size: stat.size, mtime: stat.mtimeMs }));
331
+ return;
332
+ }
162
333
  if (pathname === '/api/preview/reveal' && req.method === 'POST') {
163
334
  const body = await this.parseRequestBody(req);
164
335
  if (!body.path) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rooode/dsh-plugin-preview",
3
- "version": "0.1.2",
4
- "description": "DeepSeek Harness Markdown 文档右侧预览插件 (参考 WorkBuddy FileTabs 实现,支持多标签、GFM 渲染、TOC 大纲、源码分栏)",
3
+ "version": "0.1.3",
4
+ "description": "DeepSeek Harness Markdown 文档与工作空间文件浏览器右侧预览插件 (参考 WorkBuddy FileTabs 实现,支持工作区文件树、多标签、GFM 渲染、TOC 大纲、源码分栏)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",