@rooode/dsh-plugin-preview 0.1.1 → 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 +931 -45
  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,10 @@
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;
50
+ let globalLastOpenTime = 0;
43
51
  const stateListeners = new Set();
44
52
 
45
53
  try {
@@ -52,6 +60,23 @@
52
60
  }
53
61
  } catch (e) {}
54
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
+
55
80
  try {
56
81
  const savedTabs = localStorage.getItem(STORAGE_KEY_TABS);
57
82
  if (savedTabs) {
@@ -83,8 +108,27 @@
83
108
  notifyStateChange();
84
109
  }
85
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
+
86
127
  function setPanelOpen(open) {
87
128
  globalIsPanelOpen = open;
129
+ if (open) {
130
+ globalLastOpenTime = Date.now();
131
+ }
88
132
  notifyStateChange();
89
133
  }
90
134
 
@@ -108,6 +152,61 @@
108
152
  return SUPPORTED_EXTENSIONS.has(ext);
109
153
  }
110
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
+
111
210
  function resolveAbsoluteFilePath(targetPath) {
112
211
  if (!targetPath) return '';
113
212
  let cleanPath = targetPath.trim();
@@ -122,24 +221,7 @@
122
221
  if (isWinAbs || isPosixAbs) {
123
222
  return cleanPath;
124
223
  }
125
- let cwd = '';
126
- if (clientCtx && clientCtx.sessions) {
127
- try {
128
- const snapshot = clientCtx.sessions.list.getSnapshot();
129
- const currentSessionId = snapshot.current;
130
- if (currentSessionId && snapshot.byId[currentSessionId]) {
131
- cwd = snapshot.byId[currentSessionId].cwd || '';
132
- }
133
- } catch (e) {}
134
- }
135
- if (!cwd && clientCtx && clientCtx.workspaces) {
136
- try {
137
- const wsList = clientCtx.workspaces.list.getSnapshot();
138
- if (wsList.items && wsList.items.length > 0) {
139
- cwd = wsList.items[0].directory || '';
140
- }
141
- } catch (e) {}
142
- }
224
+ const cwd = getCurrentWorkspaceRoot();
143
225
  if (cwd) {
144
226
  const isWinCwd = /^[a-zA-Z]:[\\/]/.test(cwd);
145
227
  const sep = isWinCwd ? '\\' : '/';
@@ -179,6 +261,7 @@
179
261
  globalTabs = [newTab, ...globalTabs];
180
262
  globalActiveTabId = tabId;
181
263
  }
264
+ globalLastOpenTime = Date.now();
182
265
  globalIsPanelOpen = true;
183
266
  saveTabsToStorage();
184
267
  notifyStateChange();
@@ -196,7 +279,6 @@
196
279
  globalActiveTabId = nextTabs[nextIdx].id;
197
280
  } else {
198
281
  globalActiveTabId = null;
199
- globalIsPanelOpen = false;
200
282
  }
201
283
  }
202
284
  saveTabsToStorage();
@@ -206,7 +288,6 @@
206
288
  function closeAllTabs() {
207
289
  globalTabs = [];
208
290
  globalActiveTabId = null;
209
- globalIsPanelOpen = false;
210
291
  saveTabsToStorage();
211
292
  notifyStateChange();
212
293
  }
@@ -681,10 +762,300 @@
681
762
  };
682
763
  }
683
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
+
684
1055
  // =========================================================================
685
1056
  // React Components: MarkdownPreviewView
686
1057
  // =========================================================================
687
- function MarkdownPreviewView({ tab, fileInfo, isLoading, error, onRefresh, onOpenNative, onReveal }) {
1058
+ function MarkdownPreviewView({ tab, fileInfo, isLoading, error, isTreeOpen, onToggleTree, onRefresh, onOpenNative, onReveal }) {
688
1059
  const [viewMode, setViewMode] = useState(tab.viewMode || 'preview');
689
1060
  const [isTocOpen, setIsTocOpen] = useState(false);
690
1061
  const [searchQuery, setSearchQuery] = useState('');
@@ -735,6 +1106,13 @@
735
1106
  h('div', { className: 'dsh-preview-toolbar' },
736
1107
  // Left: View mode toggles
737
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
+
738
1116
  h('div', { className: 'dsh-preview-mode-group' },
739
1117
  h('button', {
740
1118
  type: 'button',
@@ -928,6 +1306,11 @@
928
1306
  const [activeTabId, setActiveTabId] = useState(globalActiveTabId);
929
1307
  const [isOpen, setIsOpen] = useState(globalIsPanelOpen);
930
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);
931
1314
  const [fileData, setFileData] = useState({});
932
1315
  const [loadingMap, setLoadingMap] = useState({});
933
1316
  const [errorMap, setErrorMap] = useState({});
@@ -942,6 +1325,8 @@
942
1325
  setActiveTabId(globalActiveTabId);
943
1326
  setIsOpen(globalIsPanelOpen);
944
1327
  setWidth(globalPanelWidth);
1328
+ setLocalIsTreeOpen(globalIsTreeOpen);
1329
+ setLocalTreeWidth(globalTreeWidth);
945
1330
  };
946
1331
  stateListeners.add(update);
947
1332
  return () => stateListeners.delete(update);
@@ -977,7 +1362,7 @@
977
1362
  }
978
1363
  }, [activeTab, loadTabFile]);
979
1364
 
980
- // Drag Resize Handlers
1365
+ // Drag Resize Handlers (Panel Width)
981
1366
  const onResizePointerDown = useCallback((e) => {
982
1367
  e.preventDefault();
983
1368
  e.currentTarget.setPointerCapture(e.pointerId);
@@ -1000,39 +1385,116 @@
1000
1385
  }
1001
1386
  }, [isDragging]);
1002
1387
 
1003
- // Hotkey: Esc closes preview
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
+
1411
+ const drawerRef = useRef(null);
1412
+
1413
+ // Outside Click Listener: Click on the chat conversation / dialog box outside the drawer to auto close/hide it
1414
+ useEffect(() => {
1415
+ if (!isOpen) return;
1416
+
1417
+ const handleOutsidePointerDown = (e) => {
1418
+ // 1. Ignore if just opened within 250ms
1419
+ if (Date.now() - globalLastOpenTime < 250) return;
1420
+ // 2. Ignore during dragging resize handle
1421
+ if (isDragging || isTreeDragging) return;
1422
+
1423
+ const target = e.target;
1424
+ if (!target) return;
1425
+
1426
+ // 3. If clicking inside the preview drawer, do not close
1427
+ const drawerEl = drawerRef.current || document.querySelector('.dsh-preview-drawer-container');
1428
+ if (drawerEl && (drawerEl === target || drawerEl.contains(target))) {
1429
+ return;
1430
+ }
1431
+
1432
+ // 4. If clicking on the floating pill, do not close
1433
+ const floatPill = document.querySelector('.dsh-preview-float-pill');
1434
+ if (floatPill && (floatPill === target || floatPill.contains(target))) {
1435
+ return;
1436
+ }
1437
+
1438
+ // 5. If clicking on a file link or button that opens a file preview, do not close (let interceptor open it)
1439
+ if (target.closest) {
1440
+ const btn = target.closest('button');
1441
+ if (btn) {
1442
+ const candidate = btn.getAttribute('title') || btn.getAttribute('aria-label') || btn.textContent || '';
1443
+ if (isPreviewableFile(candidate.trim())) return;
1444
+ }
1445
+ const anchor = target.closest('a');
1446
+ if (anchor) {
1447
+ const href = anchor.getAttribute('href') || '';
1448
+ if (isPreviewableFile(href)) return;
1449
+ }
1450
+ }
1451
+
1452
+ // 6. Outside click in dialog/conversation/background: Auto hide the preview drawer!
1453
+ setPanelOpen(false);
1454
+ };
1455
+
1456
+ window.addEventListener('pointerdown', handleOutsidePointerDown, true);
1457
+ return () => {
1458
+ window.removeEventListener('pointerdown', handleOutsidePointerDown, true);
1459
+ };
1460
+ }, [isOpen, isDragging, isTreeDragging]);
1461
+
1462
+ // Hotkey: Esc closes preview, Ctrl/Cmd+B toggles file tree
1004
1463
  useEffect(() => {
1005
1464
  const onKeyDown = (e) => {
1006
1465
  if (e.key === 'Escape' && globalIsPanelOpen) {
1007
1466
  setPanelOpen(false);
1008
1467
  }
1468
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'b' || e.key === 'B')) {
1469
+ e.preventDefault();
1470
+ setIsTreeOpen(!globalIsTreeOpen);
1471
+ }
1009
1472
  };
1010
1473
  window.addEventListener('keydown', onKeyDown);
1011
1474
  return () => window.removeEventListener('keydown', onKeyDown);
1012
1475
  }, []);
1013
1476
 
1014
- if (!isOpen && tabs.length === 0) return null;
1015
-
1016
1477
  const activeKey = activeTab?.filePath?.toLowerCase();
1017
1478
  const currentFileInfo = activeKey ? fileData[activeKey] : null;
1018
1479
  const currentLoading = activeKey ? !!loadingMap[activeKey] : false;
1019
1480
  const currentError = activeKey ? errorMap[activeKey] : null;
1020
1481
 
1021
1482
  return h(Fragment, null,
1022
- // Floating Mini Button to Reopen (when closed but tabs exist)
1023
- !isOpen && tabs.length > 0 ? h('div', {
1483
+ // Floating Mini Button to Reopen (when closed)
1484
+ !isOpen ? h('div', {
1024
1485
  className: 'dsh-preview-float-pill',
1025
1486
  onClick: () => setPanelOpen(true),
1026
- title: `重新展开文档预览 (${tabs.length} 个标签)`,
1487
+ title: tabs.length > 0 ? `展开文档与工作区文件浏览 (${tabs.length} 个标签)` : '浏览工作空间文件树',
1027
1488
  },
1028
- h('span', { className: 'dsh-float-pill-icon' }, '📝'),
1029
- h('span', { className: 'dsh-float-pill-text' }, `预览: ${activeTab?.title || '文档'}`),
1030
- 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
1031
1492
  ) : null,
1032
1493
 
1033
1494
  // Right-Side Slide Drawer
1034
1495
  isOpen ? h('div', {
1035
- className: `dsh-preview-drawer-container ${isDragging ? 'dragging' : ''}`,
1496
+ ref: drawerRef,
1497
+ className: `dsh-preview-drawer-container ${isDragging || isTreeDragging ? 'dragging' : ''}`,
1036
1498
  style: { width: `${width}px` },
1037
1499
  },
1038
1500
  // Drag Handle on Left Border
@@ -1048,7 +1510,7 @@
1048
1510
  h('div', { className: 'dsh-preview-drawer-header' },
1049
1511
  // Tabs Bar
1050
1512
  h('div', { className: 'dsh-preview-tabs-row' },
1051
- tabs.map(tab => {
1513
+ tabs.length > 0 ? tabs.map(tab => {
1052
1514
  const isActive = tab.id === activeTabId;
1053
1515
  const ext = (tab.extension || '').toLowerCase();
1054
1516
  const icon = ext === '.json' ? '{ }' : (ext === '.ts' || ext === '.js' ? 'TS' : '📝');
@@ -1067,11 +1529,20 @@
1067
1529
  title: '关闭此标签',
1068
1530
  }, '✕')
1069
1531
  );
1070
- })
1532
+ }) : h('div', { className: 'dsh-preview-tab-item active' },
1533
+ h('span', { className: 'dsh-tab-icon' }, '📁'),
1534
+ h('span', { className: 'dsh-tab-title' }, '工作空间文件树')
1535
+ )
1071
1536
  ),
1072
1537
 
1073
1538
  // Header Right Window Controls
1074
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
+ }, '🗂️ 目录树'),
1075
1546
  tabs.length > 1 ? h('button', {
1076
1547
  type: 'button',
1077
1548
  className: 'dsh-win-ctrl-btn',
@@ -1087,16 +1558,43 @@
1087
1558
  )
1088
1559
  ),
1089
1560
 
1090
- // Drawer Body
1091
- activeTab ? h(MarkdownPreviewView, {
1092
- tab: activeTab,
1093
- fileInfo: currentFileInfo,
1094
- isLoading: currentLoading,
1095
- error: currentError,
1096
- onRefresh: () => loadTabFile(activeTab, true),
1097
- onOpenNative: triggerOpenNative,
1098
- onReveal: triggerRevealInExplorer,
1099
- }) : 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
+ )
1100
1598
  ) : null
1101
1599
  );
1102
1600
  }
@@ -2162,6 +2660,394 @@
2162
2660
  .dsh-preview-toast-success .dsh-preview-toast-icon { color: #22c55e; font-weight: bold; }
2163
2661
  .dsh-preview-toast-error { border-color: #ef4444; }
2164
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
+ }
2165
3051
  `;
2166
3052
  document.head.appendChild(style);
2167
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.1",
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",