dsh-linked-workspace 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -1848,6 +1848,13 @@ function fileIconSpec(name, isDir) {
1848
1848
  return String(cwd || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
1849
1849
  }
1850
1850
 
1851
+ function pathInsideOf(root, target) {
1852
+ var base = linkedScopeKey(root);
1853
+ var value = linkedScopeKey(target);
1854
+ if (!base) return true;
1855
+ return value === base || value.indexOf(base + '/') === 0;
1856
+ }
1857
+
1851
1858
  function isLinkedWorkspaceId(workspaceId) {
1852
1859
  return Boolean(workspaceId) && linkedWorkspaceKeys.has(String(workspaceId));
1853
1860
  }
@@ -1998,6 +2005,19 @@ function fileIconSpec(name, isDir) {
1998
2005
  tooLarge: '文件过大({{size}}),暂不支持在侧边栏内编辑。',
1999
2006
  truncated: '内容已截断',
2000
2007
  fileLoadError: '无法打开文件',
2008
+ imageLoadError: '无法预览图片',
2009
+ previewOnly: '图片预览',
2010
+ csvTable: '表格',
2011
+ csvSource: '源码',
2012
+ csvEmpty: '没有可显示的数据',
2013
+ csvTruncated: '仅显示前 1000 行',
2014
+ moveTitle: '移动到…',
2015
+ moveMessage: '选择「{{name}}」要移动到的目录',
2016
+ moveDone: '已移动',
2017
+ moveDirty: '该文件有未保存修改,请先保存或关闭后再移动',
2018
+ moveFailed: '移动失败',
2019
+ moveHint: '进入目标文件夹后点击「使用此目录」',
2020
+ ctxMove: '移动到…',
2001
2021
  noWorkspace: '远程工作区不存在,请重新添加。',
2002
2022
  noTarget: '没有可用的远程目录。先在设置 → 连接中添加,或点击左侧「添加工作区」。',
2003
2023
  treeEmpty: '空目录',
@@ -2148,6 +2168,26 @@ function fileIconSpec(name, isDir) {
2148
2168
  openWithSystem: '系统默认应用',
2149
2169
  openWithCopy: '复制路径',
2150
2170
  openModeSaved: '打开方式已保存',
2171
+ autoSaveTitle: '自动保存',
2172
+ autoSaveHint: '编辑停顿约 1 秒后自动保存;关闭后请按 Ctrl+S 手动保存。',
2173
+ autoSaveLabel: '开启自动保存',
2174
+ autoSaveSaved: '自动保存设置已保存',
2175
+ shortcutTitle: '常用快捷键',
2176
+ shortcutHint: '与 VS Code 类似的默认快捷键;macOS 上把 Ctrl 换成 ⌘。',
2177
+ shortcutSave: '保存当前文件',
2178
+ shortcutClose: '关闭当前文件',
2179
+ shortcutSwitch: '切换文件标签',
2180
+ shortcutDirectory: '显示/隐藏目录面板',
2181
+ shortcutSearch: '在文件中搜索',
2182
+ terminalRemote: '打开远程终端',
2183
+ terminalLocal: '打开本地终端',
2184
+ terminalOpened: '已打开终端',
2185
+ terminalOpenFailed: '打开终端失败',
2186
+ dirtyCloseTitle: '文件尚未保存',
2187
+ dirtyCloseMessage: '「{{name}}」有未保存的修改。关闭前要保存吗?',
2188
+ dirtyCloseSave: '保存并关闭',
2189
+ dirtyCloseDiscard: '不保存关闭',
2190
+ saveFailed: '保存失败,文件未关闭',
2151
2191
  noChanges: '没有检测到更改',
2152
2192
  notGitRepo: '该目录不是 Git 仓库',
2153
2193
  changesRemote: '远端更改',
@@ -2288,6 +2328,19 @@ function fileIconSpec(name, isDir) {
2288
2328
  tooLarge: 'File too large ({{size}}); editing it in the sidebar is not supported yet.',
2289
2329
  truncated: 'Content truncated',
2290
2330
  fileLoadError: 'Cannot open the file',
2331
+ imageLoadError: 'Cannot preview this image',
2332
+ previewOnly: 'Image preview',
2333
+ csvTable: 'Table',
2334
+ csvSource: 'Source',
2335
+ csvEmpty: 'No rows to show',
2336
+ csvTruncated: 'Showing the first 1000 rows',
2337
+ moveTitle: 'Move to…',
2338
+ moveMessage: 'Choose the destination folder for "{{name}}"',
2339
+ moveDone: 'Moved',
2340
+ moveDirty: 'This file has unsaved changes; save or close it before moving',
2341
+ moveFailed: 'Move failed',
2342
+ moveHint: 'Open the destination folder, then click “Use this directory”',
2343
+ ctxMove: 'Move to…',
2291
2344
  noWorkspace: 'This remote workspace no longer exists. Add it again.',
2292
2345
  noTarget: 'No remote directory is available. Add one in Settings → Connections, or click “Add workspace” on the left.',
2293
2346
  treeEmpty: 'Empty directory',
@@ -2438,6 +2491,26 @@ function fileIconSpec(name, isDir) {
2438
2491
  openWithSystem: 'System default app',
2439
2492
  openWithCopy: 'Copy path',
2440
2493
  openModeSaved: 'Open mode saved',
2494
+ autoSaveTitle: 'Auto save',
2495
+ autoSaveHint: 'Saves about one second after you stop typing. When off, press Ctrl+S to save.',
2496
+ autoSaveLabel: 'Enable auto save',
2497
+ autoSaveSaved: 'Auto-save setting saved',
2498
+ shortcutTitle: 'Keyboard shortcuts',
2499
+ shortcutHint: 'VS Code-like defaults; use ⌘ instead of Ctrl on macOS.',
2500
+ shortcutSave: 'Save the current file',
2501
+ shortcutClose: 'Close the current file',
2502
+ shortcutSwitch: 'Switch file tabs',
2503
+ shortcutDirectory: 'Show/hide the directory pane',
2504
+ shortcutSearch: 'Search in files',
2505
+ terminalRemote: 'Open remote terminal',
2506
+ terminalLocal: 'Open local terminal',
2507
+ terminalOpened: 'Terminal opened',
2508
+ terminalOpenFailed: 'Could not open terminal',
2509
+ dirtyCloseTitle: 'Unsaved changes',
2510
+ dirtyCloseMessage: '“{{name}}” has unsaved changes. Save before closing?',
2511
+ dirtyCloseSave: 'Save and close',
2512
+ dirtyCloseDiscard: 'Close without saving',
2513
+ saveFailed: 'Save failed; the file was left open',
2441
2514
  noChanges: 'No changes detected',
2442
2515
  notGitRepo: 'Not a Git repository',
2443
2516
  changesRemote: 'Remote changes',
@@ -2639,9 +2712,58 @@ function fileIconSpec(name, isDir) {
2639
2712
  if (value === '.docx') return 'docx';
2640
2713
  if (value === '.xlsx') return 'xlsx';
2641
2714
  if (value === '.pptx') return 'pptx';
2715
+ if (value === '.csv' || value === '.tsv') return 'csv';
2716
+ if (value === '.png' || value === '.jpg' || value === '.jpeg' || value === '.gif'
2717
+ || value === '.webp' || value === '.bmp' || value === '.ico' || value === '.avif'
2718
+ || value === '.svg') return 'image';
2642
2719
  return '';
2643
2720
  }
2644
2721
 
2722
+ function isCsvPath(path) {
2723
+ var ext = String(path || '').toLowerCase().match(/\.[^./\\]+$/);
2724
+ return Boolean(ext && (ext[0] === '.csv' || ext[0] === '.tsv'));
2725
+ }
2726
+
2727
+ function parseDelimited(text, delimiter) {
2728
+ var rows = [];
2729
+ var row = [];
2730
+ var field = '';
2731
+ var quoted = false;
2732
+ var index = 0;
2733
+ var source = String(text || '');
2734
+ function pushField() { row.push(field); field = ''; }
2735
+ function pushRow() {
2736
+ pushField();
2737
+ rows.push(row);
2738
+ row = [];
2739
+ }
2740
+ while (index < source.length) {
2741
+ var ch = source[index];
2742
+ if (quoted) {
2743
+ if (ch === '"') {
2744
+ if (source[index + 1] === '"') { field += '"'; index += 2; continue; }
2745
+ quoted = false; index += 1; continue;
2746
+ }
2747
+ field += ch; index += 1; continue;
2748
+ }
2749
+ if (ch === '"') { quoted = true; index += 1; continue; }
2750
+ if (ch === delimiter) { pushField(); index += 1; continue; }
2751
+ if (ch === '\r') { index += 1; continue; }
2752
+ if (ch === '\n') { pushRow(); index += 1; continue; }
2753
+ field += ch; index += 1;
2754
+ }
2755
+ if (field !== '' || row.length > 0) pushRow();
2756
+ return rows;
2757
+ }
2758
+
2759
+ function csvDelimiterOf(path, text) {
2760
+ if (/\.tsv$/i.test(String(path || ''))) return '\t';
2761
+ var firstLine = String(text || '').split(/\r?\n/, 1)[0] || '';
2762
+ var commas = (firstLine.match(/,/g) || []).length;
2763
+ var semicolons = (firstLine.match(/;/g) || []).length;
2764
+ return semicolons > commas ? ';' : ',';
2765
+ }
2766
+
2645
2767
  function sanitizeDocumentHtml(html) {
2646
2768
  try {
2647
2769
  var parsed = new DOMParser().parseFromString(String(html || ''), 'text/html');
@@ -2718,7 +2840,7 @@ function fileIconSpec(name, isDir) {
2718
2840
  };
2719
2841
 
2720
2842
  var runtimeSettings = {
2721
- value: { editorTheme: DEFAULT_THEME_ID, editorThemeFamily: DEFAULT_THEME_FAMILY, editorThemeMode: 'system', appThemeDark: false, fileOpenMode: 'editor' },
2843
+ value: { editorTheme: DEFAULT_THEME_ID, editorThemeFamily: DEFAULT_THEME_FAMILY, editorThemeMode: 'system', appThemeDark: false, fileOpenMode: 'editor', autoSave: false },
2722
2844
  loaded: false,
2723
2845
  loading: null,
2724
2846
  listeners: new Set(),
@@ -2741,7 +2863,7 @@ function fileIconSpec(name, isDir) {
2741
2863
  runtimeSettings.loading = null;
2742
2864
  var openMode = settings && (settings.fileOpenMode === 'system' || settings.fileOpenMode === 'copy') ? settings.fileOpenMode : 'editor';
2743
2865
  var mode = settings && (settings.editorThemeMode === 'light' || settings.editorThemeMode === 'dark') ? settings.editorThemeMode : 'system';
2744
- runtimeSettings.apply({ editorTheme: settings && settings.editorTheme ? settings.editorTheme : DEFAULT_THEME_ID, editorThemeFamily: themeFamilyFor(settings), editorThemeMode: mode, fileOpenMode: openMode });
2866
+ runtimeSettings.apply({ editorTheme: settings && settings.editorTheme ? settings.editorTheme : DEFAULT_THEME_ID, editorThemeFamily: themeFamilyFor(settings), editorThemeMode: mode, fileOpenMode: openMode, autoSave: Boolean(settings && settings.autoSave) });
2745
2867
  return runtimeSettings.value;
2746
2868
  }).catch(function () {
2747
2869
  runtimeSettings.loaded = true;
@@ -2764,6 +2886,13 @@ function fileIconSpec(name, isDir) {
2764
2886
  return runtimeSettings.value;
2765
2887
  });
2766
2888
  },
2889
+ saveAutoSave: function (enabled) {
2890
+ return apiCall('settings.update', { autoSave: Boolean(enabled) }).then(function (settings) {
2891
+ runtimeSettings.loaded = true;
2892
+ runtimeSettings.apply({ autoSave: Boolean(settings && settings.autoSave) });
2893
+ return runtimeSettings.value;
2894
+ });
2895
+ },
2767
2896
  };
2768
2897
 
2769
2898
  function themeFamilyFor(settings) {
@@ -2968,6 +3097,10 @@ function fileIconSpec(name, isDir) {
2968
3097
  '.rw-option-title{display:flex;align-items:center;gap:8px;font-size:13px;line-height:20px;font-weight:600}',
2969
3098
  '.rw-option-desc{font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-tertiary)}',
2970
3099
  '.rw-warn-box{padding:8px 10px;border-radius:8px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 8%,var(--dsw-alias-bg-layer-1));font-size:12px;line-height:18px}',
3100
+ '.rw-setting-row{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:9px;background:var(--dsw-alias-bg-layer-1)}',
3101
+ '.rw-shortcut-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:4px 16px}',
3102
+ '.rw-shortcut-row{display:flex;align-items:center;gap:10px;min-height:28px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
3103
+ '.rw-kbd{display:inline-flex;align-items:center;justify-content:center;min-width:64px;padding:2px 7px;border:1px solid var(--dsw-alias-border-l2);border-bottom-width:2px;border-radius:5px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:11px/16px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:nowrap}',
2971
3104
  '@media (max-width:720px){.rw-theme-grid{grid-template-columns:repeat(auto-fill,minmax(132px,1fr))}.rw-pair{grid-template-columns:minmax(0,1fr)}}',
2972
3105
  '.rw-linked-toolbar{display:flex;align-items:center;gap:6px;padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-l1);min-width:0;position:relative;z-index:5}',
2973
3106
  '.rw-menu-wrap{position:relative}',
@@ -3077,6 +3210,17 @@ function fileIconSpec(name, isDir) {
3077
3210
  '.rw-props{display:grid;grid-template-columns:70px minmax(0,1fr);gap:5px 9px;font-size:12px;line-height:18px}',
3078
3211
  '.rw-props-key{color:var(--dsw-alias-label-tertiary)}',
3079
3212
  '.rw-props-value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
3213
+ '.rw-image-preview{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}',
3214
+ '.rw-image-head{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);min-width:0;flex:none}',
3215
+ '.rw-image-head .rw-actions{display:flex;align-items:center;gap:6px;flex:none}',
3216
+ '.rw-image-stage{display:flex;align-items:center;justify-content:center;flex:1 1 auto;min-height:0;overflow:auto;padding:18px;background:repeating-conic-gradient(var(--dsw-alias-bg-layer-1) 0% 25%,var(--dsw-alias-bg-base) 0% 50%) 50%/18px 18px}',
3217
+ '.rw-image{max-width:100%;max-height:100%;object-fit:contain;border-radius:4px;box-shadow:0 2px 16px rgba(0,0,0,.18)}',
3218
+ '.rw-csv-wrap{flex:1 1 auto;min-height:0;overflow:auto;background:var(--dsw-alias-bg-base)}',
3219
+ '.rw-csv-table{border-collapse:collapse;font:12px/18px ui-monospace,"Cascadia Code",Consolas,monospace;min-width:100%}',
3220
+ '.rw-csv-table th{position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary);font-weight:600;text-align:left;padding:4px 8px;border:1px solid var(--dsw-alias-border-l1);white-space:nowrap}',
3221
+ '.rw-csv-table td{padding:3px 8px;border:1px solid var(--dsw-alias-border-l1);vertical-align:top;max-width:360px;overflow-wrap:anywhere}',
3222
+ '.rw-csv-table tr:nth-child(even) td{background:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 52%,transparent)}',
3223
+ '.rw-csv-note{padding:5px 9px;font-size:10.5px;color:var(--dsw-alias-label-tertiary);border-top:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1)}',
3080
3224
  '.rw-queue-row{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:12px}',
3081
3225
  '.rw-queue-label{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
3082
3226
  '.rw-queue-state{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary)}',
@@ -3184,6 +3328,12 @@ function fileIconSpec(name, isDir) {
3184
3328
  h('circle', { key: 'd', cx: 4.6, cy: 11.3, r: 0.8, fill: 'currentColor' }),
3185
3329
  ]);
3186
3330
  }
3331
+ function IconTerminal(size) {
3332
+ return svg(size || 14, [
3333
+ h('rect', { key: 'a', x: 1.8, y: 2.4, width: 12.4, height: 11.2, rx: 1.8, stroke: 'currentColor', strokeWidth: 1.25 }),
3334
+ h('path', { key: 'b', d: 'm4.4 6 2.1 2-2.1 2M8.2 10h3.3', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round' }),
3335
+ ]);
3336
+ }
3187
3337
  function IconEye(size) {
3188
3338
  return svg(size || 14, [
3189
3339
  h('path', { key: 'a', d: 'M1.6 8s2.4-4.1 6.4-4.1S14.4 8 14.4 8 12 12.1 8 12.1 1.6 8 1.6 8Z', stroke: 'currentColor', strokeWidth: 1.25, strokeLinejoin: 'round' }),
@@ -3427,6 +3577,7 @@ function fileIconSpec(name, isDir) {
3427
3577
  var t = props.t;
3428
3578
  var locale = props.locale;
3429
3579
  var initialPath = props.initialPath || '';
3580
+ var root = props.root || '';
3430
3581
  var state = useState({ loading: true, listing: null, error: '', input: initialPath, creating: false, folderName: '' });
3431
3582
  var local = state[0];
3432
3583
  var setLocal = state[1];
@@ -3448,6 +3599,7 @@ function fileIconSpec(name, isDir) {
3448
3599
 
3449
3600
  function navigate(path) {
3450
3601
  if (!path) return;
3602
+ if (root && !pathInsideOf(root, path)) return;
3451
3603
  load(path);
3452
3604
  }
3453
3605
 
@@ -3550,7 +3702,7 @@ function fileIconSpec(name, isDir) {
3550
3702
  h('div', { className: 'rw-actions' },
3551
3703
  h('button', {
3552
3704
  type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary',
3553
- disabled: !listing || local.loading,
3705
+ disabled: !listing || local.loading || (root && !pathInsideOf(root, listing.path)),
3554
3706
  onClick: function () { if (listing && props.onPick) props.onPick(listing.path); },
3555
3707
  }, IconCheck(12), t('useThisDir')))),
3556
3708
  local.creating
@@ -3648,6 +3800,128 @@ function fileIconSpec(name, isDir) {
3648
3800
  }, IconCheck(12), t('useThisDir'))));
3649
3801
  }
3650
3802
 
3803
+ function ScopePathPicker(props) {
3804
+ var t = props.t;
3805
+ var locale = props.locale;
3806
+ var scope = props.scope;
3807
+ var root = props.root || '';
3808
+ var state = useState({ loading: true, listing: null, error: '' });
3809
+ var view = state[0];
3810
+ var setView = state[1];
3811
+
3812
+ var load = useCallback(function (path) {
3813
+ setView(function (previous) { return Object.assign({}, previous, { loading: true, error: '' }); });
3814
+ var request = Object.assign({ path: path || root }, scopeParams(scope));
3815
+ apiCall('fs.list', request).then(function (result) {
3816
+ setView({ loading: false, listing: result, error: '' });
3817
+ }).catch(function (error) {
3818
+ setView({ loading: false, listing: null, error: messageOf(error, t, locale) });
3819
+ });
3820
+ }, [scopeKeyOf(scope), root]);
3821
+
3822
+ useEffect(function () { load(props.initialPath || root); }, []);
3823
+
3824
+ var listing = view.listing;
3825
+ var dirs = listing ? (listing.entries || []).filter(function (entry) { return entry.type === 'dir'; }) : [];
3826
+ var parent = listing && listing.path !== root
3827
+ ? listing.path.replace(/\/+$/, '').replace(/\/[^/]*$/, '') || '/'
3828
+ : '';
3829
+ return h('div', { className: 'rw-browser rw-move-browser' },
3830
+ h('div', { className: 'rw-browser-head' },
3831
+ h('button', {
3832
+ type: 'button', className: 'rw-icon-btn', title: t('parentDir'),
3833
+ disabled: view.loading || !parent,
3834
+ onClick: function () { if (parent) load(parent); },
3835
+ }, h('span', { style: { transform: 'rotate(180deg)', display: 'inline-flex' } }, IconChevron(13))),
3836
+ h('div', { className: 'rw-breadcrumb rw-input-mono', title: listing ? listing.path : '' },
3837
+ listing ? listing.path : t('loading')),
3838
+ view.loading ? Spinner(13) : null),
3839
+ h('div', { className: 'rw-browser-list', role: 'list' },
3840
+ view.error
3841
+ ? h('div', { className: 'rw-row-msg' }, IconAlert(12), view.error)
3842
+ : dirs.length === 0
3843
+ ? h('div', { className: 'rw-row-msg' }, t('noEntries'))
3844
+ : dirs.map(function (entry) {
3845
+ return h('button', {
3846
+ key: entry.path, type: 'button', className: 'rw-browser-row', role: 'listitem',
3847
+ onClick: function () { load(entry.path); },
3848
+ }, h('span', { className: 'rw-dir-icon' }, IconFolder(13, false)), h('span', null, entry.name));
3849
+ })),
3850
+ h('div', { className: 'rw-browser-actions', style: { padding: '8px' } },
3851
+ h('span', { className: 'rw-hint' }, t('moveHint')),
3852
+ h('button', {
3853
+ type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary',
3854
+ disabled: !listing || view.loading,
3855
+ onClick: function () { if (listing) props.onPick(listing.path); },
3856
+ }, IconCheck(12), t('useThisDir'))));
3857
+ }
3858
+
3859
+ function ImagePreview(props) {
3860
+ var ctx = props.ctx;
3861
+ var t = props.t;
3862
+ var locale = props.locale;
3863
+ var scope = props.scope;
3864
+ var local = props.local === true;
3865
+ var filePath = props.path;
3866
+ var state = useState({ loading: true, error: null, data: null });
3867
+ var view = state[0];
3868
+ var setView = state[1];
3869
+ var params = scopeParams(scope);
3870
+
3871
+ var load = useCallback(function () {
3872
+ if (!filePath || (!local && !scope)) {
3873
+ setView({ loading: false, error: new Error(t('noWorkspace')), data: null });
3874
+ return;
3875
+ }
3876
+ setView({ loading: true, error: null, data: null });
3877
+ var request = local ? { path: filePath } : Object.assign({ path: filePath }, params);
3878
+ apiCall(local ? 'local.media' : 'media.read', request).then(function (result) {
3879
+ setView({ loading: false, error: null, data: result });
3880
+ }).catch(function (error) {
3881
+ setView({ loading: false, error: error, data: null });
3882
+ });
3883
+ }, [scopeKeyOf(scope), filePath, local]);
3884
+
3885
+ useEffect(function () { load(); }, [load]);
3886
+
3887
+ var name = basename(filePath) || t('fileTabTitle');
3888
+ var header = h('div', { className: 'rw-image-head' },
3889
+ h(FileGlyph, { name: name, size: 15 }),
3890
+ h('div', { className: 'rw-editor-title' },
3891
+ h('span', { className: 'rw-editor-name', title: name }, name),
3892
+ h('span', { className: 'rw-editor-path', title: filePath }, filePath)),
3893
+ h('div', { className: 'rw-actions' },
3894
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: load, disabled: view.loading }, IconRefresh(12), t('reload'))));
3895
+
3896
+ if (view.loading) {
3897
+ return h('div', { className: 'rw-image-preview' }, header,
3898
+ h('div', { className: 'rw-editor-message' }, Spinner(14), t('loading')));
3899
+ }
3900
+ if (view.error) {
3901
+ return h('div', { className: 'rw-image-preview' }, header,
3902
+ h('div', { className: 'rw-editor-message' },
3903
+ h(Banner, { kind: 'error' }, t('imageLoadError') + ':' + messageOf(view.error, t, locale)),
3904
+ h('button', { type: 'button', className: 'rw-btn', onClick: load }, IconRefresh(13), t('retry'))));
3905
+ }
3906
+ var data = view.data;
3907
+ return h('div', { className: 'rw-image-preview' },
3908
+ header,
3909
+ h('div', { className: 'rw-image-stage' },
3910
+ h('img', {
3911
+ className: 'rw-image',
3912
+ src: 'data:' + data.mime + ';base64,' + data.base64,
3913
+ alt: name,
3914
+ onError: function () {
3915
+ setView({ loading: false, error: new Error(t('imageLoadError')), data: null });
3916
+ },
3917
+ })),
3918
+ h('div', { className: 'rw-editor-status' },
3919
+ h('span', { className: 'rw-status-chip' }, data.mime || 'image'),
3920
+ h('span', { className: 'rw-status-chip' }, formatBytes(data.size || 0)),
3921
+ h('span', { className: 'rw-fill', style: { flex: '1 1 auto' } }),
3922
+ h('span', { className: 'rw-status-chip' }, t('previewOnly'))));
3923
+ }
3924
+
3651
3925
  // ── editor ─────────────────────────────────────────────────────────────
3652
3926
 
3653
3927
  function colorForToken(colors, type) {
@@ -3699,6 +3973,10 @@ function fileIconSpec(name, isDir) {
3699
3973
  var settings = useRuntimeSettings();
3700
3974
  var theme = getTheme(resolveEditorTheme(settings));
3701
3975
  var language = languageOf(filePath);
3976
+ var csvMode = isCsvPath(filePath);
3977
+ var csvTableState = useState(true);
3978
+ var showCsvTable = csvTableState[0];
3979
+ var setShowCsvTable = csvTableState[1];
3702
3980
 
3703
3981
  var loadState = useState({ loading: true, error: null, file: null });
3704
3982
  var load = loadState[0];
@@ -3750,11 +4028,11 @@ function fileIconSpec(name, isDir) {
3750
4028
  }, [dirty, baseTitle]);
3751
4029
 
3752
4030
  function saveFile(force) {
3753
- if (readOnly || !load.file || load.file.binary || load.file.tooLarge || save.saving) return;
4031
+ if (readOnly || !load.file || load.file.binary || load.file.tooLarge || save.saving) return Promise.resolve(false);
3754
4032
  setSave({ saving: true, conflict: false, error: '', ok: false });
3755
4033
  var payload = local ? { path: filePath, content: content } : Object.assign({ path: filePath, content: content }, params);
3756
4034
  if (!force && load.file && typeof load.file.mtime === 'number') payload.expectedMtime = load.file.mtime;
3757
- apiCall(local ? 'local.write' : 'fs.write', payload).then(function (result) {
4035
+ return apiCall(local ? 'local.write' : 'fs.write', payload).then(function (result) {
3758
4036
  setLoad(function (previous) {
3759
4037
  if (!previous.file) return previous;
3760
4038
  return { loading: false, error: null, file: Object.assign({}, previous.file, { mtime: result.mtime, size: result.size }) };
@@ -3764,15 +4042,33 @@ function fileIconSpec(name, isDir) {
3764
4042
  setTimeout(function () {
3765
4043
  setSave(function (previous) { return previous.ok ? { saving: false, conflict: false, error: '', ok: false } : previous; });
3766
4044
  }, 2200);
4045
+ return true;
3767
4046
  }).catch(function (error) {
3768
4047
  if (error && error.code === 'CONFLICT') {
3769
4048
  setSave({ saving: false, conflict: true, error: '', ok: false });
3770
4049
  } else {
3771
4050
  setSave({ saving: false, conflict: false, error: messageOf(error, t, locale), ok: false });
3772
4051
  }
4052
+ return false;
3773
4053
  });
3774
4054
  }
3775
4055
 
4056
+ var saveRef = useRef(null);
4057
+ saveRef.current = saveFile;
4058
+ useEffect(function () {
4059
+ var notify = props.onSaveReady;
4060
+ if (typeof notify === 'function') notify(function () { return saveRef.current(false); });
4061
+ return function () {
4062
+ if (typeof notify === 'function') notify(null);
4063
+ };
4064
+ }, [filePath, local]);
4065
+
4066
+ useEffect(function () {
4067
+ if (!settings.autoSave || !dirty || !editable || save.saving || save.conflict) return undefined;
4068
+ var timer = setTimeout(function () { saveRef.current(false); }, 900);
4069
+ return function () { clearTimeout(timer); };
4070
+ }, [content, dirty, settings.autoSave, editable, save.saving, save.conflict]);
4071
+
3776
4072
  useEffect(function () {
3777
4073
  if (props.visible === false) return undefined;
3778
4074
  function onKey(event) {
@@ -3805,6 +4101,14 @@ function fileIconSpec(name, isDir) {
3805
4101
  return count;
3806
4102
  }, [content]);
3807
4103
  var gutterLines = typeof lineCount === 'number' && lineCount <= 20000;
4104
+ var csvRows = useMemo(function () {
4105
+ if (!csvMode) return [];
4106
+ var text = content.length > 1000000 ? content.slice(0, 1000000) : content;
4107
+ var delimiter = csvDelimiterOf(filePath, text);
4108
+ return parseDelimited(text, delimiter).slice(0, 1000).map(function (row) {
4109
+ return row.slice(0, 80);
4110
+ });
4111
+ }, [content, filePath, csvMode]);
3808
4112
  var highlighted = useMemo(function () {
3809
4113
  if (!editable) return null;
3810
4114
  return highlightNodes(content, language, theme, 180000);
@@ -3835,13 +4139,20 @@ function fileIconSpec(name, isDir) {
3835
4139
  props.onClose
3836
4140
  ? h('button', { type: 'button', className: 'rw-icon-btn', title: t('closeEditor'), onClick: props.onClose }, IconClose(13))
3837
4141
  : null,
4142
+ csvMode
4143
+ ? h('button', {
4144
+ type: 'button', className: 'rw-btn rw-btn-sm',
4145
+ onClick: function () { setShowCsvTable(function (previous) { return !previous; }); },
4146
+ title: showCsvTable ? t('csvSource') : t('csvTable'),
4147
+ }, showCsvTable ? t('csvSource') : t('csvTable'))
4148
+ : null,
3838
4149
  h('button', {
3839
4150
  type: 'button', className: 'rw-btn rw-btn-sm', onClick: function () { loadFile(); },
3840
4151
  disabled: load.loading || save.saving,
3841
4152
  }, IconRefresh(12), t('reload')),
3842
4153
  h('button', {
3843
4154
  type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary', onClick: function () { saveFile(false); },
3844
- disabled: !editable || save.saving,
4155
+ disabled: !editable || save.saving || (csvMode && showCsvTable),
3845
4156
  }, save.saving ? Spinner(12) : IconSave(12), save.saving ? t('saving') : t('save'))));
3846
4157
 
3847
4158
  if (load.loading) {
@@ -3877,46 +4188,66 @@ function fileIconSpec(name, isDir) {
3877
4188
  : null;
3878
4189
 
3879
4190
  var canView = Boolean(load.file && !load.file.binary && !load.file.tooLarge);
4191
+ var csvColumnCount = 0;
4192
+ csvRows.forEach(function (row) { if (row.length > csvColumnCount) csvColumnCount = row.length; });
4193
+ var csvTable = csvRows.length === 0
4194
+ ? h('div', { className: 'rw-editor-message' }, t('csvEmpty'))
4195
+ : h('div', { className: 'rw-csv-wrap' },
4196
+ h('table', { className: 'rw-csv-table' },
4197
+ h('thead', null, h('tr', null,
4198
+ Array.from({ length: csvColumnCount }, function (_, index) {
4199
+ return h('th', { key: 'h' + index, title: csvRows[0][index] || '' }, csvRows[0][index] || '');
4200
+ }))),
4201
+ h('tbody', null, csvRows.slice(1).map(function (row, rowIndex) {
4202
+ return h('tr', { key: 'r' + rowIndex },
4203
+ Array.from({ length: csvColumnCount }, function (_, columnIndex) {
4204
+ var value = row[columnIndex] === undefined ? '' : row[columnIndex];
4205
+ return h('td', { key: 'c' + columnIndex, title: value }, value);
4206
+ }));
4207
+ }))),
4208
+ csvRows.length >= 1000 ? h('div', { className: 'rw-csv-note' }, t('csvTruncated')) : null);
3880
4209
  var body = canView
3881
- ? h('div', { className: 'rw-editor-body' },
3882
- h('div', { className: 'rw-code' },
3883
- h('div', { className: 'rw-gutter', ref: gutterRef },
3884
- gutterLines
3885
- ? h('div', { className: 'rw-gutter-inner' }, Array.from({ length: lineCount }, function (_, index) { return index + 1; }).join('\n'))
3886
- : null),
3887
- h('div', { className: 'rw-code-area' },
3888
- h('pre', { className: 'rw-highlight', ref: highlightRef, 'aria-hidden': 'true' },
3889
- highlighted || content),
3890
- h('textarea', {
3891
- ref: textareaRef,
3892
- className: 'rw-textarea',
3893
- readOnly: readOnly || !editable,
3894
- value: content,
3895
- spellCheck: false,
3896
- wrap: 'off',
3897
- onChange: function (event) {
3898
- setContent(event.currentTarget.value);
3899
- setDirty(true);
3900
- setSave({ saving: false, conflict: false, error: '', ok: false });
3901
- },
3902
- onScroll: syncScroll,
3903
- onKeyDown: function (event) {
3904
- if (!editable) return;
3905
- if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
3906
- event.preventDefault();
3907
- var target = event.currentTarget;
3908
- var start = target.selectionStart;
3909
- var end = target.selectionEnd;
3910
- var next = content.slice(0, start) + ' ' + content.slice(end);
3911
- setContent(next);
4210
+ ? (csvMode && showCsvTable
4211
+ ? csvTable
4212
+ : h('div', { className: 'rw-editor-body' },
4213
+ h('div', { className: 'rw-code' },
4214
+ h('div', { className: 'rw-gutter', ref: gutterRef },
4215
+ gutterLines
4216
+ ? h('div', { className: 'rw-gutter-inner' }, Array.from({ length: lineCount }, function (_, index) { return index + 1; }).join('\n'))
4217
+ : null),
4218
+ h('div', { className: 'rw-code-area' },
4219
+ h('pre', { className: 'rw-highlight', ref: highlightRef, 'aria-hidden': 'true' },
4220
+ highlighted || content),
4221
+ h('textarea', {
4222
+ ref: textareaRef,
4223
+ className: 'rw-textarea',
4224
+ readOnly: readOnly || !editable,
4225
+ value: content,
4226
+ spellCheck: false,
4227
+ wrap: 'off',
4228
+ onChange: function (event) {
4229
+ setContent(event.currentTarget.value);
3912
4230
  setDirty(true);
3913
- requestAnimationFrame(function () {
3914
- target.selectionStart = start + 4;
3915
- target.selectionEnd = start + 4;
3916
- });
3917
- }
3918
- },
3919
- }))))
4231
+ setSave({ saving: false, conflict: false, error: '', ok: false });
4232
+ },
4233
+ onScroll: syncScroll,
4234
+ onKeyDown: function (event) {
4235
+ if (!editable) return;
4236
+ if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
4237
+ event.preventDefault();
4238
+ var target = event.currentTarget;
4239
+ var start = target.selectionStart;
4240
+ var end = target.selectionEnd;
4241
+ var next = content.slice(0, start) + ' ' + content.slice(end);
4242
+ setContent(next);
4243
+ setDirty(true);
4244
+ requestAnimationFrame(function () {
4245
+ target.selectionStart = start + 4;
4246
+ target.selectionEnd = start + 4;
4247
+ });
4248
+ }
4249
+ },
4250
+ })))))
3920
4251
  : h('div', { className: 'rw-editor-message' }, notice, h('span', { className: 'rw-muted' }, t('readOnly')));
3921
4252
 
3922
4253
  return h('div', { className: 'rw-editor', style: containerStyle },
@@ -4056,6 +4387,7 @@ function fileIconSpec(name, isDir) {
4056
4387
  state: { entries: cache.entries },
4057
4388
  load: load,
4058
4389
  onSelect: props.onSelect,
4390
+ onPin: props.onPin,
4059
4391
  onSelectPath: props.onSelectPath,
4060
4392
  onContextMenu: props.onContextMenu,
4061
4393
  selectedPath: props.selectedPath || '',
@@ -4142,6 +4474,20 @@ function fileIconSpec(name, isDir) {
4142
4474
  });
4143
4475
  })))
4144
4476
  : null),
4477
+ h('button', {
4478
+ type: 'button',
4479
+ className: 'rw-icon-btn',
4480
+ title: t('terminalRemote'),
4481
+ onClick: function () { props.onOpenTerminal('remote'); },
4482
+ }, IconTerminal(14)),
4483
+ props.localEnabled
4484
+ ? h('button', {
4485
+ type: 'button',
4486
+ className: 'rw-icon-btn',
4487
+ title: t('terminalLocal'),
4488
+ onClick: function () { props.onOpenTerminal('local'); },
4489
+ }, IconTerminal(14))
4490
+ : null,
4145
4491
  h('button', {
4146
4492
  type: 'button',
4147
4493
  className: 'rw-icon-btn' + (props.directoryOpen ? ' is-on' : ''),
@@ -4388,6 +4734,9 @@ function fileIconSpec(name, isDir) {
4388
4734
  h('div', { className: 'rw-dialog-message' }, props.message),
4389
4735
  h('div', { className: 'rw-dialog-actions' },
4390
4736
  h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: props.onClose }, props.t('cancel')),
4737
+ props.secondaryLabel
4738
+ ? h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary', onClick: props.onSecondary }, props.secondaryLabel)
4739
+ : null,
4391
4740
  h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-danger', onClick: props.onConfirm }, props.confirmLabel)));
4392
4741
  }
4393
4742
 
@@ -4402,6 +4751,23 @@ function fileIconSpec(name, isDir) {
4402
4751
  h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: props.onClose }, props.t('close'))));
4403
4752
  }
4404
4753
 
4754
+ function MoveDialog(props) {
4755
+ var picker = props.side === 'local'
4756
+ ? h(LocalPathPicker, {
4757
+ t: props.t, locale: props.locale, initialPath: props.initialPath, root: props.root,
4758
+ onPick: props.onPick,
4759
+ })
4760
+ : h(ScopePathPicker, {
4761
+ t: props.t, locale: props.locale, scope: props.scope, root: props.root,
4762
+ initialPath: props.initialPath, onPick: props.onPick,
4763
+ });
4764
+ return h(DialogShell, { title: props.title, onClose: props.onClose },
4765
+ h('div', { className: 'rw-dialog-message' }, props.message),
4766
+ picker,
4767
+ h('div', { className: 'rw-dialog-actions' },
4768
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: props.onClose }, props.t('cancel'))));
4769
+ }
4770
+
4405
4771
  function QueueDialog(props) {
4406
4772
  return h(DialogShell, { title: props.title, onClose: props.onClose },
4407
4773
  props.queue.length === 0
@@ -4426,6 +4792,7 @@ function fileIconSpec(name, isDir) {
4426
4792
  var path = props.path;
4427
4793
  var local = props.local === true;
4428
4794
  var onSaved = props.onSaved;
4795
+ var settings = useRuntimeSettings();
4429
4796
  var kind = documentKindOf(path);
4430
4797
  var stateRef = useState({
4431
4798
  loading: true, error: '', data: null, text: '', dirty: false, saving: false,
@@ -4465,7 +4832,7 @@ function fileIconSpec(name, isDir) {
4465
4832
  }, [state.dirty]);
4466
4833
 
4467
4834
  function save() {
4468
- if (state.saving) return;
4835
+ if (state.saving) return Promise.resolve(false);
4469
4836
  patch({ saving: true, error: '', notice: '' });
4470
4837
  var data = {};
4471
4838
  if (kind === 'markdown') data = { text: state.text };
@@ -4479,15 +4846,49 @@ function fileIconSpec(name, isDir) {
4479
4846
  deleted: Object.keys(state.deleted).filter(function (index) { return state.deleted[index]; }).map(Number),
4480
4847
  };
4481
4848
  }
4482
- apiCall('docs.write', params({ data: data, expectedMtime: state.data ? state.data.mtime : undefined })).then(function () {
4483
- patch({ saving: false, dirty: false, notice: t('saved') });
4849
+ return apiCall('docs.write', params({ data: data, expectedMtime: state.data ? state.data.mtime : undefined })).then(function (result) {
4850
+ patch({
4851
+ saving: false,
4852
+ dirty: false,
4853
+ notice: t('saved'),
4854
+ data: state.data ? Object.assign({}, state.data, { mtime: result.mtime, size: result.size }) : state.data,
4855
+ });
4484
4856
  if (onSaved) onSaved();
4485
- load();
4857
+ return true;
4486
4858
  }).catch(function (error) {
4487
4859
  patch({ saving: false, error: messageOf(error, t, locale) });
4860
+ return false;
4488
4861
  });
4489
4862
  }
4490
4863
 
4864
+ var saveRef = useRef(null);
4865
+ saveRef.current = save;
4866
+ useEffect(function () {
4867
+ var notify = props.onSaveReady;
4868
+ if (typeof notify === 'function') notify(function () { return saveRef.current(); });
4869
+ return function () {
4870
+ if (typeof notify === 'function') notify(null);
4871
+ };
4872
+ }, [path, local]);
4873
+
4874
+ useEffect(function () {
4875
+ if (props.visible === false) return undefined;
4876
+ function onKey(event) {
4877
+ var mod = event.ctrlKey || event.metaKey;
4878
+ if (!mod || String(event.key || '').toLowerCase() !== 's') return;
4879
+ event.preventDefault();
4880
+ saveRef.current();
4881
+ }
4882
+ window.addEventListener('keydown', onKey, true);
4883
+ return function () { window.removeEventListener('keydown', onKey, true); };
4884
+ }, [props.visible]);
4885
+
4886
+ useEffect(function () {
4887
+ if (!settings.autoSave || !state.dirty || state.saving) return undefined;
4888
+ var timer = setTimeout(function () { saveRef.current(); }, 900);
4889
+ return function () { clearTimeout(timer); };
4890
+ }, [settings.autoSave, state.dirty, state.saving, state.text, state.data, state.updates, state.pptxUpdates, state.rotations, state.deleted]);
4891
+
4491
4892
  function showPreview() {
4492
4893
  var next = !state.preview;
4493
4894
  patch({ preview: next, previewLoading: next && !state.html });
@@ -4835,6 +5236,7 @@ function fileIconSpec(name, isDir) {
4835
5236
  var fileDirtyState = useState({});
4836
5237
  var fileDirty = fileDirtyState[0];
4837
5238
  var setFileDirty = fileDirtyState[1];
5239
+ var saveHandlersRef = useRef({});
4838
5240
  var directoryTabId = props.tab && props.tab.id;
4839
5241
 
4840
5242
  useEffect(function () {
@@ -4917,7 +5319,25 @@ function fileIconSpec(name, isDir) {
4917
5319
  });
4918
5320
  }
4919
5321
 
4920
- function openByMode(side, filePath) {
5322
+ function openTerminal(side) {
5323
+ if (!selected) return;
5324
+ var request = { side: side };
5325
+ if (selected.kind === 'linked') request.linkedId = selected.id;
5326
+ else request.workspaceId = selected.id;
5327
+ apiCall('terminal.open', request).then(function () {
5328
+ showNotice('success', t('terminalOpened'));
5329
+ }).catch(function (error) {
5330
+ showNotice('error', t('terminalOpenFailed') + ':' + messageOf(error, t, locale));
5331
+ });
5332
+ }
5333
+
5334
+ function pinByMode(side, filePath) {
5335
+ var mode = settings.fileOpenMode || 'editor';
5336
+ if (mode !== 'editor') return;
5337
+ openByMode(side, filePath, { pinned: true, preview: false });
5338
+ }
5339
+
5340
+ function openByMode(side, filePath, options) {
4921
5341
  if (!selected || !filePath) return;
4922
5342
  var openMode = settings.fileOpenMode || 'editor';
4923
5343
  if (openMode === 'copy') {
@@ -4936,14 +5356,34 @@ function fileIconSpec(name, isDir) {
4936
5356
  });
4937
5357
  return;
4938
5358
  }
4939
- openInternalFile(side, filePath);
5359
+ openInternalFile(side, filePath, options || { pinned: true });
4940
5360
  }
4941
5361
 
4942
- function openInternalFile(side, filePath) {
5362
+ function openInternalFile(side, filePath, options) {
5363
+ var opts = options || { pinned: true };
4943
5364
  var key = side + ':' + (side === 'remote' ? scopeKeyOf(scopeRef) : 'local') + ':' + filePath;
4944
5365
  setOpenFiles(function (previous) {
4945
- if (previous.some(function (file) { return file.key === key; })) return previous;
4946
- return previous.concat([{ key: key, side: side, path: filePath, name: basename(filePath) }]);
5366
+ var existing = previous.filter(function (file) { return file.key === key; })[0];
5367
+ if (existing) {
5368
+ if (opts.pinned && !existing.pinned) {
5369
+ return previous.map(function (file) { return file.key === key ? Object.assign({}, file, { pinned: true }) : file; });
5370
+ }
5371
+ return previous;
5372
+ }
5373
+ if (opts.preview === true) {
5374
+ var activeIndex = -1;
5375
+ previous.forEach(function (file, index) { if (file.key === activeFileKey) activeIndex = index; });
5376
+ if (activeIndex >= 0) {
5377
+ var active = previous[activeIndex];
5378
+ if (!active.pinned && !fileDirty[active.key]) {
5379
+ var replaced = previous.slice();
5380
+ replaced[activeIndex] = { key: key, side: side, path: filePath, name: basename(filePath), pinned: false };
5381
+ setFileDirty(function (dirtyMap) { var copy = Object.assign({}, dirtyMap); delete copy[active.key]; return copy; });
5382
+ return replaced;
5383
+ }
5384
+ }
5385
+ }
5386
+ return previous.concat([{ key: key, side: side, path: filePath, name: basename(filePath), pinned: Boolean(opts.pinned) }]);
4947
5387
  });
4948
5388
  setActiveFileKey(key);
4949
5389
  setDirectoryOpen(true);
@@ -4953,11 +5393,67 @@ function fileIconSpec(name, isDir) {
4953
5393
  setOpenFiles(function (previous) {
4954
5394
  var next = previous.filter(function (file) { return file.key !== key; });
4955
5395
  setFileDirty(function (dirtyMap) { var copy = Object.assign({}, dirtyMap); delete copy[key]; return copy; });
5396
+ delete saveHandlersRef.current[key];
4956
5397
  if (activeFileKey === key) setActiveFileKey(next.length > 0 ? next[next.length - 1].key : '');
4957
5398
  return next;
4958
5399
  });
4959
5400
  }
4960
5401
 
5402
+ function requestCloseInternalFile(key) {
5403
+ if (!fileDirty[key]) { closeInternalFile(key); return; }
5404
+ var file = openFiles.filter(function (entry) { return entry.key === key; })[0];
5405
+ setDialog({ kind: 'closeDirty', key: key, name: file ? file.name : '' });
5406
+ }
5407
+
5408
+ function saveAndCloseInternalFile(key) {
5409
+ var handler = saveHandlersRef.current[key];
5410
+ setDialog(null);
5411
+ if (!handler) { closeInternalFile(key); return; }
5412
+ handler().then(function (ok) {
5413
+ if (ok) closeInternalFile(key);
5414
+ else showNotice('error', t('saveFailed'));
5415
+ });
5416
+ }
5417
+
5418
+ useEffect(function () {
5419
+ function onKey(event) {
5420
+ var mod = event.ctrlKey || event.metaKey;
5421
+ if (!mod) return;
5422
+ var key = String(event.key || '').toLowerCase();
5423
+ if (key === 'w') {
5424
+ if (!activeFileKey) return;
5425
+ event.preventDefault();
5426
+ event.stopPropagation();
5427
+ requestCloseInternalFile(activeFileKey);
5428
+ return;
5429
+ }
5430
+ if (key === 'tab') {
5431
+ if (openFiles.length < 2) return;
5432
+ event.preventDefault();
5433
+ event.stopPropagation();
5434
+ var index = -1;
5435
+ openFiles.forEach(function (file, i) { if (file.key === activeFileKey) index = i; });
5436
+ var step = event.shiftKey ? -1 : 1;
5437
+ var nextIndex = (index + step + openFiles.length) % openFiles.length;
5438
+ setActiveFileKey(openFiles[nextIndex].key);
5439
+ return;
5440
+ }
5441
+ if (key === 'b') {
5442
+ event.preventDefault();
5443
+ event.stopPropagation();
5444
+ setDirectoryOpen(function (open) { return !open; });
5445
+ return;
5446
+ }
5447
+ if (key === 'f' && event.shiftKey) {
5448
+ event.preventDefault();
5449
+ event.stopPropagation();
5450
+ setSearchOpen(true);
5451
+ }
5452
+ }
5453
+ window.addEventListener('keydown', onKey, true);
5454
+ return function () { window.removeEventListener('keydown', onKey, true); };
5455
+ }, [activeFileKey, openFiles, directoryOpen]);
5456
+
4961
5457
  function closeContextMenu() { setCtxMenu(null); }
4962
5458
 
4963
5459
  function openContextMenu(side, info) {
@@ -5097,6 +5593,56 @@ function fileIconSpec(name, isDir) {
5097
5593
  }).catch(noticeError);
5098
5594
  }
5099
5595
 
5596
+ function pathUnder(filePath, parentPath) {
5597
+ if (!filePath || !parentPath) return false;
5598
+ var normalized = String(filePath).replace(/\\/g, '/').replace(/\/+$/, '');
5599
+ var parent = String(parentPath).replace(/\\/g, '/').replace(/\/+$/, '');
5600
+ return normalized === parent || normalized.indexOf(parent + '/') === 0 || normalized.indexOf(parent + '\\') === 0;
5601
+ }
5602
+
5603
+ function moveEntry(side, oldPath, targetDir) {
5604
+ var blocked = openFiles.some(function (file) {
5605
+ return file.side === side && pathUnder(file.path, oldPath) && fileDirty[file.key];
5606
+ });
5607
+ if (blocked) {
5608
+ showNotice('error', t('moveDirty'));
5609
+ return;
5610
+ }
5611
+ var method = side === 'remote' ? 'fs.move' : 'local.move';
5612
+ var params = side === 'remote'
5613
+ ? Object.assign({ path: oldPath, targetDir: targetDir }, scopeParams(scopeRef))
5614
+ : { path: oldPath, targetDir: targetDir, root: selected.localPath };
5615
+ apiCall(method, params).then(function (result) {
5616
+ var nextPath = result && result.path ? result.path : oldPath;
5617
+ setOpenFiles(function (previous) {
5618
+ return previous.filter(function (file) {
5619
+ return !(file.side === side && pathUnder(file.path, oldPath));
5620
+ });
5621
+ });
5622
+ setActiveFileKey(function (activeKey) {
5623
+ var stillOpen = openFiles.filter(function (file) {
5624
+ return file.key === activeKey && !(file.side === side && pathUnder(file.path, oldPath));
5625
+ });
5626
+ if (stillOpen.length > 0) return activeKey;
5627
+ var remaining = openFiles.filter(function (file) {
5628
+ return !(file.side === side && pathUnder(file.path, oldPath));
5629
+ });
5630
+ return remaining.length > 0 ? remaining[remaining.length - 1].key : null;
5631
+ });
5632
+ setFileDirty(function (previous) {
5633
+ var next = Object.assign({}, previous);
5634
+ openFiles.forEach(function (file) {
5635
+ if (file.side === side && pathUnder(file.path, oldPath)) delete next[file.key];
5636
+ });
5637
+ return next;
5638
+ });
5639
+ setDialog(null);
5640
+ showNotice('success', t('moveDone'));
5641
+ refreshTree();
5642
+ if (nextPath !== oldPath) refreshTree();
5643
+ }).catch(noticeError);
5644
+ }
5645
+
5100
5646
  function submitNew(side, dirPath, name, isDir) {
5101
5647
  var target = joinChildPath(dirPath, name, side);
5102
5648
  var docKind = documentKindOf(name);
@@ -5176,6 +5722,15 @@ function fileIconSpec(name, isDir) {
5176
5722
  items.push({ separator: true });
5177
5723
  items.push({ label: t('ctxCopyPath'), onSelect: function () { copyPathToClipboard(menu.path); } });
5178
5724
  items.push({ separator: true });
5725
+ var rootPath = menu.side === 'remote' ? selected.remotePath : selected.localPath;
5726
+ if (menu.path !== rootPath) {
5727
+ items.push({ label: t('ctxMove'), onSelect: function () {
5728
+ setDialog({
5729
+ kind: 'move', side: menu.side, path: menu.path, name: menu.name, isDir: menu.isDir,
5730
+ initialPath: parentPathOf(menu.path, menu.side),
5731
+ });
5732
+ } });
5733
+ }
5179
5734
  items.push({ label: t('ctxRename'), onSelect: function () {
5180
5735
  setDialog({ kind: 'rename', side: menu.side, path: menu.path, value: menu.name, title: t('renameTitle'), label: t('renameLabel') });
5181
5736
  } });
@@ -5227,6 +5782,31 @@ function fileIconSpec(name, isDir) {
5227
5782
  onConfirm: function () { setDialog(null); confirmDelete(dialog); },
5228
5783
  });
5229
5784
  }
5785
+ if (dialog.kind === 'move') {
5786
+ return h(MoveDialog, {
5787
+ t: t, locale: locale,
5788
+ title: t('moveTitle'),
5789
+ message: t('moveMessage', { name: dialog.name }),
5790
+ side: dialog.side,
5791
+ scope: dialog.side === 'remote' ? sourceScopeOf(selected) : null,
5792
+ root: dialog.side === 'remote' ? selected.remotePath : selected.localPath,
5793
+ initialPath: dialog.initialPath,
5794
+ onClose: function () { setDialog(null); },
5795
+ onPick: function (targetDir) { moveEntry(dialog.side, dialog.path, targetDir); },
5796
+ });
5797
+ }
5798
+ if (dialog.kind === 'closeDirty') {
5799
+ return h(ConfirmDialog, {
5800
+ t: t,
5801
+ title: t('dirtyCloseTitle'),
5802
+ message: t('dirtyCloseMessage', { name: dialog.name }),
5803
+ secondaryLabel: t('dirtyCloseSave'),
5804
+ confirmLabel: t('dirtyCloseDiscard'),
5805
+ onClose: function () { setDialog(null); },
5806
+ onSecondary: function () { saveAndCloseInternalFile(dialog.key); },
5807
+ onConfirm: function () { setDialog(null); closeInternalFile(dialog.key); },
5808
+ });
5809
+ }
5230
5810
  return h(PromptDialog, {
5231
5811
  t: t,
5232
5812
  title: dialog.title,
@@ -5340,7 +5920,8 @@ function fileIconSpec(name, isDir) {
5340
5920
  showHidden: showHidden,
5341
5921
  selectedPath: selectedEntry && selectedEntry.side === 'remote' ? selectedEntry.path : '',
5342
5922
  onSelectPath: function (info) { setSelectedEntry(Object.assign({ side: 'remote' }, info)); },
5343
- onSelect: function (filePath) { openByMode('remote', filePath); },
5923
+ onSelect: function (filePath) { openByMode('remote', filePath, { pinned: false, preview: true }); },
5924
+ onPin: function (filePath) { pinByMode('remote', filePath); },
5344
5925
  onContextMenu: function (info) { setSelectedEntry(Object.assign({ side: 'remote' }, info)); openContextMenu('remote', info); },
5345
5926
  })),
5346
5927
  selected.kind === 'linked'
@@ -5360,7 +5941,8 @@ function fileIconSpec(name, isDir) {
5360
5941
  showHidden: showHidden,
5361
5942
  selectedPath: selectedEntry && selectedEntry.side === 'local' ? selectedEntry.path : '',
5362
5943
  onSelectPath: function (info) { setSelectedEntry(Object.assign({ side: 'local' }, info)); },
5363
- onSelect: function (filePath) { openByMode('local', filePath); },
5944
+ onSelect: function (filePath) { openByMode('local', filePath, { pinned: false, preview: true }); },
5945
+ onPin: function (filePath) { pinByMode('local', filePath); },
5364
5946
  refreshTick: localTick,
5365
5947
  onContextMenu: function (info) { setSelectedEntry(Object.assign({ side: 'local' }, info)); openContextMenu('local', info); },
5366
5948
  }))
@@ -5370,13 +5952,24 @@ function fileIconSpec(name, isDir) {
5370
5952
  var activeFile = openFiles.filter(function (file) { return file.key === activeFileKey; })[0] || null;
5371
5953
  var activeDocKind = activeFile ? documentKindOf(activeFile.name) : '';
5372
5954
  var contentArea = activeFile
5373
- ? (activeDocKind
5955
+ ? (activeDocKind === 'image'
5956
+ ? h(ImagePreview, {
5957
+ ctx: ctx, t: t, locale: locale,
5958
+ scope: activeFile.side === 'remote' ? sourceScopeOf(selected) : undefined,
5959
+ path: activeFile.path,
5960
+ local: activeFile.side === 'local',
5961
+ })
5962
+ : activeDocKind && activeDocKind !== 'csv'
5374
5963
  ? h(DocumentPane, {
5375
5964
  ctx: ctx, t: t, locale: locale,
5376
5965
  scope: activeFile.side === 'remote' ? sourceScopeOf(selected) : undefined,
5377
5966
  path: activeFile.path,
5378
5967
  local: activeFile.side === 'local',
5379
5968
  onSaved: refreshTree,
5969
+ onSaveReady: function (handler) {
5970
+ if (handler) saveHandlersRef.current[activeFile.key] = handler;
5971
+ else delete saveHandlersRef.current[activeFile.key];
5972
+ },
5380
5973
  onDirtyChange: function (dirty) {
5381
5974
  setFileDirty(function (previous) {
5382
5975
  if (Boolean(previous[activeFile.key]) === Boolean(dirty)) return previous;
@@ -5393,6 +5986,10 @@ function fileIconSpec(name, isDir) {
5393
5986
  local: activeFile.side === 'local',
5394
5987
  readOnly: remoteOnly,
5395
5988
  hideHeader: true,
5989
+ onSaveReady: function (handler) {
5990
+ if (handler) saveHandlersRef.current[activeFile.key] = handler;
5991
+ else delete saveHandlersRef.current[activeFile.key];
5992
+ },
5396
5993
  onDirtyChange: function (dirty) {
5397
5994
  setFileDirty(function (previous) {
5398
5995
  if (Boolean(previous[activeFile.key]) === Boolean(dirty)) return previous;
@@ -5416,6 +6013,8 @@ function fileIconSpec(name, isDir) {
5416
6013
  openMode: settings.fileOpenMode || 'editor',
5417
6014
  directoryOpen: directoryOpen,
5418
6015
  onToggleDirectory: function () { setDirectoryOpen(!directoryOpen); },
6016
+ onOpenTerminal: openTerminal,
6017
+ localEnabled: !remoteOnly && selected.kind === 'linked',
5419
6018
  showHidden: showHidden,
5420
6019
  onToggleHidden: function () { setShowHidden(!showHidden); },
5421
6020
  transferEnabled: !remoteOnly && Boolean(selectedEntry),
@@ -5474,7 +6073,7 @@ function fileIconSpec(name, isDir) {
5474
6073
  h('span', { className: 'rw-file-tab-name' }, file.name),
5475
6074
  h('button', {
5476
6075
  type: 'button', className: 'rw-file-tab-close', title: t('closeFile'),
5477
- onClick: function (event) { event.stopPropagation(); closeInternalFile(file.key); },
6076
+ onClick: function (event) { event.stopPropagation(); requestCloseInternalFile(file.key); },
5478
6077
  }, IconClose(10)));
5479
6078
  }))
5480
6079
  : null,
@@ -5535,6 +6134,7 @@ function fileIconSpec(name, isDir) {
5535
6134
  var toggle = props.toggle;
5536
6135
  var state = props.state;
5537
6136
  var onSelect = props.onSelect;
6137
+ var onPin = props.onPin;
5538
6138
  var onSelectPath = props.onSelectPath;
5539
6139
  var onContextMenu = props.onContextMenu;
5540
6140
  var selectedPath = props.selectedPath;
@@ -5572,6 +6172,7 @@ function fileIconSpec(name, isDir) {
5572
6172
  state: state,
5573
6173
  load: props.load,
5574
6174
  onSelect: onSelect,
6175
+ onPin: onPin,
5575
6176
  onSelectPath: onSelectPath,
5576
6177
  onContextMenu: onContextMenu,
5577
6178
  selectedPath: selectedPath,
@@ -5584,10 +6185,11 @@ function fileIconSpec(name, isDir) {
5584
6185
  className: 'rw-row' + (active ? ' is-active' : ''),
5585
6186
  style: { paddingLeft: 6 + (depth + 1) * 12 },
5586
6187
  title: child.path,
5587
- onClick: function () {
6188
+ onClick: function (event) {
5588
6189
  if (onSelectPath) onSelectPath({ path: child.path, name: child.name, isDir: false });
6190
+ if (event.detail !== 2) onSelect(child.path);
5589
6191
  },
5590
- onDoubleClick: function () { onSelect(child.path); },
6192
+ onDoubleClick: function () { if (onPin) onPin(child.path); },
5591
6193
  onContextMenu: function (event) {
5592
6194
  if (!onContextMenu) return;
5593
6195
  event.preventDefault();
@@ -5610,10 +6212,10 @@ function fileIconSpec(name, isDir) {
5610
6212
  className: 'rw-row' + (selectedPath === path ? ' is-active' : ''),
5611
6213
  style: { paddingLeft: 6 + depth * 12 },
5612
6214
  title: path,
5613
- onClick: function () {
6215
+ onClick: function (event) {
5614
6216
  if (onSelectPath) onSelectPath({ path: path, name: name, isDir: true });
6217
+ if (event.detail !== 2) toggle(path);
5615
6218
  },
5616
- onDoubleClick: function () { toggle(path); },
5617
6219
  onContextMenu: function (event) {
5618
6220
  if (!onContextMenu) return;
5619
6221
  event.preventDefault();
@@ -6408,6 +7010,61 @@ function fileIconSpec(name, isDir) {
6408
7010
  : null);
6409
7011
  }
6410
7012
 
7013
+ function EditorSettingsGroup(props) {
7014
+ var t = props.t;
7015
+ var locale = props.locale;
7016
+ var settings = useRuntimeSettings();
7017
+ var state = useState({ saving: false, message: null });
7018
+ var local = state[0];
7019
+ var setLocal = state[1];
7020
+ var shortcuts = [
7021
+ ['Ctrl+S', t('shortcutSave')],
7022
+ ['Ctrl+W', t('shortcutClose')],
7023
+ ['Ctrl+Tab', t('shortcutSwitch')],
7024
+ ['Ctrl+B', t('shortcutDirectory')],
7025
+ ['Ctrl+Shift+F', t('shortcutSearch')],
7026
+ ];
7027
+
7028
+ function toggleAutoSave(next) {
7029
+ if (local.saving) return;
7030
+ setLocal({ saving: true, message: null });
7031
+ runtimeSettings.saveAutoSave(next).then(function () {
7032
+ setLocal({ saving: false, message: { kind: 'success', text: t('autoSaveSaved') } });
7033
+ bus.emit();
7034
+ }).catch(function (error) {
7035
+ setLocal({ saving: false, message: { kind: 'error', text: messageOf(error, t, locale) } });
7036
+ });
7037
+ }
7038
+
7039
+ return h('div', { className: 'rw-group' },
7040
+ h('div', { className: 'rw-group-head' },
7041
+ h('span', { className: 'rw-group-title' }, t('autoSaveTitle')),
7042
+ h('span', { className: 'rw-group-hint' }, t('autoSaveHint'))),
7043
+ h('div', { className: 'rw-setting-row' },
7044
+ h(Switch, {
7045
+ checked: Boolean(settings.autoSave),
7046
+ onChange: toggleAutoSave,
7047
+ label: t('autoSaveLabel'),
7048
+ text: t('autoSaveLabel'),
7049
+ }),
7050
+ local.saving ? Spinner(12) : null),
7051
+ h('div', { className: 'rw-group-head', style: { marginTop: 14 } },
7052
+ h('span', { className: 'rw-group-title' }, t('shortcutTitle')),
7053
+ h('span', { className: 'rw-group-hint' }, t('shortcutHint'))),
7054
+ h('div', { className: 'rw-shortcut-grid' },
7055
+ shortcuts.map(function (item) {
7056
+ return h('div', { key: item[0], className: 'rw-shortcut-row' },
7057
+ h('kbd', { className: 'rw-kbd' }, item[0]),
7058
+ h('span', null, item[1]));
7059
+ })),
7060
+ local.message
7061
+ ? h(Banner, {
7062
+ kind: local.message.kind === 'success' ? 'success' : 'error',
7063
+ onClose: function () { setLocal({ saving: false, message: null }); },
7064
+ }, local.message.text)
7065
+ : null);
7066
+ }
7067
+
6411
7068
  function ConnectionsPage(props) {
6412
7069
  var ctx = props.ctx;
6413
7070
  var locale = useLocale(ctx);
@@ -6449,6 +7106,7 @@ function fileIconSpec(name, isDir) {
6449
7106
  }, adding === 'remote' ? IconClose(13) : IconServer(13), t('addRemoteDir')))),
6450
7107
  !sidebarAvailable ? h(Banner, { kind: 'warn' }, t('needsSidebar')) : null,
6451
7108
  h(ThemeGroup, { t: t, locale: locale }),
7109
+ h(EditorSettingsGroup, { t: t, locale: locale }),
6452
7110
  adding !== ''
6453
7111
  ? h(AddWorkspaceFlow, {
6454
7112
  ctx: ctx, t: t, locale: locale, initialMode: adding, lockedMode: adding,