@rooode/dsh-plugin-preview 0.1.10 → 0.1.12

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/lib/client.js CHANGED
@@ -494,6 +494,97 @@
494
494
  }
495
495
  }
496
496
 
497
+ function renameOpenTabPaths(oldPath, newPath, isDir = false) {
498
+ if (!oldPath || !newPath) return;
499
+ let changed = false;
500
+ const oldNorm = oldPath.replace(/\\/g, '/').toLowerCase();
501
+ const newNorm = newPath.replace(/\\/g, '/');
502
+
503
+ globalTabs = globalTabs.map(tab => {
504
+ const tabNorm = tab.filePath.replace(/\\/g, '/').toLowerCase();
505
+ if (!isDir) {
506
+ if (tabNorm === oldNorm) {
507
+ changed = true;
508
+ const newFileName = getFileName(newPath);
509
+ const newExt = getFileExtension(newPath);
510
+ const newCat = getFileTypeCategory(newPath);
511
+ return {
512
+ ...tab,
513
+ id: 'tab:' + newPath.toLowerCase(),
514
+ filePath: newPath,
515
+ title: newFileName,
516
+ extension: newExt,
517
+ category: newCat,
518
+ };
519
+ }
520
+ } else {
521
+ if (tabNorm === oldNorm || tabNorm.startsWith(oldNorm + '/')) {
522
+ changed = true;
523
+ const subPath = tab.filePath.replace(/\\/g, '/').slice(oldNorm.length);
524
+ const updatedFilePath = newNorm + subPath;
525
+ const newFileName = getFileName(updatedFilePath);
526
+ const newExt = getFileExtension(updatedFilePath);
527
+ const newCat = getFileTypeCategory(updatedFilePath);
528
+ return {
529
+ ...tab,
530
+ id: 'tab:' + updatedFilePath.toLowerCase(),
531
+ filePath: updatedFilePath,
532
+ title: newFileName,
533
+ extension: newExt,
534
+ category: newCat,
535
+ };
536
+ }
537
+ }
538
+ return tab;
539
+ });
540
+
541
+ if (changed) {
542
+ if (globalActiveTabId) {
543
+ const activeNorm = globalActiveTabId.toLowerCase();
544
+ const targetPrefix = 'tab:' + oldNorm;
545
+ if (activeNorm === targetPrefix) {
546
+ globalActiveTabId = 'tab:' + newPath.toLowerCase();
547
+ } else if (isDir && activeNorm.startsWith(targetPrefix + '/')) {
548
+ const sub = activeNorm.slice(targetPrefix.length);
549
+ globalActiveTabId = 'tab:' + newNorm.toLowerCase() + sub;
550
+ }
551
+ }
552
+ saveTabsToStorage();
553
+ notifyStateChange();
554
+ }
555
+ }
556
+
557
+ function removeOpenTabsForPath(targetPath, isDir = false) {
558
+ if (!targetPath) return;
559
+ let changed = false;
560
+ const delNorm = targetPath.replace(/\\/g, '/').toLowerCase();
561
+ const nextTabs = globalTabs.filter(tab => {
562
+ const tabNorm = tab.filePath.replace(/\\/g, '/').toLowerCase();
563
+ if (!isDir) {
564
+ if (tabNorm === delNorm) {
565
+ changed = true;
566
+ return false;
567
+ }
568
+ } else {
569
+ if (tabNorm === delNorm || tabNorm.startsWith(delNorm + '/')) {
570
+ changed = true;
571
+ return false;
572
+ }
573
+ }
574
+ return true;
575
+ });
576
+
577
+ if (changed) {
578
+ globalTabs = nextTabs;
579
+ if (!globalTabs.some(t => t.id === globalActiveTabId)) {
580
+ globalActiveTabId = globalTabs.length > 0 ? globalTabs[0].id : null;
581
+ }
582
+ saveTabsToStorage();
583
+ notifyStateChange();
584
+ }
585
+ }
586
+
587
+
497
588
  // =========================================================================
498
589
  // Toast Notification Utility
499
590
  // =========================================================================
@@ -949,12 +1040,398 @@
949
1040
  }
950
1041
 
951
1042
  // =========================================================================
952
- // React Components: Workspace File Tree Explorer
1043
+ // React Components: Workspace File Tree Explorer (with DnD, Create & Move)
953
1044
  // =========================================================================
954
- function FileTreeNodeItem({ node, depth = 0, expandedPaths, onToggleExpand, activeFilePath, onOpenFile, searchQuery }) {
1045
+
1046
+ function IconNewFile() {
1047
+ return h('svg', {
1048
+ width: 14,
1049
+ height: 14,
1050
+ viewBox: '0 0 16 16',
1051
+ fill: 'none',
1052
+ stroke: 'currentColor',
1053
+ strokeWidth: 1.3,
1054
+ strokeLinecap: 'round',
1055
+ strokeLinejoin: 'round',
1056
+ style: { display: 'block', flexShrink: 0 }
1057
+ },
1058
+ h('path', { d: 'M9 1.5H3.5A1.5 1.5 0 0 0 2 3v10a1.5 1.5 0 0 0 1.5 1.5h9a1.5 1.5 0 0 0 1.5-1.5V6L9 1.5z' }),
1059
+ h('polyline', { points: '9 1.5 9 6 13.5 6' }),
1060
+ h('line', { x1: 5.5, y1: 10, x2: 9.5, y2: 10 }),
1061
+ h('line', { x1: 7.5, y1: 8, x2: 7.5, y2: 12 })
1062
+ );
1063
+ }
1064
+
1065
+ function IconNewFolder() {
1066
+ return h('svg', {
1067
+ width: 14,
1068
+ height: 14,
1069
+ viewBox: '0 0 16 16',
1070
+ fill: 'none',
1071
+ stroke: 'currentColor',
1072
+ strokeWidth: 1.3,
1073
+ strokeLinecap: 'round',
1074
+ strokeLinejoin: 'round',
1075
+ style: { display: 'block', flexShrink: 0 }
1076
+ },
1077
+ h('path', { d: 'M1.5 3.5A1.5 1.5 0 0 1 3 2h3.2l1.4 1.5H13A1.5 1.5 0 0 1 14.5 5v7A1.5 1.5 0 0 1 13 13.5H3A1.5 1.5 0 0 1 1.5 12V3.5z' }),
1078
+ h('line', { x1: 6, y1: 8.5, x2: 10, y2: 8.5 }),
1079
+ h('line', { x1: 8, y1: 6.5, x2: 8, y2: 10.5 })
1080
+ );
1081
+ }
1082
+
1083
+ function IconRefresh() {
1084
+ return h('svg', {
1085
+ width: 13,
1086
+ height: 13,
1087
+ viewBox: '0 0 16 16',
1088
+ fill: 'none',
1089
+ stroke: 'currentColor',
1090
+ strokeWidth: 1.4,
1091
+ strokeLinecap: 'round',
1092
+ strokeLinejoin: 'round',
1093
+ style: { display: 'block', flexShrink: 0 }
1094
+ },
1095
+ h('path', { d: 'M13.5 2.5v3.5h-3.5' }),
1096
+ h('path', { d: 'M2.5 13.5v-3.5h3.5' }),
1097
+ h('path', { d: 'M3.5 6a5.5 5.5 0 0 1 8.8-1.5l1.2 1.5' }),
1098
+ h('path', { d: 'M12.5 10a5.5 5.5 0 0 1-8.8 1.5l-1.2-1.5' })
1099
+ );
1100
+ }
1101
+
1102
+ function IconExpandAll() {
1103
+ return h('svg', {
1104
+ width: 13,
1105
+ height: 13,
1106
+ viewBox: '0 0 16 16',
1107
+ fill: 'none',
1108
+ stroke: 'currentColor',
1109
+ strokeWidth: 1.4,
1110
+ strokeLinecap: 'round',
1111
+ strokeLinejoin: 'round',
1112
+ style: { display: 'block', flexShrink: 0 }
1113
+ },
1114
+ h('polyline', { points: '3.5 5.5 8 2 12.5 5.5' }),
1115
+ h('line', { x1: 2, y1: 8, x2: 14, y2: 8 }),
1116
+ h('polyline', { points: '3.5 10.5 8 14 12.5 10.5' })
1117
+ );
1118
+ }
1119
+
1120
+ function IconCollapseAll() {
1121
+ return h('svg', {
1122
+ width: 13,
1123
+ height: 13,
1124
+ viewBox: '0 0 16 16',
1125
+ fill: 'none',
1126
+ stroke: 'currentColor',
1127
+ strokeWidth: 1.4,
1128
+ strokeLinecap: 'round',
1129
+ strokeLinejoin: 'round',
1130
+ style: { display: 'block', flexShrink: 0 }
1131
+ },
1132
+ h('polyline', { points: '3.5 3 8 6.5 12.5 3' }),
1133
+ h('line', { x1: 2, y1: 8, x2: 14, y2: 8 }),
1134
+ h('polyline', { points: '3.5 13 8 9.5 12.5 13' })
1135
+ );
1136
+ }
1137
+
1138
+ function IconChevronRight() {
1139
+ return h('svg', {
1140
+ width: 9,
1141
+ height: 9,
1142
+ viewBox: '0 0 16 16',
1143
+ fill: 'none',
1144
+ stroke: 'currentColor',
1145
+ strokeWidth: 2.2,
1146
+ strokeLinecap: 'round',
1147
+ strokeLinejoin: 'round',
1148
+ style: { display: 'block' }
1149
+ }, h('polyline', { points: '5 3 10 8 5 13' }));
1150
+ }
1151
+
1152
+ function IconChevronDown() {
1153
+ return h('svg', {
1154
+ width: 9,
1155
+ height: 9,
1156
+ viewBox: '0 0 16 16',
1157
+ fill: 'none',
1158
+ stroke: 'currentColor',
1159
+ strokeWidth: 2.2,
1160
+ strokeLinecap: 'round',
1161
+ strokeLinejoin: 'round',
1162
+ style: { display: 'block' }
1163
+ }, h('polyline', { points: '3 5 8 10 13 5' }));
1164
+ }
1165
+
1166
+ function IconClose() {
1167
+ return h('svg', {
1168
+ width: 12,
1169
+ height: 12,
1170
+ viewBox: '0 0 16 16',
1171
+ fill: 'none',
1172
+ stroke: 'currentColor',
1173
+ strokeWidth: 1.6,
1174
+ strokeLinecap: 'round',
1175
+ style: { display: 'block', flexShrink: 0 }
1176
+ },
1177
+ h('line', { x1: 3, y1: 3, x2: 13, y2: 13 }),
1178
+ h('line', { x1: 13, y1: 3, x2: 3, y2: 13 })
1179
+ );
1180
+ }
1181
+
1182
+ function isValidDropTarget(draggedItem, targetNode) {
1183
+ if (!draggedItem || !targetNode) return false;
1184
+ if (!targetNode.isDir) return false;
1185
+ const src = draggedItem.path.replace(/\\/g, '/').toLowerCase();
1186
+ const dest = targetNode.path.replace(/\\/g, '/').toLowerCase();
1187
+ // Cannot drop on itself
1188
+ if (src === dest) return false;
1189
+ // Cannot drop into current parent directory (already there)
1190
+ const lastSlash = src.lastIndexOf('/');
1191
+ const srcParent = lastSlash > 0 ? src.substring(0, lastSlash) : '';
1192
+ if (srcParent && srcParent === dest) return false;
1193
+ // If dragged is a dir, cannot drop into its own children or itself
1194
+ if (draggedItem.isDir) {
1195
+ if (dest.startsWith(src + '/')) return false;
1196
+ }
1197
+ return true;
1198
+ }
1199
+
1200
+ function InlineCreateInputNode({ depth = 0, type, onConfirm, onCancel }) {
1201
+ const [val, setVal] = useState('');
1202
+ const inputRef = useRef(null);
1203
+
1204
+ useEffect(() => {
1205
+ if (inputRef.current) {
1206
+ inputRef.current.focus();
1207
+ inputRef.current.select();
1208
+ }
1209
+ }, []);
1210
+
1211
+ const handleKeyDown = (e) => {
1212
+ if (e.key === 'Enter') {
1213
+ e.preventDefault();
1214
+ e.stopPropagation();
1215
+ if (val.trim()) onConfirm(val.trim());
1216
+ else onCancel();
1217
+ } else if (e.key === 'Escape') {
1218
+ e.preventDefault();
1219
+ e.stopPropagation();
1220
+ onCancel();
1221
+ }
1222
+ };
1223
+
1224
+ const handleBlur = () => {
1225
+ if (!val.trim()) onCancel();
1226
+ };
1227
+
1228
+ return h('div', {
1229
+ className: 'dsh-tree-inline-input-row',
1230
+ style: { paddingLeft: `${depth * 14 + 6}px` },
1231
+ onClick: (e) => e.stopPropagation(),
1232
+ },
1233
+ h('span', { className: 'dsh-tree-chevron-placeholder' }),
1234
+ h('span', {
1235
+ className: 'dsh-tree-icon',
1236
+ style: { color: type === 'dir' ? '#eab308' : '#38bdf8' },
1237
+ }, type === 'dir' ? '📁' : '📄'),
1238
+ h('input', {
1239
+ ref: inputRef,
1240
+ type: 'text',
1241
+ className: 'dsh-tree-inline-input',
1242
+ value: val,
1243
+ placeholder: type === 'dir' ? '新文件夹名称...' : '新文件名 (如 doc.md)...',
1244
+ onChange: (e) => setVal(e.target.value),
1245
+ onKeyDown: handleKeyDown,
1246
+ onBlur: handleBlur,
1247
+ })
1248
+ );
1249
+ }
1250
+
1251
+ function InlineRenameInputNode({ node, depth = 0, onConfirm, onCancel }) {
1252
+ const [val, setVal] = useState(node.name);
1253
+ const inputRef = useRef(null);
1254
+
1255
+ useEffect(() => {
1256
+ if (inputRef.current) {
1257
+ inputRef.current.focus();
1258
+ if (!node.isDir && node.name.includes('.')) {
1259
+ const lastDot = node.name.lastIndexOf('.');
1260
+ if (lastDot > 0) {
1261
+ inputRef.current.setSelectionRange(0, lastDot);
1262
+ return;
1263
+ }
1264
+ }
1265
+ inputRef.current.select();
1266
+ }
1267
+ }, [node]);
1268
+
1269
+ const handleKeyDown = (e) => {
1270
+ if (e.key === 'Enter') {
1271
+ e.preventDefault();
1272
+ e.stopPropagation();
1273
+ if (val.trim() && val.trim() !== node.name) {
1274
+ onConfirm(val.trim());
1275
+ } else {
1276
+ onCancel();
1277
+ }
1278
+ } else if (e.key === 'Escape') {
1279
+ e.preventDefault();
1280
+ e.stopPropagation();
1281
+ onCancel();
1282
+ }
1283
+ };
1284
+
1285
+ const handleBlur = () => {
1286
+ onCancel();
1287
+ };
1288
+
1289
+ const iconInfo = getFileIcon(node);
1290
+
1291
+ return h('div', {
1292
+ className: 'dsh-tree-inline-input-row',
1293
+ style: { paddingLeft: `${depth * 14 + 6}px` },
1294
+ onClick: (e) => e.stopPropagation(),
1295
+ },
1296
+ h('span', { className: 'dsh-tree-chevron-placeholder' }),
1297
+ h('span', {
1298
+ className: 'dsh-tree-icon',
1299
+ style: { color: node.isDir ? '#eab308' : iconInfo.color },
1300
+ }, node.isDir ? '📁' : iconInfo.icon),
1301
+ h('input', {
1302
+ ref: inputRef,
1303
+ type: 'text',
1304
+ className: 'dsh-tree-inline-input',
1305
+ value: val,
1306
+ onChange: (e) => setVal(e.target.value),
1307
+ onKeyDown: handleKeyDown,
1308
+ onBlur: handleBlur,
1309
+ })
1310
+ );
1311
+ }
1312
+
1313
+ function TreeContextMenu({ x, y, node, isRoot, clipboard, onAction, onClose }) {
1314
+ const menuRef = useRef(null);
1315
+ useEffect(() => {
1316
+ const handleDown = (e) => {
1317
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
1318
+ onClose();
1319
+ }
1320
+ };
1321
+ window.addEventListener('mousedown', handleDown, true);
1322
+ window.addEventListener('scroll', onClose, true);
1323
+ return () => {
1324
+ window.removeEventListener('mousedown', handleDown, true);
1325
+ window.removeEventListener('scroll', onClose, true);
1326
+ };
1327
+ }, [onClose]);
1328
+
1329
+ const style = {
1330
+ position: 'fixed',
1331
+ top: `${Math.min(Math.max(10, y), window.innerHeight - 300)}px`,
1332
+ left: `${Math.min(Math.max(10, x), window.innerWidth - 210)}px`,
1333
+ zIndex: 99999,
1334
+ };
1335
+
1336
+ const isDir = isRoot || (node && node.isDir);
1337
+
1338
+ return h('div', { ref: menuRef, className: 'dsh-tree-context-menu', style },
1339
+ isDir ? [
1340
+ h('div', { key: 'new-file', className: 'dsh-menu-item', onClick: () => { onClose(); onAction('new-file', node); } },
1341
+ h('span', { className: 'dsh-menu-icon' }, '📄'),
1342
+ h('span', null, '新建文件')
1343
+ ),
1344
+ h('div', { key: 'new-dir', className: 'dsh-menu-item', onClick: () => { onClose(); onAction('new-dir', node); } },
1345
+ h('span', { className: 'dsh-menu-icon' }, '📁'),
1346
+ h('span', null, '新建文件夹')
1347
+ ),
1348
+ h('div', { key: 'div-1', className: 'dsh-menu-divider' }),
1349
+ ] : null,
1350
+
1351
+ !isRoot && !isDir ? h('div', { key: 'preview', className: 'dsh-menu-item', onClick: () => { onClose(); onAction('preview', node); } },
1352
+ h('span', { className: 'dsh-menu-icon' }, '📖'),
1353
+ h('span', null, '打开预览')
1354
+ ) : null,
1355
+
1356
+ !isRoot ? h('div', { key: 'cut', className: 'dsh-menu-item', onClick: () => { onClose(); onAction('cut', node); } },
1357
+ h('span', { className: 'dsh-menu-icon' }, '✂️'),
1358
+ h('span', null, '剪切')
1359
+ ) : null,
1360
+
1361
+ isDir && clipboard && clipboard.action === 'cut' ? h('div', {
1362
+ key: 'paste',
1363
+ className: 'dsh-menu-item',
1364
+ onClick: () => { onClose(); onAction('paste', node); }
1365
+ },
1366
+ h('span', { className: 'dsh-menu-icon' }, '📋'),
1367
+ h('span', null, `粘贴 "${clipboard.name}" 到此处`)
1368
+ ) : null,
1369
+
1370
+ !isRoot ? h('div', { key: 'rename', className: 'dsh-menu-item', onClick: () => { onClose(); onAction('rename', node); } },
1371
+ h('span', { className: 'dsh-menu-icon' }, '🏷️'),
1372
+ h('span', null, '重命名')
1373
+ ) : null,
1374
+
1375
+ !isRoot ? h('div', { key: 'div-2', className: 'dsh-menu-divider' }) : null,
1376
+
1377
+ !isRoot ? h('div', { key: 'reveal', className: 'dsh-menu-item', onClick: () => { onClose(); onAction('reveal', node); } },
1378
+ h('span', { className: 'dsh-menu-icon' }, '📂'),
1379
+ h('span', null, '在文件管理器中定位')
1380
+ ) : null,
1381
+
1382
+ !isRoot && !isDir ? h('div', { key: 'native', className: 'dsh-menu-item', onClick: () => { onClose(); onAction('native', node); } },
1383
+ h('span', { className: 'dsh-menu-icon' }, '🌐'),
1384
+ h('span', null, '系统默认应用打开')
1385
+ ) : null,
1386
+
1387
+ !isRoot ? [
1388
+ h('div', { key: 'div-3', className: 'dsh-menu-divider' }),
1389
+ h('div', { key: 'delete', className: 'dsh-menu-item dsh-menu-item-danger', onClick: () => { onClose(); onAction('delete', node); } },
1390
+ h('span', { className: 'dsh-menu-icon' }, '🗑️'),
1391
+ h('span', null, '删除')
1392
+ )
1393
+ ] : null,
1394
+
1395
+ isRoot ? [
1396
+ h('div', { key: 'refresh', className: 'dsh-menu-item', onClick: () => { onClose(); onAction('refresh'); } },
1397
+ h('span', { className: 'dsh-menu-icon' }, '🔄'),
1398
+ h('span', null, '刷新目录树')
1399
+ )
1400
+ ] : null
1401
+ );
1402
+ }
1403
+
1404
+ function FileTreeNodeItem({
1405
+ node,
1406
+ depth = 0,
1407
+ expandedPaths,
1408
+ onToggleExpand,
1409
+ activeFilePath,
1410
+ onOpenFile,
1411
+ searchQuery,
1412
+ creatingItem,
1413
+ onConfirmCreate,
1414
+ onCancelCreate,
1415
+ renamingPath,
1416
+ onConfirmRename,
1417
+ onCancelRename,
1418
+ onContextMenu,
1419
+ draggedNode,
1420
+ dragOverNode,
1421
+ onDragStart,
1422
+ onDragEnd,
1423
+ onDragOver,
1424
+ onDragLeave,
1425
+ onDrop,
1426
+ clipboard,
1427
+ }) {
955
1428
  const isDir = node.isDir;
956
1429
  const isExpanded = expandedPaths.has(node.path);
957
1430
  const isActive = !isDir && activeFilePath && activeFilePath.toLowerCase() === node.path.toLowerCase();
1431
+ const isRenaming = renamingPath && renamingPath.toLowerCase() === node.path.toLowerCase();
1432
+ const isDragged = draggedNode && draggedNode.path && draggedNode.path.toLowerCase() === node.path.toLowerCase();
1433
+ const isDropTarget = isDir && dragOverNode && dragOverNode.path && dragOverNode.path.toLowerCase() === node.path.toLowerCase();
1434
+ const isCut = clipboard && clipboard.action === 'cut' && clipboard.path && clipboard.path.toLowerCase() === node.path.toLowerCase();
958
1435
  const iconInfo = getFileIcon(node);
959
1436
 
960
1437
  const indentStyle = { paddingLeft: `${depth * 14 + 6}px` };
@@ -968,17 +1445,71 @@
968
1445
  }
969
1446
  };
970
1447
 
1448
+ const handleContextMenu = (e) => {
1449
+ e.preventDefault();
1450
+ e.stopPropagation();
1451
+ onContextMenu(node, e);
1452
+ };
1453
+
1454
+ const handleDragStart = (e) => {
1455
+ e.stopPropagation();
1456
+ e.dataTransfer.setData('text/plain', node.path);
1457
+ e.dataTransfer.effectAllowed = 'move';
1458
+ onDragStart(node);
1459
+ };
1460
+
1461
+ const handleDragOver = (e) => {
1462
+ if (isDir && isValidDropTarget(draggedNode, node)) {
1463
+ e.preventDefault();
1464
+ e.stopPropagation();
1465
+ e.dataTransfer.dropEffect = 'move';
1466
+ onDragOver(node);
1467
+ }
1468
+ };
1469
+
1470
+ const handleDragLeave = (e) => {
1471
+ if (isDir) {
1472
+ e.stopPropagation();
1473
+ onDragLeave(node);
1474
+ }
1475
+ };
1476
+
1477
+ const handleDrop = (e) => {
1478
+ if (isDir && isValidDropTarget(draggedNode, node)) {
1479
+ e.preventDefault();
1480
+ e.stopPropagation();
1481
+ onDrop(draggedNode, node);
1482
+ }
1483
+ };
1484
+
1485
+ if (isRenaming) {
1486
+ return h(InlineRenameInputNode, {
1487
+ node,
1488
+ depth,
1489
+ onConfirm: (newName) => onConfirmRename(node, newName),
1490
+ onCancel: onCancelRename,
1491
+ });
1492
+ }
1493
+
971
1494
  return h(Fragment, null,
972
1495
  h('div', {
973
- className: `dsh-tree-node-row ${isDir ? 'is-dir' : 'is-file'} ${isActive ? 'active' : ''}`,
1496
+ className: `dsh-tree-node-row ${isDir ? 'is-dir' : 'is-file'} ${isActive ? 'active' : ''} ${isDropTarget ? 'drop-target' : ''} ${isDragged ? 'is-dragged' : ''} ${isCut ? 'is-cut' : ''}`,
974
1497
  style: indentStyle,
975
1498
  onClick: handleClick,
1499
+ onContextMenu: handleContextMenu,
1500
+ draggable: true,
1501
+ onDragStart: handleDragStart,
1502
+ onDragEnd: onDragEnd,
1503
+ onDragOver: handleDragOver,
1504
+ onDragLeave: handleDragLeave,
1505
+ onDrop: handleDrop,
976
1506
  title: `${node.name}\n${node.path}${node.size ? ' (' + formatBytes(node.size) + ')' : ''}`,
977
1507
  },
978
1508
  // Chevron indicator for directory
979
1509
  isDir ? h('span', {
980
1510
  className: `dsh-tree-chevron ${isExpanded ? 'expanded' : ''}`,
981
- }, isExpanded ? '▼' : '▶') : h('span', { className: 'dsh-tree-chevron-placeholder' }),
1511
+ onClick: (e) => { e.stopPropagation(); onToggleExpand(node.path); },
1512
+ }, isExpanded ? h(IconChevronDown) : h(IconChevronRight)) : h('span', { className: 'dsh-tree-chevron-placeholder' }),
982
1513
 
983
1514
  // File / Directory Icon
984
1515
  h('span', {
@@ -995,18 +1526,47 @@
995
1526
  )
996
1527
  ),
997
1528
 
998
- // Children if expanded
999
- isDir && isExpanded && node.children && node.children.length > 0 ? (
1000
- node.children.map(child => h(FileTreeNodeItem, {
1001
- key: child.path,
1002
- node: child,
1003
- depth: depth + 1,
1004
- expandedPaths,
1005
- onToggleExpand,
1006
- activeFilePath,
1007
- onOpenFile,
1008
- searchQuery,
1009
- }))
1529
+ // Children or Inline creation node if expanded
1530
+ isDir && isExpanded ? (
1531
+ h(Fragment, null,
1532
+ // If creating inside this directory, render inline creation node first
1533
+ creatingItem && creatingItem.parentPath && creatingItem.parentPath.replace(/\\/g, '/').toLowerCase() === node.path.replace(/\\/g, '/').toLowerCase() ? (
1534
+ h(InlineCreateInputNode, {
1535
+ depth: depth + 1,
1536
+ type: creatingItem.type,
1537
+ onConfirm: onConfirmCreate,
1538
+ onCancel: onCancelCreate,
1539
+ })
1540
+ ) : null,
1541
+
1542
+ node.children && node.children.length > 0 ? (
1543
+ node.children.map(child => h(FileTreeNodeItem, {
1544
+ key: child.path,
1545
+ node: child,
1546
+ depth: depth + 1,
1547
+ expandedPaths,
1548
+ onToggleExpand,
1549
+ activeFilePath,
1550
+ onOpenFile,
1551
+ searchQuery,
1552
+ creatingItem,
1553
+ onConfirmCreate,
1554
+ onCancelCreate,
1555
+ renamingPath,
1556
+ onConfirmRename,
1557
+ onCancelRename,
1558
+ onContextMenu,
1559
+ draggedNode,
1560
+ dragOverNode,
1561
+ onDragStart,
1562
+ onDragEnd,
1563
+ onDragOver,
1564
+ onDragLeave,
1565
+ onDrop,
1566
+ clipboard,
1567
+ }))
1568
+ ) : null
1569
+ )
1010
1570
  ) : null
1011
1571
  );
1012
1572
  }
@@ -1060,6 +1620,15 @@
1060
1620
  const [isSelectorOpen, setIsSelectorOpen] = useState(false);
1061
1621
  const [customPathInput, setCustomPathInput] = useState('');
1062
1622
  const [validating, setValidating] = useState(false);
1623
+
1624
+ // File operations state
1625
+ const [creatingItem, setCreatingItem] = useState(null); // { parentPath: string, type: 'dir' | 'file' } | null
1626
+ const [renamingPath, setRenamingPath] = useState(null); // string | null
1627
+ const [contextMenu, setContextMenu] = useState(null); // { x: number, y: number, node: any, isRoot: boolean } | null
1628
+ const [draggedNode, setDraggedNode] = useState(null);
1629
+ const [dragOverNode, setDragOverNode] = useState(null);
1630
+ const [clipboard, setClipboard] = useState(null); // { action: 'cut', path: string, name: string, isDir: boolean } | null
1631
+
1063
1632
  const currentWs = getCurrentWorkspaceRoot();
1064
1633
 
1065
1634
  const loadTree = useCallback(async (force = false) => {
@@ -1121,6 +1690,17 @@
1121
1690
  }
1122
1691
  }, [searchQuery, treeData]);
1123
1692
 
1693
+ // Listen to external workspace switch events (e.g. from Git plugin)
1694
+ useEffect(() => {
1695
+ const handleWsSwitch = (e) => {
1696
+ if (e.detail?.path && e.detail.path !== globalManualWorkspace) {
1697
+ setManualWorkspace(e.detail.path);
1698
+ }
1699
+ };
1700
+ window.addEventListener('dsh:workspace:switch', handleWsSwitch);
1701
+ return () => window.removeEventListener('dsh:workspace:switch', handleWsSwitch);
1702
+ }, []);
1703
+
1124
1704
  const availableWorkspaces = useMemo(() => {
1125
1705
  return getAllAvailableWorkspaces();
1126
1706
  }, [isSelectorOpen, globalManualWorkspace]);
@@ -1129,6 +1709,9 @@
1129
1709
  setManualWorkspace(wsPath);
1130
1710
  setIsSelectorOpen(false);
1131
1711
  showToast(`已切换工作区: ${getFileName(wsPath)}`, 'success');
1712
+ if (typeof window !== 'undefined') {
1713
+ window.dispatchEvent(new CustomEvent('dsh:workspace:switch', { detail: { path: wsPath } }));
1714
+ }
1132
1715
  };
1133
1716
 
1134
1717
  const handleCustomSubmit = async (e) => {
@@ -1148,6 +1731,9 @@
1148
1731
  setCustomPathInput('');
1149
1732
  setIsSelectorOpen(false);
1150
1733
  showToast(`已切换工作空间: ${json.name}`, 'success');
1734
+ if (typeof window !== 'undefined') {
1735
+ window.dispatchEvent(new CustomEvent('dsh:workspace:switch', { detail: { path: json.path } }));
1736
+ }
1151
1737
  } else {
1152
1738
  showToast(json?.error?.message || '指定目录不存在或不是文件夹', 'error');
1153
1739
  }
@@ -1164,6 +1750,168 @@
1164
1750
  showToast('已恢复根据当前会话自动识别工作空间', 'info');
1165
1751
  };
1166
1752
 
1753
+ // =========================================================================
1754
+ // File Operations Handlers
1755
+ // =========================================================================
1756
+ const handleStartCreate = (parentPath, type) => {
1757
+ const root = treeData?.workspaceRoot || currentWs;
1758
+ const target = parentPath || root;
1759
+ setCreatingItem({ parentPath: target, type });
1760
+ if (target) {
1761
+ setExpandedPaths(prev => new Set(prev).add(target));
1762
+ }
1763
+ };
1764
+
1765
+ const handleConfirmCreate = async (name) => {
1766
+ if (!creatingItem || !name) return;
1767
+ const isDir = creatingItem.type === 'dir';
1768
+ const endpoint = isDir ? '/api/preview/create-dir' : '/api/preview/create-file';
1769
+ try {
1770
+ const res = await fetch(endpoint, {
1771
+ method: 'POST',
1772
+ headers: { 'Content-Type': 'application/json' },
1773
+ body: JSON.stringify({
1774
+ parentPath: creatingItem.parentPath,
1775
+ name: name.trim(),
1776
+ }),
1777
+ });
1778
+ const json = await res.json();
1779
+ if (json.ok) {
1780
+ setCreatingItem(null);
1781
+ setExpandedPaths(prev => new Set(prev).add(creatingItem.parentPath));
1782
+ await loadTree(true);
1783
+ showToast(`已创建${isDir ? '文件夹' : '文件'}: ${json.name}`, 'success');
1784
+ if (!isDir && json.path) {
1785
+ openPreviewFile(json.path);
1786
+ }
1787
+ } else {
1788
+ showToast(json?.error?.message || `创建${isDir ? '文件夹' : '文件'}失败`, 'error');
1789
+ }
1790
+ } catch (err) {
1791
+ showToast(`创建失败: ${err.message}`, 'error');
1792
+ }
1793
+ };
1794
+
1795
+ const handleConfirmRename = async (node, newName) => {
1796
+ if (!node || !newName || newName.trim() === node.name) {
1797
+ setRenamingPath(null);
1798
+ return;
1799
+ }
1800
+ try {
1801
+ const res = await fetch('/api/preview/rename', {
1802
+ method: 'POST',
1803
+ headers: { 'Content-Type': 'application/json' },
1804
+ body: JSON.stringify({
1805
+ path: node.path,
1806
+ newName: newName.trim(),
1807
+ }),
1808
+ });
1809
+ const json = await res.json();
1810
+ if (json.ok) {
1811
+ setRenamingPath(null);
1812
+ renameOpenTabPaths(json.sourcePath, json.targetPath, node.isDir);
1813
+ await loadTree(true);
1814
+ showToast(`已重命名为: ${json.name}`, 'success');
1815
+ } else {
1816
+ showToast(json?.error?.message || '重命名失败', 'error');
1817
+ }
1818
+ } catch (err) {
1819
+ showToast(`重命名失败: ${err.message}`, 'error');
1820
+ }
1821
+ };
1822
+
1823
+ const handleMove = async (sourcePath, targetDirPath) => {
1824
+ if (!sourcePath || !targetDirPath) return;
1825
+ try {
1826
+ const res = await fetch('/api/preview/move', {
1827
+ method: 'POST',
1828
+ headers: { 'Content-Type': 'application/json' },
1829
+ body: JSON.stringify({
1830
+ sourcePath,
1831
+ targetPath: targetDirPath,
1832
+ }),
1833
+ });
1834
+ const json = await res.json();
1835
+ if (json.ok) {
1836
+ if (json.noop) {
1837
+ showToast('目标路径与源路径相同', 'info');
1838
+ return;
1839
+ }
1840
+ renameOpenTabPaths(json.sourcePath, json.targetPath, json.isDir);
1841
+ setExpandedPaths(prev => new Set(prev).add(targetDirPath));
1842
+ await loadTree(true);
1843
+ showToast(`已移动 ${json.name} 到 ${getFileName(targetDirPath)}`, 'success');
1844
+ } else {
1845
+ showToast(json?.error?.message || '移动文件失败', 'error');
1846
+ }
1847
+ } catch (err) {
1848
+ showToast(`移动失败: ${err.message}`, 'error');
1849
+ }
1850
+ };
1851
+
1852
+ const handleDelete = async (node) => {
1853
+ if (!node) return;
1854
+ const isDir = node.isDir;
1855
+ const msg = `确定要永久删除${isDir ? '文件夹' : '文件'} "${node.name}" 吗?此操作不可恢复。`;
1856
+ if (typeof window !== 'undefined' && !window.confirm(msg)) return;
1857
+
1858
+ try {
1859
+ const res = await fetch('/api/preview/delete', {
1860
+ method: 'POST',
1861
+ headers: { 'Content-Type': 'application/json' },
1862
+ body: JSON.stringify({ path: node.path }),
1863
+ });
1864
+ const json = await res.json();
1865
+ if (json.ok) {
1866
+ removeOpenTabsForPath(node.path, isDir);
1867
+ await loadTree(true);
1868
+ showToast(`已删除${isDir ? '文件夹' : '文件'}: ${node.name}`, 'info');
1869
+ } else {
1870
+ showToast(json?.error?.message || '删除失败', 'error');
1871
+ }
1872
+ } catch (err) {
1873
+ showToast(`删除失败: ${err.message}`, 'error');
1874
+ }
1875
+ };
1876
+
1877
+ const handleContextMenuAction = (action, node) => {
1878
+ const rootPath = treeData?.workspaceRoot || currentWs;
1879
+ if (action === 'new-file') {
1880
+ const target = node ? (node.isDir ? node.path : (node.path.substring(0, Math.max(node.path.lastIndexOf('/'), node.path.lastIndexOf('\\'))))) : rootPath;
1881
+ handleStartCreate(target, 'file');
1882
+ } else if (action === 'new-dir') {
1883
+ const target = node ? (node.isDir ? node.path : (node.path.substring(0, Math.max(node.path.lastIndexOf('/'), node.path.lastIndexOf('\\'))))) : rootPath;
1884
+ handleStartCreate(target, 'dir');
1885
+ } else if (action === 'preview') {
1886
+ if (node && !node.isDir) onOpenFile(node.path);
1887
+ } else if (action === 'cut') {
1888
+ if (node) {
1889
+ setClipboard({ action: 'cut', path: node.path, name: node.name, isDir: node.isDir });
1890
+ showToast(`已剪切: ${node.name}`, 'info');
1891
+ }
1892
+ } else if (action === 'paste') {
1893
+ if (clipboard && clipboard.action === 'cut') {
1894
+ const target = node ? (node.isDir ? node.path : (node.path.substring(0, Math.max(node.path.lastIndexOf('/'), node.path.lastIndexOf('\\'))))) : rootPath;
1895
+ handleMove(clipboard.path, target);
1896
+ setClipboard(null);
1897
+ }
1898
+ } else if (action === 'rename') {
1899
+ if (node) setRenamingPath(node.path);
1900
+ } else if (action === 'reveal') {
1901
+ if (node) triggerRevealInExplorer(node.path);
1902
+ } else if (action === 'native') {
1903
+ if (node) triggerOpenNative(node.path);
1904
+ } else if (action === 'delete') {
1905
+ if (node) handleDelete(node);
1906
+ } else if (action === 'refresh') {
1907
+ loadTree(true);
1908
+ }
1909
+ };
1910
+
1911
+ const rootPath = treeData?.workspaceRoot || currentWs;
1912
+ const isCreatingAtRoot = creatingItem && creatingItem.parentPath && rootPath && creatingItem.parentPath.replace(/\\/g, '/').toLowerCase() === rootPath.replace(/\\/g, '/').toLowerCase();
1913
+ const isRootDropTarget = dragOverNode && rootPath && dragOverNode.path && dragOverNode.path.replace(/\\/g, '/').toLowerCase() === rootPath.replace(/\\/g, '/').toLowerCase();
1914
+
1167
1915
  return h('div', {
1168
1916
  className: 'dsh-tree-sidebar',
1169
1917
  style: { width: `${width}px` },
@@ -1180,30 +1928,42 @@
1180
1928
  h('span', { className: 'dsh-tree-header-chevron' }, isSelectorOpen ? '▲' : '▼')
1181
1929
  ),
1182
1930
  h('div', { className: 'dsh-tree-header-actions' },
1931
+ h('button', {
1932
+ type: 'button',
1933
+ className: 'dsh-tree-action-btn',
1934
+ onClick: () => handleStartCreate(rootPath, 'file'),
1935
+ title: '新建文件 (在根目录)',
1936
+ }, h(IconNewFile)),
1937
+ h('button', {
1938
+ type: 'button',
1939
+ className: 'dsh-tree-action-btn',
1940
+ onClick: () => handleStartCreate(rootPath, 'dir'),
1941
+ title: '新建文件夹 (在根目录)',
1942
+ }, h(IconNewFolder)),
1183
1943
  h('button', {
1184
1944
  type: 'button',
1185
1945
  className: 'dsh-tree-action-btn',
1186
1946
  onClick: () => loadTree(true),
1187
1947
  title: '刷新文件树',
1188
- }, '🔄'),
1948
+ }, h(IconRefresh)),
1189
1949
  h('button', {
1190
1950
  type: 'button',
1191
1951
  className: 'dsh-tree-action-btn',
1192
1952
  onClick: handleExpandAll,
1193
1953
  title: '全部展开',
1194
- }, '📂'),
1954
+ }, h(IconExpandAll)),
1195
1955
  h('button', {
1196
1956
  type: 'button',
1197
1957
  className: 'dsh-tree-action-btn',
1198
1958
  onClick: handleCollapseAll,
1199
1959
  title: '全部折叠',
1200
- }, '📁'),
1960
+ }, h(IconCollapseAll)),
1201
1961
  onCloseSidebar ? h('button', {
1202
1962
  type: 'button',
1203
1963
  className: 'dsh-tree-action-btn',
1204
1964
  onClick: onCloseSidebar,
1205
1965
  title: '收起目录树 (快捷键 Ctrl/Cmd+B)',
1206
- }, '✕') : null
1966
+ }, h(IconClose)) : null
1207
1967
  )
1208
1968
  ),
1209
1969
 
@@ -1283,7 +2043,40 @@
1283
2043
  ),
1284
2044
 
1285
2045
  // Tree Content
1286
- h('div', { className: 'dsh-tree-node-list' },
2046
+ h('div', {
2047
+ className: `dsh-tree-node-list ${isRootDropTarget ? 'drop-target-root' : ''}`,
2048
+ onContextMenu: (e) => {
2049
+ e.preventDefault();
2050
+ setContextMenu({ x: e.clientX, y: e.clientY, node: null, isRoot: true });
2051
+ },
2052
+ onDragOver: (e) => {
2053
+ if (draggedNode && isValidDropTarget(draggedNode, { path: rootPath, isDir: true })) {
2054
+ e.preventDefault();
2055
+ e.dataTransfer.dropEffect = 'move';
2056
+ setDragOverNode({ path: rootPath, isDir: true });
2057
+ }
2058
+ },
2059
+ onDragLeave: () => {
2060
+ if (dragOverNode && dragOverNode.path === rootPath) {
2061
+ setDragOverNode(null);
2062
+ }
2063
+ },
2064
+ onDrop: (e) => {
2065
+ e.preventDefault();
2066
+ setDragOverNode(null);
2067
+ if (draggedNode && isValidDropTarget(draggedNode, { path: rootPath, isDir: true })) {
2068
+ handleMove(draggedNode.path, rootPath);
2069
+ }
2070
+ },
2071
+ },
2072
+ // If creating at root level, render inline create input at top
2073
+ isCreatingAtRoot ? h(InlineCreateInputNode, {
2074
+ depth: 0,
2075
+ type: creatingItem.type,
2076
+ onConfirm: handleConfirmCreate,
2077
+ onCancel: () => setCreatingItem(null),
2078
+ }) : null,
2079
+
1287
2080
  isLoading ? (
1288
2081
  h('div', { className: 'dsh-tree-loading' }, '正在扫描工作区文件...')
1289
2082
  ) : error ? (
@@ -1295,7 +2088,7 @@
1295
2088
  onClick: () => loadTree(true),
1296
2089
  }, '重试')
1297
2090
  )
1298
- ) : displayTree.length === 0 ? (
2091
+ ) : displayTree.length === 0 && !isCreatingAtRoot ? (
1299
2092
  h('div', { className: 'dsh-tree-empty' }, searchQuery ? '未匹配到相关文件' : '工作区暂无文件')
1300
2093
  ) : (
1301
2094
  displayTree.map(node => h(FileTreeNodeItem, {
@@ -1307,10 +2100,42 @@
1307
2100
  activeFilePath,
1308
2101
  onOpenFile,
1309
2102
  searchQuery,
2103
+ creatingItem,
2104
+ onConfirmCreate: handleConfirmCreate,
2105
+ onCancelCreate: () => setCreatingItem(null),
2106
+ renamingPath,
2107
+ onConfirmRename: handleConfirmRename,
2108
+ onCancelRename: () => setRenamingPath(null),
2109
+ onContextMenu: (targetNode, e) => {
2110
+ setContextMenu({ x: e.clientX, y: e.clientY, node: targetNode, isRoot: false });
2111
+ },
2112
+ draggedNode,
2113
+ dragOverNode,
2114
+ onDragStart: (targetNode) => setDraggedNode(targetNode),
2115
+ onDragEnd: () => { setDraggedNode(null); setDragOverNode(null); },
2116
+ onDragOver: (targetNode) => setDragOverNode(targetNode),
2117
+ onDragLeave: () => setDragOverNode(null),
2118
+ onDrop: (source, target) => {
2119
+ setDraggedNode(null);
2120
+ setDragOverNode(null);
2121
+ handleMove(source.path, target.path);
2122
+ },
2123
+ clipboard,
1310
2124
  }))
1311
2125
  )
1312
2126
  ),
1313
2127
 
2128
+ // Context Menu Overlay
2129
+ contextMenu ? h(TreeContextMenu, {
2130
+ x: contextMenu.x,
2131
+ y: contextMenu.y,
2132
+ node: contextMenu.node,
2133
+ isRoot: contextMenu.isRoot,
2134
+ clipboard,
2135
+ onAction: handleContextMenuAction,
2136
+ onClose: () => setContextMenu(null),
2137
+ }) : null,
2138
+
1314
2139
  // Sidebar Footer with file count stats
1315
2140
  treeData ? h('div', { className: 'dsh-tree-sidebar-footer' },
1316
2141
  h('span', null, `${treeData.totalDirs || 0} 目录`),
@@ -2242,22 +3067,62 @@
2242
3067
  }
2243
3068
 
2244
3069
  // =========================================================================
2245
- // React Component: CodeEditorView
3070
+ // React Component: CodeEditorView (Unified line-row architecture)
2246
3071
  // =========================================================================
2247
- function CodeEditorView({ content, category, isHighlighted = true, isWordWrap = true, searchQuery = '', onLineClick }) {
2248
- const lines = useMemo(() => (content ? content.split('\n') : []), [content]);
3072
+ function splitHighlightedHtmlIntoLines(html) {
3073
+ if (!html) return [];
3074
+ const rawLines = html.split('\n');
3075
+ const lines = [];
3076
+ const openTags = [];
3077
+
3078
+ for (let i = 0; i < rawLines.length; i++) {
3079
+ let line = rawLines[i];
3080
+ let prefix = '';
3081
+ if (openTags.length > 0) {
3082
+ prefix = openTags.map(t => '<' + t.name + (t.attrs ? ' ' + t.attrs : '') + '>').join('');
3083
+ }
3084
+
3085
+ const tagRegex = /<\/?([a-zA-Z0-9\-]+)([^>]*)>/g;
3086
+ let match;
3087
+ while ((match = tagRegex.exec(line)) !== null) {
3088
+ const isClosing = match[0].startsWith('</');
3089
+ const tagName = match[1];
3090
+ const attrs = match[2];
3091
+ if (isClosing) {
3092
+ const lastIdx = openTags.map(t => t.name).lastIndexOf(tagName);
3093
+ if (lastIdx !== -1) {
3094
+ openTags.splice(lastIdx, 1);
3095
+ }
3096
+ } else if (!match[0].endsWith('/>')) {
3097
+ openTags.push({ name: tagName, attrs: attrs.trim() });
3098
+ }
3099
+ }
3100
+
3101
+ let suffix = '';
3102
+ if (openTags.length > 0) {
3103
+ suffix = openTags.slice().reverse().map(t => '</' + t.name + '>').join('');
3104
+ }
2249
3105
 
2250
- const highlightedHtml = useMemo(() => {
3106
+ lines.push(prefix + line + suffix);
3107
+ }
3108
+
3109
+ return lines;
3110
+ }
3111
+
3112
+ function CodeEditorView({ content, category, isHighlighted = true, isWordWrap = true, searchQuery = '', onLineClick }) {
3113
+ const normalizedContent = useMemo(() => {
2251
3114
  if (!content) return '';
3115
+ return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
3116
+ }, [content]);
3117
+
3118
+ const highlightedLines = useMemo(() => {
3119
+ if (!normalizedContent) return [];
3120
+ let html = '';
2252
3121
  if (!isHighlighted) {
2253
- let escaped = escapeHtml(content);
2254
- if (searchQuery.trim()) {
2255
- const q = escapeHtml(searchQuery.trim());
2256
- escaped = escaped.replace(new RegExp(`(${q})`, 'gi'), '<mark class="dsh-search-highlight">$1</mark>');
2257
- }
2258
- return escaped;
3122
+ html = escapeHtml(normalizedContent);
3123
+ } else {
3124
+ html = highlightCode(normalizedContent, category);
2259
3125
  }
2260
- let html = highlightCode(content, category);
2261
3126
  if (searchQuery.trim()) {
2262
3127
  try {
2263
3128
  const q = escapeHtml(searchQuery.trim());
@@ -2265,27 +3130,34 @@
2265
3130
  html = html.replace(regex, '<mark class="dsh-search-highlight">$1</mark>');
2266
3131
  } catch (e) {}
2267
3132
  }
2268
- return html;
2269
- }, [content, category, isHighlighted, searchQuery]);
3133
+ return splitHighlightedHtmlIntoLines(html);
3134
+ }, [normalizedContent, category, isHighlighted, searchQuery]);
3135
+
3136
+ const lineCount = highlightedLines.length;
3137
+ const digitCount = Math.max(2, String(lineCount).length);
3138
+ const gutterWidth = Math.max(24, digitCount * 7.5 + 12);
2270
3139
 
2271
3140
  return h('div', { className: `dsh-code-editor-view ${isWordWrap ? 'word-wrap' : ''}` },
2272
- h('div', { className: 'dsh-code-gutter' },
2273
- lines.map((_, i) => h('div', {
2274
- key: i,
2275
- id: `line-gutter-${i + 1}`,
2276
- className: 'dsh-gutter-line',
2277
- onClick: () => onLineClick && onLineClick(i + 1),
2278
- title: `第 ${i + 1} 行 (点击定位)`
2279
- }, i + 1))
2280
- ),
2281
- h('div', { className: 'dsh-code-content' },
2282
- h('pre', { className: 'dsh-code-pre' },
2283
- h('code', {
2284
- className: `dsh-code-lang-${category}`,
2285
- dangerouslySetInnerHTML: { __html: highlightedHtml }
3141
+ highlightedLines.map((lineHtml, i) => {
3142
+ const lineNum = i + 1;
3143
+ return h('div', {
3144
+ key: lineNum,
3145
+ id: `line-row-${lineNum}`,
3146
+ className: 'dsh-code-line',
3147
+ },
3148
+ h('span', {
3149
+ id: `line-gutter-${lineNum}`,
3150
+ className: 'dsh-line-gutter',
3151
+ style: { minWidth: `${gutterWidth}px` },
3152
+ onClick: () => onLineClick && onLineClick(lineNum),
3153
+ title: `第 ${lineNum} 行 (点击定位)`,
3154
+ }, lineNum),
3155
+ h('span', {
3156
+ className: 'dsh-line-code',
3157
+ dangerouslySetInnerHTML: { __html: lineHtml ? lineHtml : ' ' },
2286
3158
  })
2287
- )
2288
- )
3159
+ );
3160
+ })
2289
3161
  );
2290
3162
  }
2291
3163
 
@@ -2387,7 +3259,9 @@
2387
3259
  const el = contentRef.current.querySelector(`#${item.id}`);
2388
3260
  if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
2389
3261
  } else {
2390
- const el = document.getElementById(`line-gutter-${item.line}`);
3262
+ const rowEl = document.getElementById(`line-row-${item.line}`);
3263
+ const gutterEl = document.getElementById(`line-gutter-${item.line}`);
3264
+ const el = rowEl || gutterEl;
2391
3265
  if (el) {
2392
3266
  el.scrollIntoView({ behavior: 'smooth', block: 'center' });
2393
3267
  el.classList.add('dsh-line-highlight-flash');
@@ -4505,6 +5379,7 @@
4505
5379
  align-items: center;
4506
5380
  gap: 2px;
4507
5381
  flex-shrink: 0;
5382
+ white-space: nowrap;
4508
5383
  }
4509
5384
 
4510
5385
  .dsh-tree-action-btn {
@@ -4518,16 +5393,25 @@
4518
5393
  display: inline-flex;
4519
5394
  align-items: center;
4520
5395
  justify-content: center;
4521
- font-size: 11px;
4522
5396
  padding: 0;
4523
- transition: all 0.1s;
5397
+ flex-shrink: 0;
5398
+ white-space: nowrap;
5399
+ transition: background 0.12s ease, color 0.12s ease;
5400
+ }
5401
+ body[data-ds-dark-theme] .dsh-tree-action-btn {
5402
+ color: #94a3b8;
5403
+ }
5404
+ .dsh-tree-action-btn svg {
5405
+ display: block;
5406
+ flex-shrink: 0;
5407
+ pointer-events: none;
4524
5408
  }
4525
5409
  .dsh-tree-action-btn:hover {
4526
- background: rgba(0, 0, 0, 0.06);
5410
+ background: var(--dsw-alias-bg-hover, rgba(0, 0, 0, 0.07));
4527
5411
  color: var(--dsw-alias-label-primary, #0f172a);
4528
5412
  }
4529
5413
  body[data-ds-dark-theme] .dsh-tree-action-btn:hover {
4530
- background: rgba(255, 255, 255, 0.08);
5414
+ background: rgba(255, 255, 255, 0.1);
4531
5415
  color: #ffffff;
4532
5416
  }
4533
5417
 
@@ -4741,6 +5625,124 @@
4741
5625
  }
4742
5626
  .dsh-tree-footer-dot { font-size: 8px; }
4743
5627
 
5628
+ /* Drag & Drop & Cut Styles */
5629
+ .dsh-tree-node-row.drop-target {
5630
+ background: rgba(14, 165, 233, 0.22) !important;
5631
+ outline: 1px dashed #0284c7 !important;
5632
+ color: #0284c7 !important;
5633
+ }
5634
+ body[data-ds-dark-theme] .dsh-tree-node-row.drop-target {
5635
+ background: rgba(56, 189, 248, 0.25) !important;
5636
+ outline-color: #38bdf8 !important;
5637
+ color: #38bdf8 !important;
5638
+ }
5639
+ .dsh-tree-node-list.drop-target-root {
5640
+ background: rgba(14, 165, 233, 0.08) !important;
5641
+ outline: 2px dashed #0284c7 !important;
5642
+ outline-offset: -4px;
5643
+ }
5644
+ body[data-ds-dark-theme] .dsh-tree-node-list.drop-target-root {
5645
+ background: rgba(56, 189, 248, 0.1) !important;
5646
+ outline-color: #38bdf8 !important;
5647
+ }
5648
+ .dsh-tree-node-row.is-dragged {
5649
+ opacity: 0.45;
5650
+ filter: grayscale(0.6);
5651
+ }
5652
+ .dsh-tree-node-row.is-cut {
5653
+ opacity: 0.5;
5654
+ filter: grayscale(0.5);
5655
+ border: 1px dashed #94a3b8;
5656
+ }
5657
+
5658
+ /* Inline Creation & Rename Input */
5659
+ .dsh-tree-inline-input-row {
5660
+ display: flex;
5661
+ align-items: center;
5662
+ height: 24px;
5663
+ margin: 1px 4px;
5664
+ gap: 3px;
5665
+ box-sizing: border-box;
5666
+ }
5667
+ .dsh-tree-inline-input {
5668
+ flex: 1;
5669
+ height: 20px;
5670
+ border-radius: 3px;
5671
+ border: 1px solid #38bdf8;
5672
+ background: var(--dsw-alias-bg-base, #ffffff);
5673
+ color: inherit;
5674
+ font-size: 11px;
5675
+ padding: 0 4px;
5676
+ outline: none;
5677
+ box-sizing: border-box;
5678
+ box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.4);
5679
+ }
5680
+ body[data-ds-dark-theme] .dsh-tree-inline-input {
5681
+ background: #1e2330;
5682
+ color: #f1f5f9;
5683
+ border-color: #38bdf8;
5684
+ }
5685
+
5686
+ /* Tree Context Menu */
5687
+ .dsh-tree-context-menu {
5688
+ position: fixed;
5689
+ min-width: 170px;
5690
+ background: var(--dsw-alias-bg-layer-1, #ffffff);
5691
+ border: 1px solid var(--dsw-alias-border-l3, #e2e8f0);
5692
+ border-radius: 6px;
5693
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.2), 0 8px 10px -6px rgba(0, 0, 0, 0.15);
5694
+ padding: 4px;
5695
+ user-select: none;
5696
+ font-family: inherit;
5697
+ }
5698
+ body[data-ds-dark-theme] .dsh-tree-context-menu {
5699
+ background: #1a1e29;
5700
+ border-color: #2e384d;
5701
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
5702
+ }
5703
+ .dsh-menu-item {
5704
+ display: flex;
5705
+ align-items: center;
5706
+ gap: 8px;
5707
+ padding: 5px 8px;
5708
+ border-radius: 4px;
5709
+ font-size: 12px;
5710
+ cursor: pointer;
5711
+ color: var(--dsw-alias-label-primary, #1e293b);
5712
+ transition: background 0.1s;
5713
+ }
5714
+ body[data-ds-dark-theme] .dsh-menu-item {
5715
+ color: #e2e8f0;
5716
+ }
5717
+ .dsh-menu-item:hover {
5718
+ background: var(--dsw-alias-bg-hover, rgba(0, 0, 0, 0.06));
5719
+ }
5720
+ body[data-ds-dark-theme] .dsh-menu-item:hover {
5721
+ background: rgba(255, 255, 255, 0.09);
5722
+ }
5723
+ .dsh-menu-item-danger {
5724
+ color: #ef4444 !important;
5725
+ }
5726
+ .dsh-menu-item-danger:hover {
5727
+ background: rgba(239, 68, 68, 0.12) !important;
5728
+ }
5729
+ .dsh-menu-icon {
5730
+ font-size: 13px;
5731
+ width: 16px;
5732
+ display: inline-flex;
5733
+ align-items: center;
5734
+ justify-content: center;
5735
+ flex-shrink: 0;
5736
+ }
5737
+ .dsh-menu-divider {
5738
+ height: 1px;
5739
+ background: var(--dsw-alias-border-l3, #e2e8f0);
5740
+ margin: 3px 4px;
5741
+ }
5742
+ body[data-ds-dark-theme] .dsh-menu-divider {
5743
+ background: #2a3140;
5744
+ }
5745
+
4744
5746
  /* Splitter between Tree and Preview */
4745
5747
  .dsh-tree-splitter {
4746
5748
  width: 4px;
@@ -4860,85 +5862,89 @@
4860
5862
  body[data-ds-dark-theme] .tok-prop { color: #9cdcfe; }
4861
5863
  body[data-ds-dark-theme] .tok-selector { color: #d7ba7d; }
4862
5864
 
4863
- /* Code Editor Viewer */
5865
+ /* Code Editor Viewer (with unified line row architecture for 100% perfect alignment) */
4864
5866
  .dsh-code-editor-view {
4865
5867
  display: flex;
5868
+ flex-direction: column;
5869
+ width: 100%;
4866
5870
  min-height: 100%;
4867
- font-family: "Cascadia Code", "Fira Code", Consolas, Menlo, monospace;
4868
- font-size: 13px;
5871
+ overflow: auto;
5872
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Cascadia Code", "Fira Code", monospace;
5873
+ font-size: 12px;
4869
5874
  line-height: 20px;
4870
5875
  background: var(--dsw-alias-bg-layer-1, #ffffff);
4871
5876
  color: var(--dsw-alias-label-primary, #1e293b);
5877
+ padding: 8px 0;
5878
+ box-sizing: border-box;
5879
+ user-select: text;
4872
5880
  }
4873
5881
  body[data-ds-dark-theme] .dsh-code-editor-view {
4874
- background: #181b24;
5882
+ background: #161922;
4875
5883
  color: #d4d4d4;
4876
5884
  }
4877
5885
 
4878
- .dsh-code-gutter {
4879
- width: 44px;
4880
- min-width: 44px;
4881
- padding: 12px 6px 12px 0;
5886
+ .dsh-code-line {
5887
+ display: flex;
5888
+ flex-direction: row;
5889
+ align-items: flex-start;
5890
+ min-height: 20px;
5891
+ line-height: 20px;
5892
+ width: 100%;
5893
+ box-sizing: border-box;
5894
+ transition: background 0.1s;
5895
+ }
5896
+ .dsh-code-line:hover {
5897
+ background: rgba(0, 0, 0, 0.035);
5898
+ }
5899
+ body[data-ds-dark-theme] .dsh-code-line:hover {
5900
+ background: rgba(255, 255, 255, 0.035);
5901
+ }
5902
+ .dsh-code-line.dsh-line-highlight-flash {
5903
+ background: rgba(14, 165, 233, 0.22) !important;
5904
+ }
5905
+
5906
+ .dsh-line-gutter {
5907
+ display: inline-block;
5908
+ box-sizing: border-box;
5909
+ padding: 0 8px 0 6px;
4882
5910
  text-align: right;
4883
5911
  user-select: none;
4884
- background: var(--dsw-alias-bg-layer-2, #f8fafc);
4885
- border-right: 1px solid var(--dsw-alias-border-l3, #e2e8f0);
4886
5912
  color: var(--dsw-alias-label-tertiary, #94a3b8);
4887
- font-size: 12px;
5913
+ font-size: 11px;
4888
5914
  line-height: 20px;
4889
5915
  flex-shrink: 0;
5916
+ border-right: 1px solid var(--dsw-alias-border-l3, #e2e8f0);
5917
+ cursor: pointer;
5918
+ transition: color 0.1s;
4890
5919
  }
4891
- body[data-ds-dark-theme] .dsh-code-gutter {
4892
- background: #141720;
5920
+ body[data-ds-dark-theme] .dsh-line-gutter {
4893
5921
  border-right-color: #2a3140;
4894
- color: #64748b;
4895
- }
4896
-
4897
- .dsh-gutter-line {
4898
- height: 20px;
4899
- cursor: pointer;
4900
- padding-right: 8px;
5922
+ color: #55637a;
4901
5923
  }
4902
- .dsh-gutter-line:hover {
5924
+ .dsh-line-gutter:hover {
4903
5925
  color: var(--dsw-brand-primary, #0284c7);
4904
- font-weight: bold;
5926
+ font-weight: 600;
4905
5927
  }
4906
- .dsh-gutter-line.dsh-line-highlight-flash {
4907
- background: rgba(14, 165, 233, 0.25);
4908
- color: #0284c7;
5928
+ body[data-ds-dark-theme] .dsh-line-gutter:hover {
5929
+ color: #38bdf8;
4909
5930
  }
4910
5931
 
4911
- .dsh-code-content {
5932
+ .dsh-line-code {
4912
5933
  flex: 1;
4913
5934
  min-width: 0;
4914
- padding: 12px 16px;
4915
- overflow: auto;
4916
- }
4917
- .dsh-code-pre {
4918
- margin: 0;
5935
+ padding: 0 12px 0 10px;
5936
+ white-space: pre;
4919
5937
  font-family: inherit;
4920
5938
  font-size: inherit;
4921
5939
  line-height: 20px;
4922
5940
  tab-size: 2;
4923
- white-space: pre;
5941
+ box-sizing: border-box;
4924
5942
  }
4925
- .dsh-code-editor-view.word-wrap .dsh-code-pre {
5943
+ .dsh-code-editor-view.word-wrap .dsh-line-code {
4926
5944
  white-space: pre-wrap;
4927
5945
  word-break: normal;
4928
5946
  overflow-wrap: break-word;
4929
5947
  }
4930
- .dsh-code-editor-view {
4931
- display: flex;
4932
- min-height: 100%;
4933
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
4934
- font-size: 12.5px;
4935
- line-height: 20px;
4936
- background: var(--dsw-alias-bg-layer-1, #ffffff);
4937
- color: var(--dsw-alias-label-primary, #1e293b);
4938
- }
4939
- .dsh-code-pre code {
4940
- font-family: inherit;
4941
- }
4942
5948
 
4943
5949
  /* Interactive JSON Inspector */
4944
5950
  .dsh-json-inspector {