dsh-linked-workspace 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -1835,8 +1835,122 @@ function fileIconSpec(name, isDir) {
1835
1835
  return ICON_spec('unknown')
1836
1836
  }
1837
1837
 
1838
+ // ---- file-creation.js ----
1839
+ // Shared by the directory menu, its submit handler and API regression tests.
1840
+ // Preview formats (CSV/HTML/images) are regular files; only document templates
1841
+ // are sent to docs.create.
1842
+ function newEntryMenuItems(menu, t, onNew) {
1843
+ return [true, false].map(function (isDir) {
1844
+ var title = t(isDir ? 'newFolderTitle' : 'newFileTitle');
1845
+ return {
1846
+ label: t(isDir ? 'ctxNewFolder' : 'ctxNewFile'),
1847
+ onSelect: function () {
1848
+ onNew({ kind: 'new', side: menu.side, dir: menu.path, isDir: isDir,
1849
+ title: title, label: t('nameLabel'), value: title });
1850
+ },
1851
+ };
1852
+ });
1853
+ }
1854
+
1855
+ function newEntryRequest(side, dirPath, name, isDir, scope) {
1856
+ var child = String(name || '').trim();
1857
+ if (!child || child === '.' || child === '..' || /[/\\\0]/.test(child)) {
1858
+ var error = new Error('文件或文件夹名称无效 / Invalid file or folder name');
1859
+ error.code = 'BAD_REQUEST';
1860
+ throw error;
1861
+ }
1862
+ var parent = String(dirPath || '');
1863
+ var separator = side === 'remote' || parent.indexOf('\\') < 0 ? '/' : '\\';
1864
+ var target = parent + (/[\\/]$/.test(parent) ? '' : separator) + child;
1865
+ var match = child.toLowerCase().match(/\.[^./\\]+$/);
1866
+ var extension = match ? match[0] : '';
1867
+ var kinds = { '.md': 'markdown', '.markdown': 'markdown', '.pdf': 'pdf',
1868
+ '.docx': 'docx', '.xlsx': 'xlsx', '.pptx': 'pptx' };
1869
+ var kind = isDir ? '' : (kinds[extension] || '');
1870
+ var method;
1871
+ var params;
1872
+ if (isDir) {
1873
+ method = side === 'remote' ? 'fs.mkdir' : 'local.mkdir';
1874
+ params = side === 'remote' ? { path: target, createOnly: true } : { path: parent, name: child };
1875
+ } else if (kind) {
1876
+ method = 'docs.create';
1877
+ params = { path: target, kind: kind };
1878
+ } else {
1879
+ method = side === 'remote' ? 'fs.write' : 'local.newFile';
1880
+ params = side === 'remote' ? { path: target, content: '', createOnly: true } : { path: target };
1881
+ }
1882
+ if (side === 'remote') params = Object.assign({}, scope, params);
1883
+ return { method: method, params: params, path: target, kind: kind };
1884
+ }
1885
+
1886
+ // ---- url-safety.js ----
1887
+ // URL safety helpers shared by the document sanitizer and its unit tests.
1888
+ //
1889
+ // Browsers strip ASCII control characters, tabs and newlines while parsing a
1890
+ // URL scheme, so `jav&#9;ascript:` reaches the address bar as `javascript:`.
1891
+ // The checks below normalise the value the same way before looking at it.
1892
+ var SAFE_SCHEMES = { http: 1, https: 1, mailto: 1, tel: 1 };
1893
+ var IMAGE_DATA = /^data:image\/(?:png|jpeg|jpg|gif|webp|bmp|avif|x-icon)[;,]/
1894
+ var URL_ATTRIBUTES = {
1895
+ href: 1,
1896
+ src: 1,
1897
+ 'xlink:href': 1,
1898
+ action: 1,
1899
+ formaction: 1,
1900
+ data: 1,
1901
+ poster: 1,
1902
+ background: 1,
1903
+ cite: 1,
1904
+ longdesc: 1,
1905
+ usemap: 1,
1906
+ };
1907
+
1908
+ function normalizeUrlValue(value) {
1909
+ return String(value == null ? '' : value)
1910
+ // C0/C1 controls, space, zero-width and line/paragraph separators are what
1911
+ // URL parsers silently drop inside a scheme.
1912
+ .replace(/[\u0000-\u0020\u007f-\u009f\u200b-\u200f\u2028\u2029\ufeff]/g, '')
1913
+ .trim();
1914
+ }
1915
+
1916
+ function urlSchemeOf(value) {
1917
+ var text = normalizeUrlValue(value);
1918
+ var match = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(text);
1919
+ return match ? match[1].toLowerCase() : '';
1920
+ }
1921
+
1922
+ // Returns '' when the attribute is safe to keep, or a short reason when the
1923
+ // sanitizer has to drop it. `tagName` is only needed for data: URLs, which are
1924
+ // allowed for images and rejected everywhere else.
1925
+ function urlAttributeViolation(name, value, tagName) {
1926
+ var attribute = String(name || '').toLowerCase();
1927
+ if (/^on/.test(attribute)) return 'event handler';
1928
+ if (!URL_ATTRIBUTES[attribute]) return '';
1929
+ var text = normalizeUrlValue(value);
1930
+ if (text === '') return '';
1931
+ // Relative paths, fragments and protocol-relative URLs have no scheme.
1932
+ if (text.charAt(0) === '#' || text.slice(0, 2) === '//') return '';
1933
+ var scheme = urlSchemeOf(text);
1934
+ if (scheme === '') return '';
1935
+ if (SAFE_SCHEMES[scheme]) return '';
1936
+ if (scheme === 'data' && String(tagName || '').toLowerCase() === 'img' && IMAGE_DATA.test(text)) return '';
1937
+ return scheme + ': URL';
1938
+ }
1939
+
1940
+ // Complete attribute policy for the document preview. Inline styles are dropped
1941
+ // as well: `position:fixed; inset:0; z-index:99999` can cover the whole Harness
1942
+ // window, which turns a preview into a clickjacking / phishing surface without
1943
+ // a single line of script.
1944
+ function attributeViolation(name, value, tagName) {
1945
+ if (String(name || '').toLowerCase() === 'style') return 'inline style';
1946
+ return urlAttributeViolation(name, value, tagName);
1947
+ }
1948
+
1838
1949
  var TREE_TAB_TYPE = 'dsh-linked-workspace';
1839
1950
  var REMOTE_TAB_TYPE = 'dsh-linked-workspace:remote';
1951
+ // One tree page; a folder with 20k entries must not render 20k rows.
1952
+ var TREE_PAGE_SIZE = 500;
1953
+ var TREE_PAGE_MAX = 5000;
1840
1954
  var linkedLocalKeys = new Set();
1841
1955
  var linkedWorkspaceKeys = new Set();
1842
1956
  var hasRemoteDirectories = false;
@@ -2050,6 +2164,11 @@ function fileIconSpec(name, isDir) {
2050
2164
  latency: '延迟',
2051
2165
  countSuffix: ' 个',
2052
2166
  noEntries: '空目录',
2167
+ loadMore: '加载更多(剩余 {{count}} 项)',
2168
+ loadMoreShort: '加载更多',
2169
+ pickerTruncated: '目录项过多,仅显示前 {{count}} 项;可直接在输入框中输入路径',
2170
+ sheetTruncated: '仅显示前 {{rows}} 行 × {{cols}} 列',
2171
+ sheetMore: '工作表较多,仅显示前 20 个',
2053
2172
  noCredential: '缺少凭据',
2054
2173
  sourceManagerShort: 'SSH',
2055
2174
  remoteGroup: '远程目录',
@@ -2379,6 +2498,11 @@ function fileIconSpec(name, isDir) {
2379
2498
  latency: 'Latency',
2380
2499
  countSuffix: '',
2381
2500
  noEntries: 'Empty directory',
2501
+ loadMore: 'Load more ({{count}} left)',
2502
+ loadMoreShort: 'Load more',
2503
+ pickerTruncated: 'Too many entries; only the first {{count}} are shown. Type a path directly.',
2504
+ sheetTruncated: 'Showing the first {{rows}} rows × {{cols}} columns',
2505
+ sheetMore: 'Showing the first 20 worksheets only',
2382
2506
  noCredential: 'No credentials',
2383
2507
  sourceManagerShort: 'SSH',
2384
2508
  remoteGroup: 'Remote directories',
@@ -2689,6 +2813,7 @@ function fileIconSpec(name, isDir) {
2689
2813
  FS_IS_DIR: '目标是目录',
2690
2814
  FS_NOT_DIR: '目标不是目录',
2691
2815
  FS_TOO_LARGE: '文件过大',
2816
+ FS_EXISTS: '目标已存在,请使用其他名称',
2692
2817
  CONFLICT: '远端文件已被修改',
2693
2818
  BAD_REQUEST: '请求参数无效',
2694
2819
  },
@@ -2703,6 +2828,7 @@ function fileIconSpec(name, isDir) {
2703
2828
  FS_IS_DIR: 'That path is a directory',
2704
2829
  FS_NOT_DIR: 'That path is not a directory',
2705
2830
  FS_TOO_LARGE: 'The file is too large',
2831
+ FS_EXISTS: 'The target already exists; choose another name',
2706
2832
  CONFLICT: 'The remote file changed',
2707
2833
  BAD_REQUEST: 'Invalid request',
2708
2834
  },
@@ -2783,7 +2909,7 @@ function fileIconSpec(name, isDir) {
2783
2909
  parsed.querySelectorAll('script,style,iframe,object,embed,link,meta,base,form').forEach(function (node) { node.remove(); });
2784
2910
  parsed.querySelectorAll('*').forEach(function (element) {
2785
2911
  Array.from(element.attributes).forEach(function (attribute) {
2786
- if (/^on/i.test(attribute.name) || /^javascript:/i.test(attribute.value) || /^data:text\/html/i.test(attribute.value)) {
2912
+ if (attributeViolation(attribute.name, attribute.value, element.tagName)) {
2787
2913
  element.removeAttribute(attribute.name);
2788
2914
  }
2789
2915
  });
@@ -3049,6 +3175,9 @@ function fileIconSpec(name, isDir) {
3049
3175
  '.rw-tree-body{padding:2px 4px 6px;border-top:1px solid var(--dsw-alias-border-l1)}',
3050
3176
  '.rw-row{display:flex;align-items:center;gap:5px;width:100%;min-width:0;height:23px;padding:0 6px;border:0;border-radius:5px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12.5px;cursor:pointer;text-align:left;box-sizing:border-box}',
3051
3177
  '.rw-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
3178
+ '.rw-row:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary,#4176e6));outline-offset:-1px}',
3179
+ '.rw-input:focus-visible,.rw-textarea:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary,#4176e6));outline-offset:-1px}',
3180
+ '.rw-btn:focus-visible,.rw-doc-tab:focus-visible,.rw-icon-btn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary,#4176e6));outline-offset:1px}',
3052
3181
  '.rw-row.is-active{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent)}',
3053
3182
  '.rw-row-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1 1 auto;min-width:0}',
3054
3183
  '.rw-row-size{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
@@ -3176,6 +3305,10 @@ function fileIconSpec(name, isDir) {
3176
3305
  '.rw-doc-table td{border:1px solid var(--dsw-alias-border-l2);padding:0}',
3177
3306
  '.rw-doc-table input{width:100%;border:0;background:transparent;padding:4px 5px;font:inherit;color:var(--dsw-alias-label-primary);outline:none;box-sizing:border-box}',
3178
3307
  '.rw-doc-tabs{display:flex;gap:3px;padding:5px;border-bottom:1px solid var(--dsw-alias-border-l1);overflow:auto;flex:none}',
3308
+ '.rw-doc-note{padding:3px 10px;font-size:11px;color:var(--dsw-alias-label-tertiary);border-bottom:1px solid var(--dsw-alias-border-l1)}',
3309
+ '.rw-load-more{width:100%;border:0;background:transparent;cursor:pointer;text-align:left;font:inherit;font-size:11.5px;color:var(--dsw-alias-brand-primary)}',
3310
+ '.rw-load-more:hover{text-decoration:underline}',
3311
+ '.rw-load-more:disabled{opacity:.6;cursor:default}',
3179
3312
  '.rw-doc-tab{padding:2px 8px;border:1px solid transparent;border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11.5px;cursor:pointer;white-space:nowrap}',
3180
3313
  '.rw-doc-tab.is-on{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}',
3181
3314
  '.rw-doc-grid{width:100%;border-collapse:collapse;font-size:11.5px}',
@@ -3643,6 +3776,10 @@ function fileIconSpec(name, isDir) {
3643
3776
 
3644
3777
  var listing = local.listing;
3645
3778
  var entries = listing ? listing.entries : [];
3779
+ // A folder with thousands of sub-folders must not build thousands of DOM
3780
+ // rows; the input box below still accepts any path.
3781
+ var PICKER_LIMIT = 500;
3782
+ var shownEntries = entries.slice(0, PICKER_LIMIT);
3646
3783
  var crumbs = useMemo(function () {
3647
3784
  if (!listing || !listing.path) return [];
3648
3785
  var path = listing.path;
@@ -3695,12 +3832,15 @@ function fileIconSpec(name, isDir) {
3695
3832
  ? h('div', { className: 'rw-row-msg' }, IconAlert(12), local.error)
3696
3833
  : entries.length === 0
3697
3834
  ? h('div', { className: 'rw-row-msg' }, t('emptyFolder'))
3698
- : entries.map(function (entry) {
3835
+ : shownEntries.map(function (entry) {
3699
3836
  return h('button', {
3700
3837
  key: entry.path, type: 'button', className: 'rw-browser-row', role: 'listitem',
3701
3838
  onClick: function () { navigate(entry.path); },
3702
3839
  }, h('span', { className: 'rw-dir-icon' }, IconFolder(13, false)), h('span', null, entry.name));
3703
- })),
3840
+ }),
3841
+ entries.length > PICKER_LIMIT
3842
+ ? h('div', { className: 'rw-row-msg' }, t('pickerTruncated', { count: PICKER_LIMIT }))
3843
+ : null),
3704
3844
  h('div', { className: 'rw-browser-actions', style: { padding: '8px', gap: 8 } },
3705
3845
  h('div', { className: 'rw-inline', style: { minWidth: 0, flex: '1 1 auto' } },
3706
3846
  h('input', {
@@ -3772,6 +3912,9 @@ function fileIconSpec(name, isDir) {
3772
3912
 
3773
3913
  var listing = remote.listing;
3774
3914
  var dirs = listing ? (listing.entries || []).filter(function (entry) { return entry.type === 'dir'; }) : [];
3915
+ var PICKER_LIMIT = 500;
3916
+ var shownDirs = dirs.slice(0, PICKER_LIMIT);
3917
+ var pickerTruncated = dirs.length > PICKER_LIMIT || Boolean(listing && listing.hasMore);
3775
3918
  var crumbs = useMemo(function () {
3776
3919
  if (!listing || !listing.path) return [];
3777
3920
  var pieces = listing.path.split('/').filter(function (piece) { return piece !== ''; });
@@ -3803,12 +3946,15 @@ function fileIconSpec(name, isDir) {
3803
3946
  ? h('div', { className: 'rw-row-msg' }, IconAlert(12), remote.error)
3804
3947
  : dirs.length === 0
3805
3948
  ? h('div', { className: 'rw-row-msg' }, t('noEntries'))
3806
- : dirs.map(function (entry) {
3949
+ : shownDirs.map(function (entry) {
3807
3950
  return h('button', {
3808
3951
  key: entry.path, type: 'button', className: 'rw-browser-row', role: 'listitem',
3809
3952
  onClick: function () { navigate(entry.path); },
3810
3953
  }, h('span', { className: 'rw-dir-icon' }, IconFolder(13, false)), h('span', null, entry.name));
3811
- })),
3954
+ }),
3955
+ pickerTruncated
3956
+ ? h('div', { className: 'rw-row-msg' }, t('pickerTruncated', { count: PICKER_LIMIT }))
3957
+ : null),
3812
3958
  h('div', { className: 'rw-browser-actions', style: { padding: '8px' } },
3813
3959
  h('span', { className: 'rw-hint rw-input-mono', title: remote.input || (listing && listing.path) || '' },
3814
3960
  (listing && listing.path) || (remote.input !== '' ? remote.input : t('home'))),
@@ -4507,27 +4653,45 @@ function fileIconSpec(name, isDir) {
4507
4653
  });
4508
4654
  }
4509
4655
 
4656
+ function listRequest(path, offset, limit) {
4657
+ var params = { path: path, limit: limit || TREE_PAGE_SIZE, offset: offset };
4658
+ return props.kind === 'local'
4659
+ ? apiCall('local.list', params)
4660
+ : apiCall('fs.list', Object.assign(params, scopeParams(props.scope)));
4661
+ }
4662
+
4663
+ function applyEntries(list) {
4664
+ return props.showHidden ? list : list.filter(function (entry) {
4665
+ return String(entry.name || '').charAt(0) !== '.';
4666
+ });
4667
+ }
4668
+
4510
4669
  function load(path, silent) {
4511
4670
  if (pending.current.has(path)) return;
4512
4671
  pending.current.add(path);
4672
+ var previousRecord = cache.entries.get(path) || {};
4673
+ // A silent refresh keeps as many rows as the user already loaded:
4674
+ // re-reading only the first page would drop everything past it.
4675
+ var keep = silent ? Math.min(Math.max(Number(previousRecord.raw) || 0, TREE_PAGE_SIZE), TREE_PAGE_MAX) : TREE_PAGE_SIZE;
4513
4676
  setCache(function (previous) {
4514
4677
  var entries = new Map(previous.entries);
4515
4678
  var existing = entries.get(path) || {};
4516
- entries.set(path, { loading: !silent, error: null, entries: existing.entries || [], loaded: existing.loaded });
4679
+ entries.set(path, { loading: !silent, error: null, entries: existing.entries || [], loaded: existing.loaded, raw: existing.raw, total: existing.total, hasMore: existing.hasMore });
4517
4680
  return { entries: entries };
4518
4681
  });
4519
- var request = props.kind === 'local'
4520
- ? apiCall('local.list', { path: path })
4521
- : apiCall('fs.list', Object.assign({ path: path }, scopeParams(props.scope)));
4522
- request.then(function (result) {
4682
+ listRequest(path, 0, keep).then(function (result) {
4523
4683
  pending.current.delete(path);
4524
- var entries = result.entries || [];
4525
- if (!props.showHidden) {
4526
- entries = entries.filter(function (entry) { return String(entry.name || '').charAt(0) !== '.'; });
4527
- }
4528
4684
  setCache(function (previous) {
4529
4685
  var next = new Map(previous.entries);
4530
- next.set(path, { loading: false, error: null, loaded: true, entries: entries });
4686
+ next.set(path, {
4687
+ loading: false,
4688
+ error: null,
4689
+ loaded: true,
4690
+ entries: applyEntries(result.entries || []),
4691
+ raw: (result.entries || []).length,
4692
+ total: Number(result.total) || (result.entries || []).length,
4693
+ hasMore: Boolean(result.hasMore),
4694
+ });
4531
4695
  return { entries: next };
4532
4696
  });
4533
4697
  setExpanded(function (previous) {
@@ -4545,7 +4709,45 @@ function fileIconSpec(name, isDir) {
4545
4709
  });
4546
4710
  }
4547
4711
 
4548
- return h(TreeDirectory, {
4712
+ function loadMore(path) {
4713
+ var record = cache.entries.get(path);
4714
+ if (!record || record.loadingMore || !record.hasMore) return;
4715
+ // Offset is the number of RAW entries already fetched, not the number of
4716
+ // visible rows: hidden entries are filtered out of the list but still
4717
+ // occupy positions in the server-side listing.
4718
+ var offset = Number(record.raw);
4719
+ if (!Number.isFinite(offset)) offset = (record.entries || []).length;
4720
+ setCache(function (previous) {
4721
+ var next = new Map(previous.entries);
4722
+ var current = next.get(path);
4723
+ if (current) next.set(path, Object.assign({}, current, { loadingMore: true }));
4724
+ return { entries: next };
4725
+ });
4726
+ listRequest(path, offset).then(function (result) {
4727
+ setCache(function (previous) {
4728
+ var next = new Map(previous.entries);
4729
+ var current = next.get(path) || { entries: [] };
4730
+ next.set(path, Object.assign({}, current, {
4731
+ loadingMore: false,
4732
+ loaded: true,
4733
+ entries: current.entries.concat(applyEntries(result.entries || [])),
4734
+ raw: offset + (result.entries || []).length,
4735
+ total: Number(result.total) || current.total,
4736
+ hasMore: Boolean(result.hasMore),
4737
+ }));
4738
+ return { entries: next };
4739
+ });
4740
+ }).catch(function (error) {
4741
+ setCache(function (previous) {
4742
+ var next = new Map(previous.entries);
4743
+ var current = next.get(path);
4744
+ if (current) next.set(path, Object.assign({}, current, { loadingMore: false, moreError: error }));
4745
+ return { entries: next };
4746
+ });
4747
+ });
4748
+ }
4749
+
4750
+ return h('div', { className: 'rw-tree', role: 'tree', 'aria-label': t('allFiles') }, h(TreeDirectory, {
4549
4751
  path: rootPath,
4550
4752
  name: props.rootName || t('allFiles'),
4551
4753
  depth: 0,
@@ -4556,12 +4758,13 @@ function fileIconSpec(name, isDir) {
4556
4758
  toggle: toggle,
4557
4759
  state: { entries: cache.entries },
4558
4760
  load: load,
4761
+ loadMore: loadMore,
4559
4762
  onSelect: props.onSelect,
4560
4763
  onPin: props.onPin,
4561
4764
  onSelectPath: props.onSelectPath,
4562
4765
  onContextMenu: props.onContextMenu,
4563
4766
  selectedPath: props.selectedPath || '',
4564
- });
4767
+ }));
4565
4768
  }
4566
4769
 
4567
4770
  function TreeSection(props) {
@@ -4883,20 +5086,46 @@ function fileIconSpec(name, isDir) {
4883
5086
  var state = useState(props.value || '');
4884
5087
  var value = state[0];
4885
5088
  var setValue = state[1];
4886
- function submit() { if (value.trim() !== '') props.onSubmit(value.trim()); }
4887
- return h(DialogShell, { title: props.title, onClose: props.onClose },
5089
+ var statusState = useState({ busy: false, error: '' });
5090
+ var status = statusState[0];
5091
+ var setStatus = statusState[1];
5092
+ var pendingRef = useRef(false);
5093
+ var mountedRef = useRef(true);
5094
+ useEffect(function () {
5095
+ mountedRef.current = true;
5096
+ return function () { mountedRef.current = false; };
5097
+ }, []);
5098
+ function close() { if (!pendingRef.current) props.onClose(); }
5099
+ function submit() {
5100
+ if (value.trim() === '' || pendingRef.current) return;
5101
+ pendingRef.current = true;
5102
+ setStatus({ busy: true, error: '' });
5103
+ Promise.resolve().then(function () { return props.onSubmit(value.trim()); }).then(function () {
5104
+ pendingRef.current = false;
5105
+ if (mountedRef.current) setStatus({ busy: false, error: '' });
5106
+ }).catch(function (error) {
5107
+ pendingRef.current = false;
5108
+ if (mountedRef.current) setStatus({ busy: false, error: messageOf(error, props.t, props.locale) });
5109
+ });
5110
+ }
5111
+ return h(DialogShell, { title: props.title, onClose: close },
4888
5112
  h('label', { className: 'rw-dialog-label' }, props.label),
4889
5113
  h('input', {
4890
5114
  className: 'rw-dialog-input',
4891
5115
  value: value,
5116
+ disabled: status.busy,
4892
5117
  autoFocus: true,
4893
5118
  spellCheck: false,
4894
5119
  onChange: function (event) { setValue(event.currentTarget.value); },
4895
- onKeyDown: function (event) { if (event.key === 'Enter') submit(); },
5120
+ onKeyDown: function (event) {
5121
+ if (event.key === 'Enter' && (!event.nativeEvent || !event.nativeEvent.isComposing)) { event.preventDefault(); submit(); }
5122
+ else if (event.key === 'Escape') close();
5123
+ },
4896
5124
  }),
5125
+ status.error ? h(Banner, { kind: 'error' }, status.error) : null,
4897
5126
  h('div', { className: 'rw-dialog-actions' },
4898
- h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: props.onClose }, props.t('cancel')),
4899
- h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary', onClick: submit }, props.t('confirm'))));
5127
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: status.busy, onClick: close }, props.t('cancel')),
5128
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary', disabled: status.busy || value.trim() === '', onClick: submit }, props.t(status.busy ? 'loading' : 'confirm'))));
4900
5129
  }
4901
5130
 
4902
5131
  function ConfirmDialog(props) {
@@ -5225,6 +5454,12 @@ function fileIconSpec(name, isDir) {
5225
5454
  h('div', { className: 'rw-doc-tabs' }, sheets.map(function (sheet, index) {
5226
5455
  return h('button', { key: sheet.name, type: 'button', className: 'rw-doc-tab' + (index === state.sheet ? ' is-on' : ''), onClick: function () { patch({ sheet: index }); } }, sheet.name);
5227
5456
  })),
5457
+ active.truncated
5458
+ ? h('div', { className: 'rw-doc-note' }, t('sheetTruncated', { rows: active.maxRow || 0, cols: active.maxCol || 0 }))
5459
+ : null,
5460
+ (state.data.sheetCount || sheets.length) > sheets.length
5461
+ ? h('div', { className: 'rw-doc-note' }, t('sheetMore'))
5462
+ : null,
5228
5463
  h('div', { style: { flex: '1 1 auto', overflow: 'auto', padding: 8 } },
5229
5464
  h('table', { className: 'rw-doc-grid' },
5230
5465
  h('thead', null, header),
@@ -5814,27 +6049,13 @@ function fileIconSpec(name, isDir) {
5814
6049
  }
5815
6050
 
5816
6051
  function submitNew(side, dirPath, name, isDir) {
5817
- var target = joinChildPath(dirPath, name, side);
5818
- var docKind = documentKindOf(name);
5819
- var promise;
5820
- if (isDir) {
5821
- promise = side === 'remote'
5822
- ? apiCall('fs.mkdir', Object.assign({ path: target }, scopeParams(scopeRef)))
5823
- : apiCall('local.mkdir', { path: dirPath, name: name });
5824
- } else if (docKind) {
5825
- promise = side === 'remote'
5826
- ? apiCall('docs.create', Object.assign({ path: target, kind: docKind }, scopeParams(scopeRef)))
5827
- : apiCall('docs.create', { path: target, kind: docKind });
5828
- } else {
5829
- promise = side === 'remote'
5830
- ? apiCall('fs.write', Object.assign({ path: target, content: '' }, scopeParams(scopeRef)))
5831
- : apiCall('local.newFile', { path: target });
5832
- }
5833
- promise.then(function () {
6052
+ var request = newEntryRequest(side, dirPath, name, isDir, scopeParams(scopeRef));
6053
+ return apiCall(request.method, request.params).then(function () {
6054
+ setDialog(null);
5834
6055
  showNotice('success', t('newDone'));
5835
6056
  refreshTree();
5836
- if (!isDir && docKind) openInternalFile(side, target);
5837
- }).catch(noticeError);
6057
+ if (!isDir && request.kind) openInternalFile(side, request.path);
6058
+ });
5838
6059
  }
5839
6060
 
5840
6061
  function submitChmod(side, targetPath, mode) {
@@ -5849,9 +6070,9 @@ function fileIconSpec(name, isDir) {
5849
6070
  }
5850
6071
 
5851
6072
  function submitDialog(current, value) {
6073
+ if (current.kind === 'new') return submitNew(current.side, current.dir, value, current.isDir);
5852
6074
  setDialog(null);
5853
6075
  if (current.kind === 'rename') submitRenamed(current.side, current.path, value);
5854
- else if (current.kind === 'new') submitNew(current.side, current.dir, value, current.isDir);
5855
6076
  else if (current.kind === 'chmod') submitChmod(current.side, current.path, value);
5856
6077
  }
5857
6078
 
@@ -5911,14 +6132,8 @@ function fileIconSpec(name, isDir) {
5911
6132
  setDialog({ kind: 'chmod', side: menu.side, path: menu.path, title: t('chmodTitle'), label: t('chmodLabel'), value: '644' });
5912
6133
  } });
5913
6134
  if (menu.isDir) {
5914
- items.push({ label: t('ctxNew'), sub: [
5915
- { label: t('ctxNewFolder'), onSelect: function () {
5916
- setDialog({ kind: 'new', side: menu.side, dir: menu.path, isDir: true, title: t('newFolderTitle'), label: t('nameLabel'), value: t('newFolderTitle') });
5917
- } },
5918
- { label: t('ctxNewFile'), onSelect: function () {
5919
- setDialog({ kind: 'new', side: menu.side, dir: menu.path, isDir: false, title: t('newFileTitle'), label: t('nameLabel'), value: t('newFileTitle') });
5920
- } },
5921
- ] });
6135
+ items.push({ separator: true });
6136
+ items = items.concat(newEntryMenuItems(menu, t, setDialog));
5922
6137
  }
5923
6138
  items.push({ separator: true });
5924
6139
  items.push({ label: t('ctxProperties'), onSelect: function () { showProperties(menu.side, menu.path); } });
@@ -5979,11 +6194,12 @@ function fileIconSpec(name, isDir) {
5979
6194
  }
5980
6195
  return h(PromptDialog, {
5981
6196
  t: t,
6197
+ locale: locale,
5982
6198
  title: dialog.title,
5983
6199
  label: dialog.label,
5984
6200
  value: dialog.value,
5985
6201
  onClose: function () { setDialog(null); },
5986
- onSubmit: function (value) { submitDialog(dialog, value); },
6202
+ onSubmit: function (value) { return submitDialog(dialog, value); },
5987
6203
  });
5988
6204
  }
5989
6205
 
@@ -6348,6 +6564,7 @@ function fileIconSpec(name, isDir) {
6348
6564
  toggle: toggle,
6349
6565
  state: state,
6350
6566
  load: props.load,
6567
+ loadMore: props.loadMore,
6351
6568
  onSelect: onSelect,
6352
6569
  onPin: onPin,
6353
6570
  onSelectPath: onSelectPath,
@@ -6359,6 +6576,9 @@ function fileIconSpec(name, isDir) {
6359
6576
  children.push(h('button', {
6360
6577
  key: child.path,
6361
6578
  type: 'button',
6579
+ role: 'treeitem',
6580
+ 'aria-level': depth + 1,
6581
+ 'aria-selected': active,
6362
6582
  className: 'rw-row' + (active ? ' is-active' : ''),
6363
6583
  style: { paddingLeft: 6 + (depth + 1) * 12 },
6364
6584
  title: child.path,
@@ -6380,12 +6600,28 @@ function fileIconSpec(name, isDir) {
6380
6600
  h('span', { className: 'rw-row-size' }, formatBytes(child.size))));
6381
6601
  }
6382
6602
  });
6603
+ if (entry.hasMore) {
6604
+ children.push(h('button', {
6605
+ key: 'more',
6606
+ type: 'button',
6607
+ className: 'rw-row-msg rw-load-more',
6608
+ style: { paddingLeft: 6 + (depth + 1) * 12 },
6609
+ disabled: Boolean(entry.loadingMore),
6610
+ onClick: function () { if (props.loadMore) props.loadMore(path); },
6611
+ }, entry.loadingMore ? Spinner(12) : null,
6612
+ entry.moreError
6613
+ ? messageOf(entry.moreError, t, locale)
6614
+ : t('loadMore', { count: Math.max((entry.total || 0) - (Number.isFinite(entry.raw) ? entry.raw : entry.entries.length), 0) })));
6615
+ }
6383
6616
  }
6384
6617
  }
6385
6618
 
6386
6619
  return h(React.Fragment, null,
6387
6620
  h('button', {
6388
6621
  type: 'button',
6622
+ role: 'treeitem',
6623
+ 'aria-level': depth + 1,
6624
+ 'aria-expanded': isOpen,
6389
6625
  className: 'rw-row' + (selectedPath === path ? ' is-active' : ''),
6390
6626
  style: { paddingLeft: 6 + depth * 12 },
6391
6627
  title: path,
@@ -6393,13 +6629,16 @@ function fileIconSpec(name, isDir) {
6393
6629
  if (onSelectPath) onSelectPath({ path: path, name: name, isDir: true });
6394
6630
  if (event.detail !== 2) toggle(path);
6395
6631
  },
6632
+ onKeyDown: function (event) {
6633
+ if (event.key === 'ArrowRight' && !isOpen) { event.preventDefault(); toggle(path); }
6634
+ else if (event.key === 'ArrowLeft' && isOpen) { event.preventDefault(); toggle(path); }
6635
+ },
6396
6636
  onContextMenu: function (event) {
6397
6637
  if (!onContextMenu) return;
6398
6638
  event.preventDefault();
6399
6639
  event.stopPropagation();
6400
6640
  onContextMenu({ path: path, name: name, isDir: true, event: event });
6401
6641
  },
6402
- 'aria-expanded': isOpen,
6403
6642
  },
6404
6643
  h('span', { className: 'rw-chevron' + (isOpen ? ' is-open' : '') }, IconChevron(12)),
6405
6644
  h(FileGlyph, { name: name, dir: true, open: isOpen, size: 13 }),
@@ -6430,22 +6669,35 @@ function fileIconSpec(name, isDir) {
6430
6669
  });
6431
6670
  }
6432
6671
 
6672
+ function listRequest(path, offset) {
6673
+ return Object.assign({ path: path, limit: TREE_PAGE_SIZE, offset: offset },
6674
+ source.kind === 'linked' ? { linkedId: source.id } : { workspaceId: source.id });
6675
+ }
6676
+
6433
6677
  function load(path) {
6678
+ // Ref-based guard: a state setter callback is not a lock, so checking
6679
+ // record.loading inside setCache would still fire duplicate requests.
6680
+ if (pending.current.has(path)) return;
6681
+ pending.current.add(path);
6434
6682
  setCache(function (previous) {
6435
- var record = previous.entries.get(path);
6436
- if (record && record.loading) return previous;
6437
6683
  var entries = new Map(previous.entries);
6438
6684
  var existing = entries.get(path) || {};
6439
6685
  entries.set(path, { loading: true, error: null, entries: existing.entries || [], loaded: existing.loaded });
6440
6686
  return { entries: entries };
6441
6687
  });
6442
- var request = source.kind === 'linked'
6443
- ? { linkedId: source.id, path: path }
6444
- : { workspaceId: source.id, path: path };
6445
- apiCall('fs.list', request).then(function (result) {
6688
+ apiCall('fs.list', listRequest(path, 0)).then(function (result) {
6689
+ pending.current.delete(path);
6446
6690
  setCache(function (previous) {
6447
6691
  var entries = new Map(previous.entries);
6448
- entries.set(path, { loading: false, error: null, loaded: true, entries: result.entries || [] });
6692
+ entries.set(path, {
6693
+ loading: false,
6694
+ error: null,
6695
+ loaded: true,
6696
+ entries: result.entries || [],
6697
+ raw: (result.entries || []).length,
6698
+ total: Number(result.total) || (result.entries || []).length,
6699
+ hasMore: Boolean(result.hasMore),
6700
+ });
6449
6701
  return { entries: entries };
6450
6702
  });
6451
6703
  setExpanded(function (previous) {
@@ -6454,6 +6706,7 @@ function fileIconSpec(name, isDir) {
6454
6706
  return next;
6455
6707
  });
6456
6708
  }).catch(function (error) {
6709
+ pending.current.delete(path);
6457
6710
  setCache(function (previous) {
6458
6711
  var entries = new Map(previous.entries);
6459
6712
  entries.set(path, { loading: false, error: error, loaded: false, entries: [] });
@@ -6462,6 +6715,40 @@ function fileIconSpec(name, isDir) {
6462
6715
  });
6463
6716
  }
6464
6717
 
6718
+ function loadMore(path) {
6719
+ var record = cache.entries.get(path);
6720
+ if (!record || record.loadingMore || !record.hasMore) return;
6721
+ var offset = Number.isFinite(Number(record.raw)) ? Number(record.raw) : (record.entries || []).length;
6722
+ setCache(function (previous) {
6723
+ var entries = new Map(previous.entries);
6724
+ var current = entries.get(path);
6725
+ if (current) entries.set(path, Object.assign({}, current, { loadingMore: true }));
6726
+ return { entries: entries };
6727
+ });
6728
+ apiCall('fs.list', listRequest(path, offset)).then(function (result) {
6729
+ setCache(function (previous) {
6730
+ var entries = new Map(previous.entries);
6731
+ var current = entries.get(path) || { entries: [] };
6732
+ entries.set(path, Object.assign({}, current, {
6733
+ loadingMore: false,
6734
+ loaded: true,
6735
+ entries: current.entries.concat(result.entries || []),
6736
+ raw: offset + (result.entries || []).length,
6737
+ total: Number(result.total) || current.total,
6738
+ hasMore: Boolean(result.hasMore),
6739
+ }));
6740
+ return { entries: entries };
6741
+ });
6742
+ }).catch(function (error) {
6743
+ setCache(function (previous) {
6744
+ var entries = new Map(previous.entries);
6745
+ var current = entries.get(path);
6746
+ if (current) entries.set(path, Object.assign({}, current, { loadingMore: false, moreError: error }));
6747
+ return { entries: entries };
6748
+ });
6749
+ });
6750
+ }
6751
+
6465
6752
  useEffect(function () {
6466
6753
  if (!open) return;
6467
6754
  var root = source.remotePath;
@@ -6490,7 +6777,7 @@ function fileIconSpec(name, isDir) {
6490
6777
  h('span', { className: 'rw-tree-ws-sub' }, source.username + '@' + source.host + (source.kind === 'linked' ? ' · ' + t('linkedChip') : ''))),
6491
6778
  h(StatusPill, { status: source.status, t: t })),
6492
6779
  open
6493
- ? h('div', { className: 'rw-tree-body' },
6780
+ ? h('div', { className: 'rw-tree-body', role: 'tree', 'aria-label': source.name },
6494
6781
  h(TreeDirectory, {
6495
6782
  path: source.remotePath,
6496
6783
  name: '/',
@@ -6502,6 +6789,7 @@ function fileIconSpec(name, isDir) {
6502
6789
  toggle: toggle,
6503
6790
  state: { entries: cache.entries },
6504
6791
  load: load,
6792
+ loadMore: loadMore,
6505
6793
  onSelect: props.onSelect,
6506
6794
  selectedPath: props.selectedPath,
6507
6795
  }))
@@ -6778,10 +7066,58 @@ function fileIconSpec(name, isDir) {
6778
7066
  });
6779
7067
  }
6780
7068
 
7069
+ var modalRef = useRef(null);
7070
+ var focusablesOf = function () {
7071
+ var node = modalRef.current;
7072
+ if (!node) return [];
7073
+ return Array.prototype.filter.call(
7074
+ node.querySelectorAll('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])'),
7075
+ function (element) { return !element.disabled && element.offsetParent !== null; });
7076
+ };
7077
+
7078
+ useEffect(function () {
7079
+ if (!props.open) return;
7080
+ var node = modalRef.current;
7081
+ if (!node) return;
7082
+ // Focus the first real field, not the tab strip, so typing works at once.
7083
+ var target = node.querySelector('.rw-modal-body input, .rw-modal-body textarea') || focusablesOf()[0];
7084
+ if (target && typeof target.focus === 'function') target.focus();
7085
+ }, [props.open, flow]);
7086
+
7087
+ function onModalKeyDown(event) {
7088
+ if (event.key === 'Escape') {
7089
+ event.preventDefault();
7090
+ event.stopPropagation();
7091
+ if (props.onCancel) props.onCancel();
7092
+ return;
7093
+ }
7094
+ if (event.key !== 'Tab') return;
7095
+ // Keep Tab inside the dialog: without this the focus escapes to the
7096
+ // page behind the backdrop.
7097
+ var focusables = focusablesOf();
7098
+ if (focusables.length === 0) return;
7099
+ var first = focusables[0];
7100
+ var last = focusables[focusables.length - 1];
7101
+ if (event.shiftKey && document.activeElement === first) {
7102
+ event.preventDefault();
7103
+ last.focus();
7104
+ } else if (!event.shiftKey && document.activeElement === last) {
7105
+ event.preventDefault();
7106
+ first.focus();
7107
+ }
7108
+ }
7109
+
6781
7110
  if (!props.open) return null;
6782
7111
 
6783
7112
  return h('div', { className: 'rw-modal-backdrop', role: 'presentation' },
6784
- h('div', { className: 'rw-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': t('flowTitle') },
7113
+ h('div', {
7114
+ ref: modalRef,
7115
+ className: 'rw-modal',
7116
+ role: 'dialog',
7117
+ 'aria-modal': 'true',
7118
+ 'aria-label': t('flowTitle'),
7119
+ onKeyDown: onModalKeyDown,
7120
+ },
6785
7121
  h('div', { className: 'rw-modal-head' },
6786
7122
  h('span', { className: 'rw-icon-chip' }, IconLink(16)),
6787
7123
  h('span', { className: 'rw-modal-title' }, t('flowTitle')),