dsh-linked-workspace 0.8.0 → 0.8.2

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,112 @@ 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
+
1838
1940
  var TREE_TAB_TYPE = 'dsh-linked-workspace';
1839
1941
  var REMOTE_TAB_TYPE = 'dsh-linked-workspace:remote';
1942
+ // One tree page; a folder with 20k entries must not render 20k rows.
1943
+ var TREE_PAGE_SIZE = 500;
1840
1944
  var linkedLocalKeys = new Set();
1841
1945
  var linkedWorkspaceKeys = new Set();
1842
1946
  var hasRemoteDirectories = false;
@@ -2006,6 +2110,12 @@ function fileIconSpec(name, isDir) {
2006
2110
  truncated: '内容已截断',
2007
2111
  fileLoadError: '无法打开文件',
2008
2112
  imageLoadError: '无法预览图片',
2113
+ imageZoomIn: '放大',
2114
+ imageZoomOut: '缩小',
2115
+ imageFit: '适应窗口',
2116
+ imageActualSize: '原始大小',
2117
+ htmlLoadError: '无法预览网页',
2118
+ htmlScriptsDisabled: '网页脚本已禁用',
2009
2119
  previewOnly: '图片预览',
2010
2120
  csvTable: '表格',
2011
2121
  csvSource: '源码',
@@ -2044,6 +2154,10 @@ function fileIconSpec(name, isDir) {
2044
2154
  latency: '延迟',
2045
2155
  countSuffix: ' 个',
2046
2156
  noEntries: '空目录',
2157
+ loadMore: '加载更多(剩余 {{count}} 项)',
2158
+ loadMoreShort: '加载更多',
2159
+ sheetTruncated: '仅显示前 {{rows}} 行 × {{cols}} 列',
2160
+ sheetMore: '工作表较多,仅显示前 20 个',
2047
2161
  noCredential: '缺少凭据',
2048
2162
  sourceManagerShort: 'SSH',
2049
2163
  remoteGroup: '远程目录',
@@ -2329,6 +2443,12 @@ function fileIconSpec(name, isDir) {
2329
2443
  truncated: 'Content truncated',
2330
2444
  fileLoadError: 'Cannot open the file',
2331
2445
  imageLoadError: 'Cannot preview this image',
2446
+ imageZoomIn: 'Zoom in',
2447
+ imageZoomOut: 'Zoom out',
2448
+ imageFit: 'Fit to window',
2449
+ imageActualSize: 'Actual size',
2450
+ htmlLoadError: 'Cannot preview this page',
2451
+ htmlScriptsDisabled: 'Page scripts are disabled',
2332
2452
  previewOnly: 'Image preview',
2333
2453
  csvTable: 'Table',
2334
2454
  csvSource: 'Source',
@@ -2367,6 +2487,10 @@ function fileIconSpec(name, isDir) {
2367
2487
  latency: 'Latency',
2368
2488
  countSuffix: '',
2369
2489
  noEntries: 'Empty directory',
2490
+ loadMore: 'Load more ({{count}} left)',
2491
+ loadMoreShort: 'Load more',
2492
+ sheetTruncated: 'Showing the first {{rows}} rows × {{cols}} columns',
2493
+ sheetMore: 'Showing the first 20 worksheets only',
2370
2494
  noCredential: 'No credentials',
2371
2495
  sourceManagerShort: 'SSH',
2372
2496
  remoteGroup: 'Remote directories',
@@ -2677,6 +2801,7 @@ function fileIconSpec(name, isDir) {
2677
2801
  FS_IS_DIR: '目标是目录',
2678
2802
  FS_NOT_DIR: '目标不是目录',
2679
2803
  FS_TOO_LARGE: '文件过大',
2804
+ FS_EXISTS: '目标已存在,请使用其他名称',
2680
2805
  CONFLICT: '远端文件已被修改',
2681
2806
  BAD_REQUEST: '请求参数无效',
2682
2807
  },
@@ -2691,6 +2816,7 @@ function fileIconSpec(name, isDir) {
2691
2816
  FS_IS_DIR: 'That path is a directory',
2692
2817
  FS_NOT_DIR: 'That path is not a directory',
2693
2818
  FS_TOO_LARGE: 'The file is too large',
2819
+ FS_EXISTS: 'The target already exists; choose another name',
2694
2820
  CONFLICT: 'The remote file changed',
2695
2821
  BAD_REQUEST: 'Invalid request',
2696
2822
  },
@@ -2713,6 +2839,7 @@ function fileIconSpec(name, isDir) {
2713
2839
  if (value === '.xlsx') return 'xlsx';
2714
2840
  if (value === '.pptx') return 'pptx';
2715
2841
  if (value === '.csv' || value === '.tsv') return 'csv';
2842
+ if (value === '.html' || value === '.htm') return 'html';
2716
2843
  if (value === '.png' || value === '.jpg' || value === '.jpeg' || value === '.gif'
2717
2844
  || value === '.webp' || value === '.bmp' || value === '.ico' || value === '.avif'
2718
2845
  || value === '.svg') return 'image';
@@ -2770,7 +2897,7 @@ function fileIconSpec(name, isDir) {
2770
2897
  parsed.querySelectorAll('script,style,iframe,object,embed,link,meta,base,form').forEach(function (node) { node.remove(); });
2771
2898
  parsed.querySelectorAll('*').forEach(function (element) {
2772
2899
  Array.from(element.attributes).forEach(function (attribute) {
2773
- if (/^on/i.test(attribute.name) || /^javascript:/i.test(attribute.value) || /^data:text\/html/i.test(attribute.value)) {
2900
+ if (urlAttributeViolation(attribute.name, attribute.value, element.tagName)) {
2774
2901
  element.removeAttribute(attribute.name);
2775
2902
  }
2776
2903
  });
@@ -3036,6 +3163,9 @@ function fileIconSpec(name, isDir) {
3036
3163
  '.rw-tree-body{padding:2px 4px 6px;border-top:1px solid var(--dsw-alias-border-l1)}',
3037
3164
  '.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}',
3038
3165
  '.rw-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
3166
+ '.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}',
3167
+ '.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}',
3168
+ '.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}',
3039
3169
  '.rw-row.is-active{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent)}',
3040
3170
  '.rw-row-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1 1 auto;min-width:0}',
3041
3171
  '.rw-row-size{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}',
@@ -3163,6 +3293,10 @@ function fileIconSpec(name, isDir) {
3163
3293
  '.rw-doc-table td{border:1px solid var(--dsw-alias-border-l2);padding:0}',
3164
3294
  '.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}',
3165
3295
  '.rw-doc-tabs{display:flex;gap:3px;padding:5px;border-bottom:1px solid var(--dsw-alias-border-l1);overflow:auto;flex:none}',
3296
+ '.rw-doc-note{padding:3px 10px;font-size:11px;color:var(--dsw-alias-label-tertiary);border-bottom:1px solid var(--dsw-alias-border-l1)}',
3297
+ '.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)}',
3298
+ '.rw-load-more:hover{text-decoration:underline}',
3299
+ '.rw-load-more:disabled{opacity:.6;cursor:default}',
3166
3300
  '.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}',
3167
3301
  '.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)}',
3168
3302
  '.rw-doc-grid{width:100%;border-collapse:collapse;font-size:11.5px}',
@@ -3213,8 +3347,14 @@ function fileIconSpec(name, isDir) {
3213
3347
  '.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
3348
  '.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
3349
  '.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)}',
3350
+ '.rw-image-stage{display:block;flex:1 1 auto;min-height:0;overflow:auto;padding:18px;text-align:center;background:repeating-conic-gradient(var(--dsw-alias-bg-layer-1) 0% 25%,var(--dsw-alias-bg-base) 0% 50%) 50%/18px 18px}',
3351
+ '.rw-image-canvas{display:inline-block;min-width:100%;min-height:100%;box-sizing:border-box}',
3352
+ '.rw-image{display:inline-block;max-width:none;max-height:none;vertical-align:top;border-radius:4px;box-shadow:0 2px 16px rgba(0,0,0,.18)}',
3353
+ '.rw-image-tools{display:flex;align-items:center;gap:4px;flex:none}',
3354
+ '.rw-image-zoom{min-width:52px;text-align:center;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary);font-size:12px}',
3355
+ '.rw-html-preview{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}',
3356
+ '.rw-html-stage{flex:1 1 auto;min-height:0;background:#fff;position:relative}',
3357
+ '.rw-html-frame{display:block;width:100%;height:100%;border:0;background:#fff}',
3218
3358
  '.rw-csv-wrap{flex:1 1 auto;min-height:0;overflow:auto;background:var(--dsw-alias-bg-base)}',
3219
3359
  '.rw-csv-table{border-collapse:collapse;font:12px/18px ui-monospace,"Cascadia Code",Consolas,monospace;min-width:100%}',
3220
3360
  '.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}',
@@ -3866,13 +4006,38 @@ function fileIconSpec(name, isDir) {
3866
4006
  var state = useState({ loading: true, error: null, data: null });
3867
4007
  var view = state[0];
3868
4008
  var setView = state[1];
4009
+ var naturalState = useState(null);
4010
+ var natural = naturalState[0];
4011
+ var setNatural = naturalState[1];
4012
+ var zoomState = useState(1);
4013
+ var zoom = zoomState[0];
4014
+ var setZoom = zoomState[1];
4015
+ var stageRef = useRef(null);
3869
4016
  var params = scopeParams(scope);
4017
+ var MIN_ZOOM = 0.05;
4018
+ var MAX_ZOOM = 8;
4019
+
4020
+ function clampZoom(value) {
4021
+ return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value));
4022
+ }
4023
+
4024
+ function zoomBy(factor) {
4025
+ setZoom(function (previous) { return clampZoom(previous * factor); });
4026
+ }
4027
+
4028
+ function fitToStage() {
4029
+ if (!natural || !stageRef.current) return;
4030
+ var width = Math.max(40, stageRef.current.clientWidth - 36);
4031
+ var height = Math.max(40, stageRef.current.clientHeight - 36);
4032
+ setZoom(clampZoom(Math.min(1, width / natural.width, height / natural.height)));
4033
+ }
3870
4034
 
3871
4035
  var load = useCallback(function () {
3872
4036
  if (!filePath || (!local && !scope)) {
3873
4037
  setView({ loading: false, error: new Error(t('noWorkspace')), data: null });
3874
4038
  return;
3875
4039
  }
4040
+ setNatural(null);
3876
4041
  setView({ loading: true, error: null, data: null });
3877
4042
  var request = local ? { path: filePath } : Object.assign({ path: filePath }, params);
3878
4043
  apiCall(local ? 'local.media' : 'media.read', request).then(function (result) {
@@ -3884,13 +4049,59 @@ function fileIconSpec(name, isDir) {
3884
4049
 
3885
4050
  useEffect(function () { load(); }, [load]);
3886
4051
 
4052
+ useEffect(function () {
4053
+ setZoom(1);
4054
+ setNatural(null);
4055
+ }, [filePath]);
4056
+
4057
+ useEffect(function () {
4058
+ if (!natural || !stageRef.current) return;
4059
+ var width = Math.max(40, stageRef.current.clientWidth - 36);
4060
+ var height = Math.max(40, stageRef.current.clientHeight - 36);
4061
+ setZoom(clampZoom(Math.min(1, width / natural.width, height / natural.height)));
4062
+ }, [natural]);
4063
+
4064
+ useEffect(function () {
4065
+ var stage = stageRef.current;
4066
+ if (!stage) return;
4067
+ function onWheel(event) {
4068
+ if (!event.ctrlKey && !event.metaKey) return;
4069
+ event.preventDefault();
4070
+ setZoom(function (previous) {
4071
+ return clampZoom(previous * (event.deltaY < 0 ? 1.2 : 1 / 1.2));
4072
+ });
4073
+ }
4074
+ stage.addEventListener('wheel', onWheel, { passive: false });
4075
+ return function () { stage.removeEventListener('wheel', onWheel); };
4076
+ }, [view.data]);
4077
+
3887
4078
  var name = basename(filePath) || t('fileTabTitle');
4079
+ var zoomTools = view.data ? h('div', { className: 'rw-image-tools' },
4080
+ h('button', {
4081
+ type: 'button', className: 'rw-btn rw-btn-sm', title: t('imageZoomOut'),
4082
+ onClick: function () { zoomBy(1 / 1.2); },
4083
+ }, '−'),
4084
+ h('span', { className: 'rw-image-zoom' }, Math.round(zoom * 100) + '%'),
4085
+ h('button', {
4086
+ type: 'button', className: 'rw-btn rw-btn-sm', title: t('imageZoomIn'),
4087
+ onClick: function () { zoomBy(1.2); },
4088
+ }, '+'),
4089
+ h('button', {
4090
+ type: 'button', className: 'rw-btn rw-btn-sm', title: t('imageFit'),
4091
+ onClick: fitToStage,
4092
+ }, t('imageFit')),
4093
+ h('button', {
4094
+ type: 'button', className: 'rw-btn rw-btn-sm', title: t('imageActualSize'),
4095
+ onClick: function () { setZoom(1); },
4096
+ }, '1:1')) : null;
4097
+
3888
4098
  var header = h('div', { className: 'rw-image-head' },
3889
4099
  h(FileGlyph, { name: name, size: 15 }),
3890
4100
  h('div', { className: 'rw-editor-title' },
3891
4101
  h('span', { className: 'rw-editor-name', title: name }, name),
3892
4102
  h('span', { className: 'rw-editor-path', title: filePath }, filePath)),
3893
4103
  h('div', { className: 'rw-actions' },
4104
+ zoomTools,
3894
4105
  h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: load, disabled: view.loading }, IconRefresh(12), t('reload'))));
3895
4106
 
3896
4107
  if (view.loading) {
@@ -3904,24 +4115,104 @@ function fileIconSpec(name, isDir) {
3904
4115
  h('button', { type: 'button', className: 'rw-btn', onClick: load }, IconRefresh(13), t('retry'))));
3905
4116
  }
3906
4117
  var data = view.data;
4118
+ var imageStyle = natural
4119
+ ? { width: natural.width * zoom + 'px', height: natural.height * zoom + 'px' }
4120
+ : { maxWidth: '100%', maxHeight: '100%' };
3907
4121
  return h('div', { className: 'rw-image-preview' },
3908
4122
  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
- })),
4123
+ h('div', { className: 'rw-image-stage', ref: stageRef },
4124
+ h('div', { className: 'rw-image-canvas' },
4125
+ h('img', {
4126
+ className: 'rw-image',
4127
+ src: 'data:' + data.mime + ';base64,' + data.base64,
4128
+ alt: name,
4129
+ style: imageStyle,
4130
+ onLoad: function (event) {
4131
+ var image = event.currentTarget;
4132
+ if (image.naturalWidth && image.naturalHeight) {
4133
+ setNatural({ width: image.naturalWidth, height: image.naturalHeight });
4134
+ }
4135
+ },
4136
+ onError: function () {
4137
+ setView({ loading: false, error: new Error(t('imageLoadError')), data: null });
4138
+ },
4139
+ }))),
3918
4140
  h('div', { className: 'rw-editor-status' },
3919
4141
  h('span', { className: 'rw-status-chip' }, data.mime || 'image'),
3920
4142
  h('span', { className: 'rw-status-chip' }, formatBytes(data.size || 0)),
4143
+ h('span', { className: 'rw-status-chip' }, Math.round(zoom * 100) + '%'),
3921
4144
  h('span', { className: 'rw-fill', style: { flex: '1 1 auto' } }),
3922
4145
  h('span', { className: 'rw-status-chip' }, t('previewOnly'))));
3923
4146
  }
3924
4147
 
4148
+ function HtmlPreview(props) {
4149
+ var ctx = props.ctx;
4150
+ var t = props.t;
4151
+ var locale = props.locale;
4152
+ var scope = props.scope;
4153
+ var local = props.local === true;
4154
+ var filePath = props.path;
4155
+ var state = useState({ loading: true, error: null, data: null });
4156
+ var view = state[0];
4157
+ var setView = state[1];
4158
+ var params = scopeParams(scope);
4159
+
4160
+ var load = useCallback(function () {
4161
+ if (!filePath || (!local && !scope)) {
4162
+ setView({ loading: false, error: new Error(t('noWorkspace')), data: null });
4163
+ return;
4164
+ }
4165
+ setView({ loading: true, error: null, data: null });
4166
+ var request = local ? { path: filePath } : Object.assign({ path: filePath }, params);
4167
+ apiCall(local ? 'local.read' : 'fs.read', request).then(function (result) {
4168
+ if (result.binary || result.tooLarge) {
4169
+ setView({ loading: false, error: new Error(t('htmlLoadError')), data: null });
4170
+ return;
4171
+ }
4172
+ setView({ loading: false, error: null, data: result });
4173
+ }).catch(function (error) {
4174
+ setView({ loading: false, error: error, data: null });
4175
+ });
4176
+ }, [scopeKeyOf(scope), filePath, local]);
4177
+
4178
+ useEffect(function () { load(); }, [load]);
4179
+
4180
+ var name = basename(filePath) || t('fileTabTitle');
4181
+ var header = h('div', { className: 'rw-image-head' },
4182
+ h(FileGlyph, { name: name, size: 15 }),
4183
+ h('div', { className: 'rw-editor-title' },
4184
+ h('span', { className: 'rw-editor-name', title: name }, name),
4185
+ h('span', { className: 'rw-editor-path', title: filePath }, filePath)),
4186
+ h('div', { className: 'rw-actions' },
4187
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: load, disabled: view.loading }, IconRefresh(12), t('reload'))));
4188
+
4189
+ if (view.loading) {
4190
+ return h('div', { className: 'rw-html-preview' }, header,
4191
+ h('div', { className: 'rw-editor-message' }, Spinner(14), t('loading')));
4192
+ }
4193
+ if (view.error) {
4194
+ return h('div', { className: 'rw-html-preview' }, header,
4195
+ h('div', { className: 'rw-editor-message' },
4196
+ h(Banner, { kind: 'error' }, t('htmlLoadError') + ':' + messageOf(view.error, t, locale)),
4197
+ h('button', { type: 'button', className: 'rw-btn', onClick: load }, IconRefresh(13), t('retry'))));
4198
+ }
4199
+ var data = view.data;
4200
+ return h('div', { className: 'rw-html-preview' },
4201
+ header,
4202
+ h('div', { className: 'rw-html-stage' },
4203
+ h('iframe', {
4204
+ className: 'rw-html-frame',
4205
+ title: name,
4206
+ sandbox: '',
4207
+ srcDoc: data.content || '',
4208
+ })),
4209
+ h('div', { className: 'rw-editor-status' },
4210
+ h('span', { className: 'rw-status-chip' }, 'HTML'),
4211
+ h('span', { className: 'rw-status-chip' }, formatBytes(data.size || 0)),
4212
+ h('span', { className: 'rw-fill', style: { flex: '1 1 auto' } }),
4213
+ h('span', { className: 'rw-status-chip' }, t('htmlScriptsDisabled'))));
4214
+ }
4215
+
3925
4216
  // ── editor ─────────────────────────────────────────────────────────────
3926
4217
 
3927
4218
  function colorForToken(colors, type) {
@@ -4337,6 +4628,19 @@ function fileIconSpec(name, isDir) {
4337
4628
  });
4338
4629
  }
4339
4630
 
4631
+ function listRequest(path, offset) {
4632
+ var params = { path: path, limit: TREE_PAGE_SIZE, offset: offset };
4633
+ return props.kind === 'local'
4634
+ ? apiCall('local.list', params)
4635
+ : apiCall('fs.list', Object.assign(params, scopeParams(props.scope)));
4636
+ }
4637
+
4638
+ function applyEntries(list) {
4639
+ return props.showHidden ? list : list.filter(function (entry) {
4640
+ return String(entry.name || '').charAt(0) !== '.';
4641
+ });
4642
+ }
4643
+
4340
4644
  function load(path, silent) {
4341
4645
  if (pending.current.has(path)) return;
4342
4646
  pending.current.add(path);
@@ -4346,18 +4650,18 @@ function fileIconSpec(name, isDir) {
4346
4650
  entries.set(path, { loading: !silent, error: null, entries: existing.entries || [], loaded: existing.loaded });
4347
4651
  return { entries: entries };
4348
4652
  });
4349
- var request = props.kind === 'local'
4350
- ? apiCall('local.list', { path: path })
4351
- : apiCall('fs.list', Object.assign({ path: path }, scopeParams(props.scope)));
4352
- request.then(function (result) {
4653
+ listRequest(path, 0).then(function (result) {
4353
4654
  pending.current.delete(path);
4354
- var entries = result.entries || [];
4355
- if (!props.showHidden) {
4356
- entries = entries.filter(function (entry) { return String(entry.name || '').charAt(0) !== '.'; });
4357
- }
4358
4655
  setCache(function (previous) {
4359
4656
  var next = new Map(previous.entries);
4360
- next.set(path, { loading: false, error: null, loaded: true, entries: entries });
4657
+ next.set(path, {
4658
+ loading: false,
4659
+ error: null,
4660
+ loaded: true,
4661
+ entries: applyEntries(result.entries || []),
4662
+ total: Number(result.total) || (result.entries || []).length,
4663
+ hasMore: Boolean(result.hasMore),
4664
+ });
4361
4665
  return { entries: next };
4362
4666
  });
4363
4667
  setExpanded(function (previous) {
@@ -4375,7 +4679,39 @@ function fileIconSpec(name, isDir) {
4375
4679
  });
4376
4680
  }
4377
4681
 
4378
- return h(TreeDirectory, {
4682
+ function loadMore(path) {
4683
+ var record = cache.entries.get(path);
4684
+ if (!record || record.loadingMore || !record.hasMore) return;
4685
+ setCache(function (previous) {
4686
+ var next = new Map(previous.entries);
4687
+ var current = next.get(path);
4688
+ if (current) next.set(path, Object.assign({}, current, { loadingMore: true }));
4689
+ return { entries: next };
4690
+ });
4691
+ listRequest(path, (record.entries || []).length).then(function (result) {
4692
+ setCache(function (previous) {
4693
+ var next = new Map(previous.entries);
4694
+ var current = next.get(path) || { entries: [] };
4695
+ next.set(path, Object.assign({}, current, {
4696
+ loadingMore: false,
4697
+ loaded: true,
4698
+ entries: current.entries.concat(applyEntries(result.entries || [])),
4699
+ total: Number(result.total) || current.total,
4700
+ hasMore: Boolean(result.hasMore),
4701
+ }));
4702
+ return { entries: next };
4703
+ });
4704
+ }).catch(function (error) {
4705
+ setCache(function (previous) {
4706
+ var next = new Map(previous.entries);
4707
+ var current = next.get(path);
4708
+ if (current) next.set(path, Object.assign({}, current, { loadingMore: false, moreError: error }));
4709
+ return { entries: next };
4710
+ });
4711
+ });
4712
+ }
4713
+
4714
+ return h('div', { className: 'rw-tree', role: 'tree', 'aria-label': t('allFiles') }, h(TreeDirectory, {
4379
4715
  path: rootPath,
4380
4716
  name: props.rootName || t('allFiles'),
4381
4717
  depth: 0,
@@ -4386,12 +4722,13 @@ function fileIconSpec(name, isDir) {
4386
4722
  toggle: toggle,
4387
4723
  state: { entries: cache.entries },
4388
4724
  load: load,
4725
+ loadMore: loadMore,
4389
4726
  onSelect: props.onSelect,
4390
4727
  onPin: props.onPin,
4391
4728
  onSelectPath: props.onSelectPath,
4392
4729
  onContextMenu: props.onContextMenu,
4393
4730
  selectedPath: props.selectedPath || '',
4394
- });
4731
+ }));
4395
4732
  }
4396
4733
 
4397
4734
  function TreeSection(props) {
@@ -4713,20 +5050,46 @@ function fileIconSpec(name, isDir) {
4713
5050
  var state = useState(props.value || '');
4714
5051
  var value = state[0];
4715
5052
  var setValue = state[1];
4716
- function submit() { if (value.trim() !== '') props.onSubmit(value.trim()); }
4717
- return h(DialogShell, { title: props.title, onClose: props.onClose },
5053
+ var statusState = useState({ busy: false, error: '' });
5054
+ var status = statusState[0];
5055
+ var setStatus = statusState[1];
5056
+ var pendingRef = useRef(false);
5057
+ var mountedRef = useRef(true);
5058
+ useEffect(function () {
5059
+ mountedRef.current = true;
5060
+ return function () { mountedRef.current = false; };
5061
+ }, []);
5062
+ function close() { if (!pendingRef.current) props.onClose(); }
5063
+ function submit() {
5064
+ if (value.trim() === '' || pendingRef.current) return;
5065
+ pendingRef.current = true;
5066
+ setStatus({ busy: true, error: '' });
5067
+ Promise.resolve().then(function () { return props.onSubmit(value.trim()); }).then(function () {
5068
+ pendingRef.current = false;
5069
+ if (mountedRef.current) setStatus({ busy: false, error: '' });
5070
+ }).catch(function (error) {
5071
+ pendingRef.current = false;
5072
+ if (mountedRef.current) setStatus({ busy: false, error: messageOf(error, props.t, props.locale) });
5073
+ });
5074
+ }
5075
+ return h(DialogShell, { title: props.title, onClose: close },
4718
5076
  h('label', { className: 'rw-dialog-label' }, props.label),
4719
5077
  h('input', {
4720
5078
  className: 'rw-dialog-input',
4721
5079
  value: value,
5080
+ disabled: status.busy,
4722
5081
  autoFocus: true,
4723
5082
  spellCheck: false,
4724
5083
  onChange: function (event) { setValue(event.currentTarget.value); },
4725
- onKeyDown: function (event) { if (event.key === 'Enter') submit(); },
5084
+ onKeyDown: function (event) {
5085
+ if (event.key === 'Enter' && (!event.nativeEvent || !event.nativeEvent.isComposing)) { event.preventDefault(); submit(); }
5086
+ else if (event.key === 'Escape') close();
5087
+ },
4726
5088
  }),
5089
+ status.error ? h(Banner, { kind: 'error' }, status.error) : null,
4727
5090
  h('div', { className: 'rw-dialog-actions' },
4728
- h('button', { type: 'button', className: 'rw-btn rw-btn-sm', onClick: props.onClose }, props.t('cancel')),
4729
- h('button', { type: 'button', className: 'rw-btn rw-btn-sm rw-btn-primary', onClick: submit }, props.t('confirm'))));
5091
+ h('button', { type: 'button', className: 'rw-btn rw-btn-sm', disabled: status.busy, onClick: close }, props.t('cancel')),
5092
+ 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'))));
4730
5093
  }
4731
5094
 
4732
5095
  function ConfirmDialog(props) {
@@ -5055,6 +5418,12 @@ function fileIconSpec(name, isDir) {
5055
5418
  h('div', { className: 'rw-doc-tabs' }, sheets.map(function (sheet, index) {
5056
5419
  return h('button', { key: sheet.name, type: 'button', className: 'rw-doc-tab' + (index === state.sheet ? ' is-on' : ''), onClick: function () { patch({ sheet: index }); } }, sheet.name);
5057
5420
  })),
5421
+ active.truncated
5422
+ ? h('div', { className: 'rw-doc-note' }, t('sheetTruncated', { rows: active.maxRow || 0, cols: active.maxCol || 0 }))
5423
+ : null,
5424
+ (state.data.sheetCount || sheets.length) > sheets.length
5425
+ ? h('div', { className: 'rw-doc-note' }, t('sheetMore'))
5426
+ : null,
5058
5427
  h('div', { style: { flex: '1 1 auto', overflow: 'auto', padding: 8 } },
5059
5428
  h('table', { className: 'rw-doc-grid' },
5060
5429
  h('thead', null, header),
@@ -5644,27 +6013,13 @@ function fileIconSpec(name, isDir) {
5644
6013
  }
5645
6014
 
5646
6015
  function submitNew(side, dirPath, name, isDir) {
5647
- var target = joinChildPath(dirPath, name, side);
5648
- var docKind = documentKindOf(name);
5649
- var promise;
5650
- if (isDir) {
5651
- promise = side === 'remote'
5652
- ? apiCall('fs.mkdir', Object.assign({ path: target }, scopeParams(scopeRef)))
5653
- : apiCall('local.mkdir', { path: dirPath, name: name });
5654
- } else if (docKind) {
5655
- promise = side === 'remote'
5656
- ? apiCall('docs.create', Object.assign({ path: target, kind: docKind }, scopeParams(scopeRef)))
5657
- : apiCall('docs.create', { path: target, kind: docKind });
5658
- } else {
5659
- promise = side === 'remote'
5660
- ? apiCall('fs.write', Object.assign({ path: target, content: '' }, scopeParams(scopeRef)))
5661
- : apiCall('local.newFile', { path: target });
5662
- }
5663
- promise.then(function () {
6016
+ var request = newEntryRequest(side, dirPath, name, isDir, scopeParams(scopeRef));
6017
+ return apiCall(request.method, request.params).then(function () {
6018
+ setDialog(null);
5664
6019
  showNotice('success', t('newDone'));
5665
6020
  refreshTree();
5666
- if (!isDir && docKind) openInternalFile(side, target);
5667
- }).catch(noticeError);
6021
+ if (!isDir && request.kind) openInternalFile(side, request.path);
6022
+ });
5668
6023
  }
5669
6024
 
5670
6025
  function submitChmod(side, targetPath, mode) {
@@ -5679,9 +6034,9 @@ function fileIconSpec(name, isDir) {
5679
6034
  }
5680
6035
 
5681
6036
  function submitDialog(current, value) {
6037
+ if (current.kind === 'new') return submitNew(current.side, current.dir, value, current.isDir);
5682
6038
  setDialog(null);
5683
6039
  if (current.kind === 'rename') submitRenamed(current.side, current.path, value);
5684
- else if (current.kind === 'new') submitNew(current.side, current.dir, value, current.isDir);
5685
6040
  else if (current.kind === 'chmod') submitChmod(current.side, current.path, value);
5686
6041
  }
5687
6042
 
@@ -5741,14 +6096,8 @@ function fileIconSpec(name, isDir) {
5741
6096
  setDialog({ kind: 'chmod', side: menu.side, path: menu.path, title: t('chmodTitle'), label: t('chmodLabel'), value: '644' });
5742
6097
  } });
5743
6098
  if (menu.isDir) {
5744
- items.push({ label: t('ctxNew'), sub: [
5745
- { label: t('ctxNewFolder'), onSelect: function () {
5746
- setDialog({ kind: 'new', side: menu.side, dir: menu.path, isDir: true, title: t('newFolderTitle'), label: t('nameLabel'), value: t('newFolderTitle') });
5747
- } },
5748
- { label: t('ctxNewFile'), onSelect: function () {
5749
- setDialog({ kind: 'new', side: menu.side, dir: menu.path, isDir: false, title: t('newFileTitle'), label: t('nameLabel'), value: t('newFileTitle') });
5750
- } },
5751
- ] });
6099
+ items.push({ separator: true });
6100
+ items = items.concat(newEntryMenuItems(menu, t, setDialog));
5752
6101
  }
5753
6102
  items.push({ separator: true });
5754
6103
  items.push({ label: t('ctxProperties'), onSelect: function () { showProperties(menu.side, menu.path); } });
@@ -5809,11 +6158,12 @@ function fileIconSpec(name, isDir) {
5809
6158
  }
5810
6159
  return h(PromptDialog, {
5811
6160
  t: t,
6161
+ locale: locale,
5812
6162
  title: dialog.title,
5813
6163
  label: dialog.label,
5814
6164
  value: dialog.value,
5815
6165
  onClose: function () { setDialog(null); },
5816
- onSubmit: function (value) { submitDialog(dialog, value); },
6166
+ onSubmit: function (value) { return submitDialog(dialog, value); },
5817
6167
  });
5818
6168
  }
5819
6169
 
@@ -5959,6 +6309,13 @@ function fileIconSpec(name, isDir) {
5959
6309
  path: activeFile.path,
5960
6310
  local: activeFile.side === 'local',
5961
6311
  })
6312
+ : activeDocKind === 'html'
6313
+ ? h(HtmlPreview, {
6314
+ ctx: ctx, t: t, locale: locale,
6315
+ scope: activeFile.side === 'remote' ? sourceScopeOf(selected) : undefined,
6316
+ path: activeFile.path,
6317
+ local: activeFile.side === 'local',
6318
+ })
5962
6319
  : activeDocKind && activeDocKind !== 'csv'
5963
6320
  ? h(DocumentPane, {
5964
6321
  ctx: ctx, t: t, locale: locale,
@@ -6171,6 +6528,7 @@ function fileIconSpec(name, isDir) {
6171
6528
  toggle: toggle,
6172
6529
  state: state,
6173
6530
  load: props.load,
6531
+ loadMore: props.loadMore,
6174
6532
  onSelect: onSelect,
6175
6533
  onPin: onPin,
6176
6534
  onSelectPath: onSelectPath,
@@ -6182,6 +6540,9 @@ function fileIconSpec(name, isDir) {
6182
6540
  children.push(h('button', {
6183
6541
  key: child.path,
6184
6542
  type: 'button',
6543
+ role: 'treeitem',
6544
+ 'aria-level': depth + 1,
6545
+ 'aria-selected': active,
6185
6546
  className: 'rw-row' + (active ? ' is-active' : ''),
6186
6547
  style: { paddingLeft: 6 + (depth + 1) * 12 },
6187
6548
  title: child.path,
@@ -6203,12 +6564,28 @@ function fileIconSpec(name, isDir) {
6203
6564
  h('span', { className: 'rw-row-size' }, formatBytes(child.size))));
6204
6565
  }
6205
6566
  });
6567
+ if (entry.hasMore) {
6568
+ children.push(h('button', {
6569
+ key: 'more',
6570
+ type: 'button',
6571
+ className: 'rw-row-msg rw-load-more',
6572
+ style: { paddingLeft: 6 + (depth + 1) * 12 },
6573
+ disabled: Boolean(entry.loadingMore),
6574
+ onClick: function () { if (props.loadMore) props.loadMore(path); },
6575
+ }, entry.loadingMore ? Spinner(12) : null,
6576
+ entry.moreError
6577
+ ? messageOf(entry.moreError, t, locale)
6578
+ : t('loadMore', { count: Math.max((entry.total || 0) - entry.entries.length, 0) })));
6579
+ }
6206
6580
  }
6207
6581
  }
6208
6582
 
6209
6583
  return h(React.Fragment, null,
6210
6584
  h('button', {
6211
6585
  type: 'button',
6586
+ role: 'treeitem',
6587
+ 'aria-level': depth + 1,
6588
+ 'aria-expanded': isOpen,
6212
6589
  className: 'rw-row' + (selectedPath === path ? ' is-active' : ''),
6213
6590
  style: { paddingLeft: 6 + depth * 12 },
6214
6591
  title: path,
@@ -6216,13 +6593,16 @@ function fileIconSpec(name, isDir) {
6216
6593
  if (onSelectPath) onSelectPath({ path: path, name: name, isDir: true });
6217
6594
  if (event.detail !== 2) toggle(path);
6218
6595
  },
6596
+ onKeyDown: function (event) {
6597
+ if (event.key === 'ArrowRight' && !isOpen) { event.preventDefault(); toggle(path); }
6598
+ else if (event.key === 'ArrowLeft' && isOpen) { event.preventDefault(); toggle(path); }
6599
+ },
6219
6600
  onContextMenu: function (event) {
6220
6601
  if (!onContextMenu) return;
6221
6602
  event.preventDefault();
6222
6603
  event.stopPropagation();
6223
6604
  onContextMenu({ path: path, name: name, isDir: true, event: event });
6224
6605
  },
6225
- 'aria-expanded': isOpen,
6226
6606
  },
6227
6607
  h('span', { className: 'rw-chevron' + (isOpen ? ' is-open' : '') }, IconChevron(12)),
6228
6608
  h(FileGlyph, { name: name, dir: true, open: isOpen, size: 13 }),
@@ -6253,6 +6633,11 @@ function fileIconSpec(name, isDir) {
6253
6633
  });
6254
6634
  }
6255
6635
 
6636
+ function listRequest(path, offset) {
6637
+ return Object.assign({ path: path, limit: TREE_PAGE_SIZE, offset: offset },
6638
+ source.kind === 'linked' ? { linkedId: source.id } : { workspaceId: source.id });
6639
+ }
6640
+
6256
6641
  function load(path) {
6257
6642
  setCache(function (previous) {
6258
6643
  var record = previous.entries.get(path);
@@ -6262,13 +6647,17 @@ function fileIconSpec(name, isDir) {
6262
6647
  entries.set(path, { loading: true, error: null, entries: existing.entries || [], loaded: existing.loaded });
6263
6648
  return { entries: entries };
6264
6649
  });
6265
- var request = source.kind === 'linked'
6266
- ? { linkedId: source.id, path: path }
6267
- : { workspaceId: source.id, path: path };
6268
- apiCall('fs.list', request).then(function (result) {
6650
+ apiCall('fs.list', listRequest(path, 0)).then(function (result) {
6269
6651
  setCache(function (previous) {
6270
6652
  var entries = new Map(previous.entries);
6271
- entries.set(path, { loading: false, error: null, loaded: true, entries: result.entries || [] });
6653
+ entries.set(path, {
6654
+ loading: false,
6655
+ error: null,
6656
+ loaded: true,
6657
+ entries: result.entries || [],
6658
+ total: Number(result.total) || (result.entries || []).length,
6659
+ hasMore: Boolean(result.hasMore),
6660
+ });
6272
6661
  return { entries: entries };
6273
6662
  });
6274
6663
  setExpanded(function (previous) {
@@ -6285,6 +6674,38 @@ function fileIconSpec(name, isDir) {
6285
6674
  });
6286
6675
  }
6287
6676
 
6677
+ function loadMore(path) {
6678
+ var record = cache.entries.get(path);
6679
+ if (!record || record.loadingMore || !record.hasMore) return;
6680
+ setCache(function (previous) {
6681
+ var entries = new Map(previous.entries);
6682
+ var current = entries.get(path);
6683
+ if (current) entries.set(path, Object.assign({}, current, { loadingMore: true }));
6684
+ return { entries: entries };
6685
+ });
6686
+ apiCall('fs.list', listRequest(path, (record.entries || []).length)).then(function (result) {
6687
+ setCache(function (previous) {
6688
+ var entries = new Map(previous.entries);
6689
+ var current = entries.get(path) || { entries: [] };
6690
+ entries.set(path, Object.assign({}, current, {
6691
+ loadingMore: false,
6692
+ loaded: true,
6693
+ entries: current.entries.concat(result.entries || []),
6694
+ total: Number(result.total) || current.total,
6695
+ hasMore: Boolean(result.hasMore),
6696
+ }));
6697
+ return { entries: entries };
6698
+ });
6699
+ }).catch(function (error) {
6700
+ setCache(function (previous) {
6701
+ var entries = new Map(previous.entries);
6702
+ var current = entries.get(path);
6703
+ if (current) entries.set(path, Object.assign({}, current, { loadingMore: false, moreError: error }));
6704
+ return { entries: entries };
6705
+ });
6706
+ });
6707
+ }
6708
+
6288
6709
  useEffect(function () {
6289
6710
  if (!open) return;
6290
6711
  var root = source.remotePath;
@@ -6313,7 +6734,7 @@ function fileIconSpec(name, isDir) {
6313
6734
  h('span', { className: 'rw-tree-ws-sub' }, source.username + '@' + source.host + (source.kind === 'linked' ? ' · ' + t('linkedChip') : ''))),
6314
6735
  h(StatusPill, { status: source.status, t: t })),
6315
6736
  open
6316
- ? h('div', { className: 'rw-tree-body' },
6737
+ ? h('div', { className: 'rw-tree-body', role: 'tree', 'aria-label': source.name },
6317
6738
  h(TreeDirectory, {
6318
6739
  path: source.remotePath,
6319
6740
  name: '/',
@@ -6325,6 +6746,7 @@ function fileIconSpec(name, isDir) {
6325
6746
  toggle: toggle,
6326
6747
  state: { entries: cache.entries },
6327
6748
  load: load,
6749
+ loadMore: loadMore,
6328
6750
  onSelect: props.onSelect,
6329
6751
  selectedPath: props.selectedPath,
6330
6752
  }))
@@ -6601,10 +7023,58 @@ function fileIconSpec(name, isDir) {
6601
7023
  });
6602
7024
  }
6603
7025
 
7026
+ var modalRef = useRef(null);
7027
+ var focusablesOf = function () {
7028
+ var node = modalRef.current;
7029
+ if (!node) return [];
7030
+ return Array.prototype.filter.call(
7031
+ node.querySelectorAll('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])'),
7032
+ function (element) { return !element.disabled && element.offsetParent !== null; });
7033
+ };
7034
+
7035
+ useEffect(function () {
7036
+ if (!props.open) return;
7037
+ var node = modalRef.current;
7038
+ if (!node) return;
7039
+ // Focus the first real field, not the tab strip, so typing works at once.
7040
+ var target = node.querySelector('.rw-modal-body input, .rw-modal-body textarea') || focusablesOf()[0];
7041
+ if (target && typeof target.focus === 'function') target.focus();
7042
+ }, [props.open, flow]);
7043
+
7044
+ function onModalKeyDown(event) {
7045
+ if (event.key === 'Escape') {
7046
+ event.preventDefault();
7047
+ event.stopPropagation();
7048
+ if (props.onCancel) props.onCancel();
7049
+ return;
7050
+ }
7051
+ if (event.key !== 'Tab') return;
7052
+ // Keep Tab inside the dialog: without this the focus escapes to the
7053
+ // page behind the backdrop.
7054
+ var focusables = focusablesOf();
7055
+ if (focusables.length === 0) return;
7056
+ var first = focusables[0];
7057
+ var last = focusables[focusables.length - 1];
7058
+ if (event.shiftKey && document.activeElement === first) {
7059
+ event.preventDefault();
7060
+ last.focus();
7061
+ } else if (!event.shiftKey && document.activeElement === last) {
7062
+ event.preventDefault();
7063
+ first.focus();
7064
+ }
7065
+ }
7066
+
6604
7067
  if (!props.open) return null;
6605
7068
 
6606
7069
  return h('div', { className: 'rw-modal-backdrop', role: 'presentation' },
6607
- h('div', { className: 'rw-modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': t('flowTitle') },
7070
+ h('div', {
7071
+ ref: modalRef,
7072
+ className: 'rw-modal',
7073
+ role: 'dialog',
7074
+ 'aria-modal': 'true',
7075
+ 'aria-label': t('flowTitle'),
7076
+ onKeyDown: onModalKeyDown,
7077
+ },
6608
7078
  h('div', { className: 'rw-modal-head' },
6609
7079
  h('span', { className: 'rw-icon-chip' }, IconLink(16)),
6610
7080
  h('span', { className: 'rw-modal-title' }, t('flowTitle')),