mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-25 21:01:49 +01:00
feat(files): add detailed properties and CodeMirror editor workspace
This commit is contained in:
1 parent
b52b8d7e20
commit
ac6e3dc110
21 files changed
+949
-24
No files matched your search
@@ -72,6 +72,14 @@ Download a single file directly, or select multiple files/folders to download a
|
||||
|
||||
Opening an ordinary file attempts the UTF-8 editor, limited to 5 MiB. Invalid UTF-8, binary data and larger files cannot be edited; use Download instead. Saving checks a content revision. External changes require reloading the file or explicitly confirming overwrite. Unsaved changes require confirmation before closing the editor.
|
||||
|
||||
**Edit as text** is available in the context menu and Actions for every non-directory entry, including images, archives and unknown extensions. It bypasses media preview. Binary data and invalid UTF-8 can be viewed as read-only text (replacement characters may appear); they cannot be saved through the editor. Devices, pipes and files over 5 MiB are rejected. Nothing is executed or converted on disk.
|
||||
|
||||
The offline CodeMirror editor loads only when needed. It provides line numbers, highlighting for JavaScript/TypeScript, JSON, HTML, CSS, Python and Rust, indentation, folding, find/replace, undo/redo, word wrap and Ctrl/Cmd+S. Other formats open as plain text. There are no completion popups, diagnostics, language servers or CDN requests. Headset keyboard input edits the document at the current selection. If loading fails, the ordinary textarea remains available.
|
||||
|
||||
The editor occupies the file workspace, with a document tab and parent path, a compact toolbar, a manual language-mode selector and live line/column status. Saving and closing stay at the top. The status bar reports encoding and line endings; consistent CRLF and CR files retain their line separators when edited. Opening a confirmation dialog preserves the editor's undo history and temporarily blocks hidden input.
|
||||
|
||||
**Properties** reads fresh metadata: path, type, byte size, allocated disk space, owner/group names and numeric IDs, modification/access/metadata-change/creation times (where supported), inode, device and hard-link count. Link properties describe the link itself and show its target and resolved path. Directory size describes the directory entry, not a recursive total. Owned non-symlink entries support a read/write/execute matrix synchronized with a validated three-digit octal mode. Existing special bits are preserved; other users' permissions are not elevated. Saving checks device/inode identity to reject replaced entries.
|
||||
|
||||
## Archives and media
|
||||
|
||||
Create ZIP, TAR or TAR.GZ archives; the output filename selects the format. Choose a destination and conflict behavior when extracting. Archive operations reject absolute paths, parent traversal, symbolic/hard links and special files; extracted contents are never executed. Archive limits are 50,000 entries and 64 GiB expanded data. Cancelling removes staging files and leaves already committed items intact.
|
||||
|
||||
@@ -74,6 +74,14 @@
|
||||
|
||||
普通文件打开时尝试 UTF-8 文本编辑器,上限 5 MiB;非法编码、二进制或超大文件无法编辑,可下载后处理。保存校验内容版本,外部修改后须重新加载或确认覆盖。关闭未保存内容会要求确认。
|
||||
|
||||
所有非目录条目的右键菜单和“操作”中都提供“以文本编辑”,包括图片、压缩包和未知后缀文件,不会先打开媒体预览。二进制或非法 UTF-8 可作为只读文本查看(可能出现替代字符),不能保存;设备、管道和超过 5 MiB 的文件不打开。不执行文件内容,不在磁盘上转换编码。
|
||||
|
||||
离线 CodeMirror 编辑器仅在打开文件时加载,支持行号、JavaScript/TypeScript、JSON、HTML、CSS、Python、Rust 高亮、缩进、折叠、查找替换、撤销重做、自动换行和 Ctrl/Cmd+S。其他格式按纯文本显示。没有补全弹窗、诊断、语言服务或 CDN 请求。头显键盘在当前光标或选区编辑文档;加载失败时仍可使用普通文本框。
|
||||
|
||||
编辑器占满文件管理工作区,提供文件标签及父目录路径、紧凑工具栏、手动语言模式选择和实时行列状态,保存及关闭固定在顶部。底部显示编码和换行格式;统一使用 CRLF 或 CR 的文件编辑后保留其分隔符。确认对话框打开时保留撤销历史,并暂时阻止隐藏编辑区继续接收输入。
|
||||
|
||||
“属性”实时读取完整路径、类型、字节大小、磁盘占用、所有者及所属组名称和数字 ID、修改/访问/状态变更/创建时间(不支持时显示不可用)、Inode、设备编号及硬链接数。符号链接显示链接自身的属性,以及目标和解析路径。目录大小是目录条目自身大小,不是递归统计。当前用户拥有的非符号链接条目可通过读/写/执行矩阵和同步的三位八进制权限修改;保留现有特殊权限位,不自动提权。保存会核对设备及 Inode,拒绝更改已经被替换的条目。
|
||||
|
||||
## 压缩与媒体
|
||||
|
||||
支持创建 ZIP、TAR、TAR.GZ,输出文件名决定格式。解压时选择目标目录和冲突策略。拒绝绝对路径、向上路径穿越、软硬链接及特殊文件,不执行解压内容;最多 50,000 项、展开大小 64 GiB。取消会清理暂存文件,保留已经提交的项目。
|
||||
|
||||
Generated
+292
@@ -12,6 +12,18 @@
|
||||
"examples/showcase"
|
||||
],
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-css": "6.3.1",
|
||||
"@codemirror/lang-html": "6.4.12",
|
||||
"@codemirror/lang-javascript": "6.2.5",
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-rust": "6.0.2",
|
||||
"@codemirror/language": "6.13.1",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.6",
|
||||
"@codemirror/view": "6.43.14",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@tabler/icons-react": "3.48.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
@@ -34,6 +46,172 @@
|
||||
"@framely/sdk": "0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
|
||||
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
|
||||
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-json": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/json": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-python": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
|
||||
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.3.2",
|
||||
"@codemirror/language": "^6.8.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/python": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-rust": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
|
||||
"integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/rust": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
|
||||
"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/streamparser": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
|
||||
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
|
||||
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/streamparser": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
|
||||
"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.14",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
|
||||
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
|
||||
@@ -488,6 +666,102 @@
|
||||
"resolved": "ui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
|
||||
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
|
||||
"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
|
||||
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
|
||||
"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/json": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
|
||||
"integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/python": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz",
|
||||
"integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/rust": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.3.tgz",
|
||||
"integrity": "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
|
||||
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@resvg/resvg-js": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
|
||||
@@ -777,6 +1051,12 @@
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -853,6 +1133,12 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
|
||||
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -867,6 +1153,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"sdk": {
|
||||
"name": "@framely/sdk",
|
||||
"version": "0.4.0",
|
||||
|
||||
@@ -15,6 +15,18 @@
|
||||
"plugin:init": "node tools/plugin-dev.mjs init"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-css": "6.3.1",
|
||||
"@codemirror/lang-html": "6.4.12",
|
||||
"@codemirror/lang-javascript": "6.2.5",
|
||||
"@codemirror/lang-json": "6.0.2",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-rust": "6.0.2",
|
||||
"@codemirror/language": "6.13.1",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.6",
|
||||
"@codemirror/view": "6.43.14",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@tabler/icons-react": "3.48.0",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
|
||||
+12
-3
@@ -1,16 +1,23 @@
|
||||
type Editable=HTMLInputElement|HTMLTextAreaElement;
|
||||
type Editable=HTMLInputElement|HTMLTextAreaElement|HTMLElement;
|
||||
export function installKeyboard(open:(params:{existing:string;password:boolean;multiline:boolean})=>Promise<unknown>){
|
||||
let field:Editable|null=null,last:Editable|null=null,openedAt=0;
|
||||
const request=(target:EventTarget|null)=>{
|
||||
if(!(target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement)||target.disabled||target.readOnly)return;
|
||||
const embedded=target instanceof HTMLElement&&target.matches('[data-framely-editor-input][contenteditable="true"]');
|
||||
if(!embedded&&(!(target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement)||target.disabled||target.readOnly))return;
|
||||
if(!(target instanceof HTMLElement))return;
|
||||
if(target instanceof HTMLInputElement&&!['text','password','url','search','email','number','tel'].includes(target.type))return;
|
||||
field=target;const now=performance.now();if(last===target&&now-openedAt<300)return;last=target;openedAt=now;
|
||||
void open({existing:target.value,password:target instanceof HTMLInputElement&&target.type==='password',multiline:target instanceof HTMLTextAreaElement}).catch(console.error);
|
||||
const params={existing:target instanceof HTMLInputElement||target instanceof HTMLTextAreaElement?target.value:'',password:target instanceof HTMLInputElement&&target.type==='password',multiline:embedded||target instanceof HTMLTextAreaElement};
|
||||
if(embedded)target.dispatchEvent(new CustomEvent('framely.keyboard.open',{detail:params}));
|
||||
void open(params).catch(console.error);
|
||||
};
|
||||
document.addEventListener('focusin',e=>request(e.target));
|
||||
document.addEventListener('pointerdown',e=>request(e.target));
|
||||
(window as any).__framelyCommitKeyboard=(value:string)=>{
|
||||
if(!field?.isConnected)return;
|
||||
if(field.closest('[inert]')||((field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement)&&(field.disabled||field.readOnly)))return;
|
||||
if(!field.dispatchEvent(new CustomEvent('framely.keyboard.commit',{detail:value,cancelable:true})))return;
|
||||
if(!(field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement))return;
|
||||
const proto=field instanceof HTMLTextAreaElement?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;
|
||||
Object.getOwnPropertyDescriptor(proto,'value')!.set!.call(field,value);
|
||||
field.dispatchEvent(new Event('input',{bubbles:true}));field.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
@@ -18,9 +25,11 @@ export function installKeyboard(open:(params:{existing:string;password:boolean;m
|
||||
};
|
||||
(window as any).__framelyEditKeyboard=(edit:string)=>{
|
||||
if(!field?.isConnected)return;
|
||||
if(field.closest('[inert]')||((field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement)&&(field.disabled||field.readOnly)))return;
|
||||
// Embedded editors can consume the native keyboard without mutating their
|
||||
// hidden textarea. Ordinary inputs continue through the path below.
|
||||
if(!field.dispatchEvent(new CustomEvent('framely.keyboard.edit',{detail:edit,cancelable:true})))return;
|
||||
if(!(field instanceof HTMLInputElement||field instanceof HTMLTextAreaElement))return;
|
||||
const value=field.value;
|
||||
let start=field.selectionStart??value.length,end=field.selectionEnd??start;
|
||||
const segments=Array.from(new (Intl as any).Segmenter(undefined,{granularity:'grapheme'}).segment(value)) as {index:number;segment:string}[];
|
||||
|
||||
+118
-11
@@ -9,7 +9,7 @@ use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::{Read, Write},
|
||||
os::unix::fs::{symlink, FileExt, MetadataExt, PermissionsExt},
|
||||
os::{fd::AsRawFd, unix::fs::{symlink, FileExt, MetadataExt, OpenOptionsExt, PermissionsExt}},
|
||||
path::{Component, Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
@@ -68,7 +68,6 @@ fn save_json(path: &Path, v: &Value) -> Result<()> {
|
||||
fs::rename(&temp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ViewPreferences {
|
||||
@@ -190,6 +189,9 @@ impl Drop for Download {
|
||||
}
|
||||
fn entry(p: &Path) -> Result<Value> {
|
||||
let m = fs::symlink_metadata(p)?;
|
||||
entry_metadata(p, &m)
|
||||
}
|
||||
fn entry_metadata(p: &Path, m: &fs::Metadata) -> Result<Value> {
|
||||
let link = m.file_type().is_symlink();
|
||||
let dir = if link {
|
||||
fs::metadata(p).is_ok_and(|m| m.is_dir())
|
||||
@@ -220,6 +222,43 @@ fn entry(p: &Path) -> Result<Value> {
|
||||
json!({"name":p.file_name().unwrap_or_default().to_string_lossy(),"path":p,"directory":dir,"symlink":link,"size":m.len(),"mediaRevision":media_revision,"modified":m.modified().ok().and_then(|t|t.duration_since(UNIX_EPOCH).ok()).map(|t|t.as_secs()),"mode":m.mode()&0o7777,"uid":m.uid(),"gid":m.gid(),"linkTarget":if link{fs::read_link(p).ok()}else{None}}),
|
||||
)
|
||||
}
|
||||
fn account_name(id: u32, group: bool) -> Option<String> {
|
||||
let mut buffer = vec![0u8; 65536];
|
||||
// Reentrant NSS lookups, with numeric IDs retained if no name is available.
|
||||
unsafe {
|
||||
let name = if group {
|
||||
let mut record: libc::group = std::mem::zeroed();
|
||||
let mut found = std::ptr::null_mut();
|
||||
if libc::getgrgid_r(id, &mut record, buffer.as_mut_ptr().cast(), buffer.len(), &mut found) != 0 || found.is_null() { return None; }
|
||||
record.gr_name
|
||||
} else {
|
||||
let mut record: libc::passwd = std::mem::zeroed();
|
||||
let mut found = std::ptr::null_mut();
|
||||
if libc::getpwuid_r(id, &mut record, buffer.as_mut_ptr().cast(), buffer.len(), &mut found) != 0 || found.is_null() { return None; }
|
||||
record.pw_name
|
||||
};
|
||||
(!name.is_null()).then(|| std::ffi::CStr::from_ptr(name).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
fn properties(p: &Path) -> Result<Value> {
|
||||
let m = fs::symlink_metadata(p)?;
|
||||
let mut result = entry_metadata(p, &m)?;
|
||||
let time = |value: std::io::Result<SystemTime>| value.ok().and_then(|v|v.duration_since(UNIX_EPOCH).ok()).map(|v|v.as_secs());
|
||||
result["regular"] = json!(m.is_file());
|
||||
result["accessed"] = json!(time(m.accessed()));
|
||||
result["created"] = json!(time(m.created()));
|
||||
result["changed"] = json!(m.ctime());
|
||||
result["allocated"] = json!(m.blocks().saturating_mul(512));
|
||||
result["inode"] = json!(m.ino().to_string());
|
||||
result["device"] = json!(m.dev().to_string());
|
||||
result["links"] = json!(m.nlink());
|
||||
result["owner"] = json!(account_name(m.uid(), false));
|
||||
result["group"] = json!(account_name(m.gid(), true));
|
||||
result["canChmod"] = json!(!m.file_type().is_symlink() && m.uid() == unsafe {libc::geteuid()});
|
||||
// Metadata describes the link itself, never a silently followed target.
|
||||
result["resolvedPath"] = json!(p.canonicalize().ok());
|
||||
Ok(result)
|
||||
}
|
||||
fn list(p: &Value, home: &Path) -> Result<Value> {
|
||||
let dir = PathBuf::from(
|
||||
p["path"]
|
||||
@@ -304,22 +343,30 @@ fn binary(bytes: &[u8]) -> bool {
|
||||
.any(|byte| (*byte < 0x20 && !matches!(*byte, b'\t' | b'\r' | b'\n')) || *byte == 0x7f)
|
||||
}
|
||||
fn read_text(p: &Path) -> Result<Value> {
|
||||
let m = fs::metadata(p)?;
|
||||
read_text_mode(p, false)
|
||||
}
|
||||
fn read_text_mode(p: &Path, as_text: bool) -> Result<Value> {
|
||||
let file = fs::OpenOptions::new().read(true).custom_flags(libc::O_NONBLOCK).open(p)?;
|
||||
let m = file.metadata()?;
|
||||
ensure!(
|
||||
m.is_file() && m.len() <= 5 * 1024 * 1024,
|
||||
"Text editor supports files up to 5 MiB"
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
fs::File::open(p)?
|
||||
file
|
||||
.take(5 * 1024 * 1024 + 1)
|
||||
.read_to_end(&mut bytes)?;
|
||||
ensure!(
|
||||
bytes.len() <= 5 * 1024 * 1024,
|
||||
"Text editor supports files up to 5 MiB"
|
||||
);
|
||||
ensure!(!binary(&bytes), "Binary files cannot be edited as text");
|
||||
let text = std::str::from_utf8(&bytes).context("Only UTF-8 text can be edited")?;
|
||||
Ok(json!({"text":text,"revision":fingerprint(&bytes)}))
|
||||
let read_only = binary(&bytes) || std::str::from_utf8(&bytes).is_err();
|
||||
if !as_text {
|
||||
ensure!(!binary(&bytes), "Binary files cannot be edited as text");
|
||||
std::str::from_utf8(&bytes).context("Only UTF-8 text can be edited")?;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
Ok(json!({"text":text,"revision":fingerprint(&bytes),"readOnly":read_only}))
|
||||
}
|
||||
fn remove(p: &Path) -> Result<()> {
|
||||
let m = fs::symlink_metadata(p)?;
|
||||
@@ -558,7 +605,7 @@ impl Files {
|
||||
);
|
||||
let operation = p["operation"].as_str().unwrap_or("list");
|
||||
match operation {
|
||||
"list" => list(p, home),"properties" => entry(&path(p,"path")?),
|
||||
"list" => list(p, home),"properties" => properties(&path(p,"path")?),
|
||||
"view.preferences" => {
|
||||
let _guard = self.preferences.lock().unwrap();
|
||||
view_preferences(home, p)
|
||||
@@ -581,7 +628,7 @@ impl Files {
|
||||
json!([])
|
||||
})
|
||||
},
|
||||
"read" => read_text(&path(p,"path")?),
|
||||
"read" => read_text_mode(&path(p,"path")?, p["asText"] == true),
|
||||
"write" => {
|
||||
let file = path(p,"path")?.canonicalize()?;
|
||||
let current = read_text(&file)?;
|
||||
@@ -621,10 +668,17 @@ impl Files {
|
||||
},
|
||||
"chmod" => {
|
||||
let file = path(p,"path")?;
|
||||
ensure!(!fs::symlink_metadata(&file)?.file_type().is_symlink(),"Cannot change symlink permissions");
|
||||
// O_PATH permits changing an owned file even when it has mode
|
||||
// 000. Pin the inode so path replacement cannot redirect chmod.
|
||||
let handle = fs::OpenOptions::new().read(true).custom_flags(libc::O_PATH|libc::O_NOFOLLOW).open(&file)?;
|
||||
let metadata = handle.metadata()?;
|
||||
ensure!(!metadata.file_type().is_symlink(),"Cannot change symlink permissions");
|
||||
if let Some(expected) = p.get("identity") {
|
||||
ensure!(expected["device"] == metadata.dev().to_string() && expected["inode"] == metadata.ino().to_string(), "File changed outside the properties panel; reopen properties");
|
||||
}
|
||||
let mode = p["mode"].as_u64().context("Missing file permissions")?;
|
||||
ensure!(mode<=0o777,"Only normal user permission bits can be changed");
|
||||
fs::set_permissions(file, fs::Permissions::from_mode(mode as u32))?;
|
||||
fs::set_permissions(format!("/proc/self/fd/{}",handle.as_raw_fd()), fs::Permissions::from_mode((metadata.mode()&0o7000)|mode as u32))?;
|
||||
Ok(json!(true))
|
||||
},
|
||||
"trash.list" => Ok(json!(read_trash(home)?.into_iter().map(|t|json!({
|
||||
@@ -1241,6 +1295,59 @@ fn target_resolution(path: &Path, p: &Value) -> Result<Option<PathBuf>> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn detailed_properties_and_safe_permission_changes() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let file = temp.path().join("private.txt");
|
||||
fs::write(&file, "data").unwrap();
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0o2600)).unwrap();
|
||||
let details = properties(&file).unwrap();
|
||||
assert_eq!(details["regular"], true);
|
||||
assert_eq!(details["size"], 4);
|
||||
assert!(details["inode"].is_string() && details["device"].is_string());
|
||||
assert!(details["allocated"].is_number() && details["links"].is_number());
|
||||
assert_eq!(details["canChmod"], true);
|
||||
let manager = Files::default();
|
||||
let identity = json!({"device":details["device"],"inode":details["inode"]});
|
||||
manager.api(temp.path(), &json!({"operation":"chmod","path":file,"mode":0o640,"identity":identity})).unwrap();
|
||||
assert_eq!(fs::metadata(&file).unwrap().mode() & 0o7777, 0o2640);
|
||||
assert!(manager.api(temp.path(), &json!({"operation":"chmod","path":file,"mode":0o1000})).is_err());
|
||||
let replacement = temp.path().join("replacement");
|
||||
fs::write(&replacement, "other").unwrap();
|
||||
fs::rename(&replacement, &file).unwrap();
|
||||
assert!(manager.api(temp.path(), &json!({"operation":"chmod","path":file,"mode":0o777,"identity":identity})).is_err());
|
||||
let link = temp.path().join("link");
|
||||
symlink(&file,&link).unwrap();
|
||||
let details = properties(&link).unwrap();
|
||||
assert_eq!(details["canChmod"], false);
|
||||
assert_eq!(details["symlink"], true);
|
||||
assert!(manager.api(temp.path(), &json!({"operation":"chmod","path":link,"mode":0o777})).is_err());
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0)).unwrap();
|
||||
manager.api(temp.path(), &json!({"operation":"chmod","path":file,"mode":0o600})).unwrap();
|
||||
assert_eq!(fs::metadata(&file).unwrap().mode() & 0o777, 0o600);
|
||||
}
|
||||
#[test]
|
||||
fn explicit_text_preview_never_allows_binary_overwrite() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let file = temp.path().join("unknown.data");
|
||||
fs::write(&file, "const answer = 42;\n").unwrap();
|
||||
assert_eq!(read_text_mode(&file,true).unwrap()["readOnly"], false);
|
||||
for data in [vec![255,254,0,1],b"binary\x01data".to_vec()] {
|
||||
fs::write(&file, &data).unwrap();
|
||||
let preview = read_text_mode(&file,true).unwrap();
|
||||
assert_eq!(preview["readOnly"], true);
|
||||
assert_eq!(preview["revision"], fingerprint(&data));
|
||||
assert!(Files::default().api(temp.path(), &json!({"operation":"write","path":file,"revision":preview["revision"],"text":"new","approve":true})).is_err());
|
||||
assert_eq!(fs::read(&file).unwrap(),data);
|
||||
}
|
||||
let fifo = temp.path().join("pipe");
|
||||
let path = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap();
|
||||
assert_eq!(unsafe {libc::mkfifo(path.as_ptr(),0o600)},0);
|
||||
assert!(read_text_mode(&fifo,true).is_err());
|
||||
let oversized = temp.path().join("large");
|
||||
fs::File::create(&oversized).unwrap().set_len(5*1024*1024+1).unwrap();
|
||||
assert!(read_text_mode(&oversized,true).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn media_revisions_track_replacements_and_symlink_targets() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let image = temp.path().join("sample.png");
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// The actual FileManager/CodeMirror workspace with generated demonstration code.
|
||||
import React from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {FileManager} from '../ui/src/file-manager';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import {Brand} from '../ui/src/icons';
|
||||
import '../ui/src/style.css';
|
||||
configureLanguage('zh-CN',[]);
|
||||
const home='/home/steamos',code=`// 串流配置示例,仅用于界面验证\r\ninterface CaptureSettings {\r\n width: number;\r\n height: number;\r\n fps: number;\r\n codec: 'h264' | 'h265';\r\n}\r\n\r\nconst settings: CaptureSettings = {\r\n width: 2560,\r\n height: 1920,\r\n fps: 90,\r\n codec: 'h265',\r\n};\r\n\r\nfunction resolution(config: CaptureSettings): string {\r\n const { width, height } = config;\r\n return width + ' × ' + height;\r\n}\r\n\r\nexport { settings, resolution };\r\n`;
|
||||
let text=code;
|
||||
window.fetch=async(url,init)=>{
|
||||
if(String(url)==='/api')return new Response(JSON.stringify({result:true}));
|
||||
const p=JSON.parse(String(init?.body));let result:any;
|
||||
if(p.operation==='preferences')result=[];
|
||||
else if(p.operation==='view.preferences')result={view:'list',folders:{}};
|
||||
else if(p.operation==='list')result={path:p.path??home,parent:'/',entries:p.hidden?[]:[{name:'capture.ts',path:home+'/capture.ts',directory:false,symlink:false,size:code.length,mode:420,uid:1000,gid:1000}],total:p.hidden?0:1,roots:[{name:'Home',path:home}]};
|
||||
else if(p.operation==='read')result={text,revision:'example',readOnly:false};
|
||||
else if(p.operation==='write'){text=p.text;result={text,revision:'saved',readOnly:false};}
|
||||
else throw Error('Unexpected operation '+p.operation);
|
||||
return new Response(JSON.stringify({result}));
|
||||
};
|
||||
createRoot(document.getElementById('root')!).render(<div className="app manager"><header className="shell-header"><Brand wordmark/><span>文件</span></header><div className="workspace tools-workspace"><main className="content file-content"><div className="file-workspace-frame"><FileManager/></div></main></div></div>);
|
||||
const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms));
|
||||
const until=async(f:()=>unknown)=>{for(let n=0;n<150;n++){if(f())return;await wait();}throw Error('Timeout '+document.body.innerText);};
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
await until(()=>document.querySelector('.file-list-scroll')?.getAttribute('aria-busy')==='false');document.querySelector<HTMLButtonElement>('.file-name')!.click();await until(()=>document.querySelector('.cm-content[contenteditable=true]'));await wait(250);
|
||||
const workspace=document.querySelector<HTMLElement>('.file-editor-workspace')!,area=workspace.getBoundingClientRect(),root=document.querySelector('.file-manager')!.getBoundingClientRect(),surface=document.querySelector('.file-code-surface')!.getBoundingClientRect();
|
||||
if(area.width<root.width-2||area.height<root.height-2||surface.height<300)throw Error('Editor does not fill workspace');
|
||||
const line=document.querySelector('.cm-line')!.getBoundingClientRect(),number=[...document.querySelectorAll<HTMLElement>('.cm-lineNumbers .cm-gutterElement')].find(e=>e.getBoundingClientRect().height>0&&getComputedStyle(e).visibility!=='hidden')!.getBoundingClientRect();
|
||||
if(Math.abs(line.top-number.top)>2)throw Error(`Gutter is not aligned: ${line.top} / ${number.top}`);
|
||||
if(document.querySelectorAll('.cm-line span').length<8||!document.body.textContent?.includes('CRLF'))throw Error('Syntax or line ending status missing');
|
||||
if(!document.querySelector('.file-layout')?.hasAttribute('inert'))throw Error('Editor left background controls interactive');
|
||||
if(document.querySelector('.cm-tooltip-autocomplete'))throw Error('Unexpected completion popup');
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="语言模式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));const menu=document.querySelector('[role=listbox]')!.getBoundingClientRect();if(menu.bottom>innerHeight||menu.height<100)throw Error('Language dropdown clipped');
|
||||
[...document.querySelectorAll<HTMLButtonElement>('[role=option]')].find(e=>e.textContent==='JSON')!.click();await wait();document.querySelector<HTMLButtonElement>('[aria-label="语言模式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));[...document.querySelectorAll<HTMLButtonElement>('[role=option]')].find(e=>e.textContent==='TypeScript')!.click();await wait(200);
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="语言模式"]')!.click();await until(()=>document.querySelector('[role=listbox]'));document.querySelector('[role=option]')!.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await until(()=>!document.querySelector('[role=listbox]'));if(!document.querySelector('.file-editor-workspace'))throw Error('Language menu Escape closed editor');
|
||||
console.log('FRAMELY_PREVIEW_INSTALL_CODE_EDITOR_ZH');await wait(200);
|
||||
[...document.querySelectorAll<HTMLButtonElement>('.file-code-toolbar button')].find(e=>e.textContent?.includes('查找替换'))!.click();await until(()=>document.querySelector('.file-code-search'));
|
||||
await wait();const searchSurface=document.querySelector('.file-code-surface')!.getBoundingClientRect(),scroller=document.querySelector('.cm-scroller')!.getBoundingClientRect();if(Math.abs(searchSurface.top-scroller.top)>2||Math.abs(searchSurface.height-scroller.height)>2)throw Error('Find widget consumes document space');
|
||||
const search=document.querySelector<HTMLElement>('.file-code-search')!,find=search.querySelector<HTMLInputElement>('input[name=Find]')!,replace=search.querySelector<HTMLInputElement>('input[name=Replace]')!;
|
||||
const set=(input:HTMLInputElement,value:string)=>{input.value=value;input.dispatchEvent(new Event('input',{bubbles:true}));};
|
||||
set(find,'width');await wait();if(!search.querySelector('output')?.textContent?.includes('/ 4'))throw Error('Wrong search match count');
|
||||
find.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));await wait();if(!search.querySelector('output')?.textContent?.startsWith('1 /'))throw Error('Find did not select first result');
|
||||
find.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',shiftKey:true,bubbles:true}));await wait();if(!search.querySelector('output')?.textContent?.startsWith('4 /'))throw Error('Previous did not wrap');
|
||||
search.querySelector<HTMLButtonElement>('[name="Toggle replace"]')!.click();if(replace.closest<HTMLElement>('.file-search-replacement')!.hidden)throw Error('Replace row did not expand');
|
||||
set(replace,'pixels');search.querySelector<HTMLButtonElement>('[name="replace all"]')!.click();await wait();if(!document.querySelector('.cm-content')?.textContent?.includes('pixels')||search.querySelector('output')?.textContent!=='– / 0')throw Error('Replace all failed');
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="撤销"]')!.click();await wait();if(!search.querySelector('output')?.textContent?.includes('/ 4'))throw Error('Undo did not restore matches');
|
||||
search.querySelector<HTMLButtonElement>('[name=regexp]')!.click();set(find,'[');await wait();if(find.getAttribute('aria-invalid')!=='true'||!search.querySelector<HTMLButtonElement>('[name=next]')!.disabled||!search.textContent?.includes('正则表达式无效'))throw Error('Invalid regex not reported');
|
||||
set(find,'width');search.querySelector<HTMLButtonElement>('[name=regexp]')!.click();
|
||||
search.querySelector<HTMLButtonElement>('[name="match case"]')!.click();set(find,'WIDTH');await wait();if(!search.textContent?.includes('没有匹配结果'))throw Error('Case toggle ignored');
|
||||
search.querySelector<HTMLButtonElement>('[name="match case"]')!.click();search.querySelector<HTMLButtonElement>('[name="by word"]')!.click();set(find,'wid');await wait();if(!search.textContent?.includes('没有匹配结果'))throw Error('Whole word toggle ignored');
|
||||
set(find,'');find.focus();(window as any).__framelyCommitKeyboard('width');await wait();if(!search.querySelector('output')?.textContent?.includes('/ 4'))throw Error('Native keyboard did not update search');
|
||||
await wait(1200);console.log('FRAMELY_PREVIEW_INSTALL_CODE_EDITOR_SEARCH_ZH');await wait(200);
|
||||
if(search.getBoundingClientRect().right>innerWidth+1||find.getBoundingClientRect().width<100)throw Error('Find widget is clipped');
|
||||
const status=document.querySelector('.file-editor-status')!;if(status.scrollWidth>status.clientWidth||status.getBoundingClientRect().right>area.right+1)throw Error('Status bar is clipped');
|
||||
find.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await until(()=>!document.querySelector('.file-code-search'));if(!document.querySelector('.file-editor-workspace'))throw Error('Search Escape closed file');
|
||||
const content=document.querySelector<HTMLElement>('.cm-content')!;content.focus();content.dispatchEvent(new KeyboardEvent('keydown',{key:'Home',code:'Home',ctrlKey:true,bubbles:true}));await wait();(window as any).__framelyEditKeyboard('// ');await wait();
|
||||
if(!document.querySelector('.file-editor-status output')?.textContent?.includes('列 4'))throw Error('Cursor status not updated');
|
||||
const [save,close]=[...document.querySelectorAll<HTMLButtonElement>('.file-editor-heading button')];save.click();await until(()=>text.startsWith('// // ')&&save.disabled&&!close.disabled);
|
||||
if(text.replaceAll('\r\n','').includes('\n'))throw Error('Saving changed CRLF line endings');
|
||||
const host=document.createElement('div');host.style.cssText='position:fixed;left:-5000px;width:500px;height:300px';document.body.append(host);let modified=false;
|
||||
const readonly=window.FramelyCodeEditor!.mount({parent:host,path:'readonly.txt',value:'hello hello',readOnly:true,label:'Readonly test',phrases:{},onChange:()=>{modified=true;},onSave:()=>{}});readonly.find();
|
||||
const panel=host.querySelector('.file-code-search')!,readonlyFind=panel.querySelector<HTMLInputElement>('[name=Find]')!;set(readonlyFind,'hello');panel.querySelector<HTMLButtonElement>('[name="Toggle replace"]')!.click();
|
||||
if(!panel.querySelector<HTMLInputElement>('[name=Replace]')!.disabled||!panel.querySelector<HTMLButtonElement>('[name="replace all"]')!.disabled)throw Error('Read-only replacement allowed');
|
||||
readonlyFind.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));if(modified)throw Error('Read-only search mutated text');readonly.destroy();host.remove();
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error);}
|
||||
};
|
||||
console.log('FRAMELY_VIEW_READY');
|
||||
@@ -0,0 +1,30 @@
|
||||
import React,{useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {FileCodeEditor} from '../ui/src/file-code-editor';
|
||||
import {configureLanguage} from '../ui/src/i18n';
|
||||
import {installKeyboard} from '../sdk/src/keyboard';
|
||||
import '../ui/src/style.css';
|
||||
import '../ui/src/file-manager.css';
|
||||
configureLanguage('en-US',[]);installKeyboard(async()=>true);
|
||||
let attempts=0;
|
||||
const append=document.head.append.bind(document.head);
|
||||
document.head.append=(...nodes:(Node|string)[])=>{const script=nodes.find(n=>n instanceof HTMLScriptElement) as HTMLScriptElement|undefined;if(script?.src.endsWith('/assets/code-editor.js')&&attempts++===0){setTimeout(()=>script.dispatchEvent(new Event('error')),20);return;}append(...nodes);};
|
||||
function Fixture(){const [value,setValue]=useState('original'),[readOnly,setReadOnly]=useState(false);return <div className="app"><div className="file-manager"><h1>Editor fallback test</h1><FileCodeEditor path="test.ts" value={value} onChange={setValue} readOnly={readOnly} onSave={()=>{}}/><output id="fallback-state">{value}</output><button onClick={()=>setReadOnly(true)}>Read-only</button></div></div>;}
|
||||
createRoot(document.getElementById('root')!).render(<Fixture/>);
|
||||
const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms));
|
||||
const until=async(f:()=>unknown)=>{for(let n=0;n<150;n++){if(f())return;await wait();}throw Error('Timeout '+document.body.innerText);};
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
await until(()=>document.querySelector('textarea'));
|
||||
const fallback=document.querySelector('textarea')!;fallback.focus();(window as any).__framelyCommitKeyboard('汉😃\nline');await until(()=>document.querySelector('#fallback-state')?.textContent==='汉😃\nline');
|
||||
fallback.setSelectionRange(fallback.value.length,fallback.value.length);
|
||||
(window as any).__framelyEditKeyboard('\b');await until(()=>document.querySelector('#fallback-state')?.textContent==='汉😃\nlin');
|
||||
[...document.querySelectorAll('button')].find(b=>b.textContent==='Retry')!.click();await until(()=>document.querySelector('.cm-content[contenteditable=true]'));
|
||||
if(attempts!==2||document.querySelector('textarea')||!document.querySelector('.cm-content')!.textContent?.includes('lin'))throw Error('Retry lost fallback content');
|
||||
const content=document.querySelector<HTMLElement>('.cm-content')!;content.focus();await wait();(window as any).__framelyEditKeyboard('中文');await wait();
|
||||
if(!document.querySelector('#fallback-state')?.textContent?.includes('中文'))throw Error('Native editor input failed after retry');
|
||||
[...document.querySelectorAll('button')].find(b=>b.textContent==='Read-only')!.click();await until(()=>content.contentEditable==='false');const previous=document.querySelector('#fallback-state')?.textContent;
|
||||
(window as any).__framelyEditKeyboard('not allowed');await wait();if(document.querySelector('#fallback-state')?.textContent!==previous)throw Error('Read-only editor accepted native input');
|
||||
console.log('FRAMELY_BRIDGE_PASS');
|
||||
}catch(error){console.error('FRAMELY_BRIDGE_FAIL '+error+' '+(error as Error).stack);}
|
||||
};
|
||||
console.log('FRAMELY_VIEW_READY');
|
||||
@@ -9,6 +9,7 @@ const until=async(f:()=>unknown)=>{for(let n=0;n<150;n++){if(f())return;await wa
|
||||
const home='/home/steamos',downloads=home+'/Downloads';
|
||||
const longFolder='一个很长的文件夹名称 - Project documents and archived screenshots';
|
||||
let binaryMode=false;
|
||||
let savedText='fixture text',permissionMode=420,failPermissions=false;
|
||||
let preferences:any={view:'list',folders:{[home]:{sort:'size',descending:true},[downloads]:{sort:'modified',descending:false}}};
|
||||
const calls:any[]=[],downloadsClicked:string[]=[];const anchorClick=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){downloadsClicked.push(this.getAttribute('href')??'');};
|
||||
const files=(directory:string)=>[
|
||||
@@ -17,6 +18,7 @@ const files=(directory:string)=>[
|
||||
{name:'notes.txt',path:directory+'/notes.txt',directory:false,symlink:false,size:64,modified:1791350000,mode:420,uid:1000,gid:1000},
|
||||
{name:'movie.webm',path:directory+'/movie.webm',directory:false,symlink:true,size:2048,modified:1791340000,mode:420,uid:1000,gid:1000}];
|
||||
window.fetch=async(url,init)=>{
|
||||
if(String(url)==='/api'){const request=JSON.parse(String(init?.body));calls.push(request);return new Response(JSON.stringify({result:true}));}
|
||||
if(String(url)!=='/manager-api/files')throw Error('Unexpected request '+url);
|
||||
const p=JSON.parse(String(init?.body));calls.push(p);let result:any;
|
||||
if(p.operation==='preferences')result=[];
|
||||
@@ -35,7 +37,10 @@ window.fetch=async(url,init)=>{
|
||||
result={path:directory,parent:'/',entries,total:entries.length,roots:[{name:'Home',path:home},{name:'Downloads',path:downloads}]};
|
||||
}else if(p.operation==='preview'){const canvas=document.createElement('canvas');canvas.width=80;canvas.height=60;const ctx=canvas.getContext('2d')!;ctx.fillStyle='#8bb4ce';ctx.fillRect(0,0,80,60);result={url:p.path.endsWith('.png')?canvas.toDataURL(): 'data:video/webm;base64,AA==',token:p.path};}
|
||||
else if(p.operation==='preview.release')result=true;
|
||||
else if(p.operation==='read'){if(p.path.endsWith('notes.txt'))result={text:'fixture text',revision:'revision'};else return new Response(JSON.stringify({error:'Binary files cannot be edited as text'}));}
|
||||
else if(p.operation==='read'){if(p.path.endsWith('notes.txt'))result={text:savedText,revision:'revision',readOnly:false};else if(p.asText)result={text:'binary\u0000preview',revision:'binary',readOnly:true};else return new Response(JSON.stringify({error:'Binary files cannot be edited as text'}));}
|
||||
else if(p.operation==='write'){savedText=p.text;result={text:savedText,revision:'saved',readOnly:false};}
|
||||
else if(p.operation==='properties'){const e=files(home).find(e=>e.path===p.path)!;result={...e,regular:!e.directory&&!e.symlink,mode:permissionMode,owner:'steamos',group:'steamos',canChmod:!e.symlink,inode:'9007199254740993',device:'2049',links:1,allocated:8192,accessed:1791360000,changed:1791360000,linkTarget:e.symlink?'notes.txt':undefined,resolvedPath:e.symlink?home+'/notes.txt':e.path};}
|
||||
else if(p.operation==='chmod'){if(failPermissions)return new Response(JSON.stringify({error:'Fixture permission denied'}));if(p.identity.inode!=='9007199254740993')throw Error('Properties lost inode precision');permissionMode=p.mode;result=true;}
|
||||
else if(p.operation==='download')result={url:'/fixture-download'};
|
||||
else throw Error('Unexpected operation '+p.operation);
|
||||
return new Response(JSON.stringify({result}));
|
||||
@@ -49,6 +54,17 @@ const listing=()=>calls.filter(p=>p.operation==='list'&&!p.hidden).at(-1);
|
||||
const changeAddress=async(value:string)=>{const input=document.querySelector<HTMLInputElement>('.file-address input')!;Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(input,value);input.dispatchEvent(new Event('input',{bubbles:true}));await wait();input.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}));};
|
||||
(window as any).runInstallReviewChecks=async()=>{try{
|
||||
configureLanguage('en-US',[]);render('one');await until(ready);await wait(200);
|
||||
if(innerWidth<1000){
|
||||
const item=(name:string)=>[...document.querySelectorAll<HTMLButtonElement>('.file-name')].find(e=>e.textContent?.trim()===name)!;
|
||||
const context=async(name:string)=>{await until(ready);item(name).dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:300,clientY:400}));await until(()=>document.querySelector('.file-context-menu'));};
|
||||
await context('notes.txt');button('Properties').click();await until(()=>document.querySelector('.file-properties-modal')?.getAttribute('aria-busy')==='false');
|
||||
if(document.querySelectorAll('.file-permission-grid button').length!==9)throw Error('Mobile permissions missing');await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_FILE_PROPERTIES_EN');await wait(100);button('Close').click();await until(()=>!document.querySelector('.file-properties-modal'));
|
||||
await context('notes.txt');button('Edit as text').click();await until(()=>document.querySelector('.cm-content[contenteditable=true]'));const content=document.querySelector<HTMLElement>('.cm-content')!;content.focus();await wait();(window as any).__framelyEditKeyboard('中文');await wait();if(!content.textContent?.includes('中文'))throw Error('Mobile native editing failed');await wait(100);console.log('FRAMELY_PREVIEW_INSTALL_CODE_EDITOR_EN');await wait(100);button('Save').click();await until(()=>!button('Close').disabled&&button('Save').disabled&&savedText.includes('中文'));button('Close').click();await until(()=>!document.querySelector('.file-editor-modal'));
|
||||
await context('A very long photograph name with spaces.png');await until(()=>!button('Edit as text').disabled);button('Edit as text').click();await until(()=>document.querySelector('.cm-content[contenteditable=false]'));if(!button('Save').disabled)throw Error('Mobile binary preview writable');button('Close').click();await until(()=>!document.querySelector('.file-editor-modal'));
|
||||
configureLanguage('zh-CN',[]);render('mobile-zh');await wait();await until(ready);await context('notes.txt');button('属性').click();await until(()=>document.querySelector('.file-properties-modal')?.getAttribute('aria-busy')==='false');await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_FILE_PROPERTIES_ZH');await wait(100);
|
||||
if(document.documentElement.scrollWidth>innerWidth)throw Error('Mobile page overflow');
|
||||
HTMLAnchorElement.prototype.click=anchorClick;console.log('FRAMELY_BRIDGE_PASS');return;
|
||||
}
|
||||
if(calls.some(p=>p.operation==='preview'))throw Error('List view fetched thumbnails');
|
||||
if(listing().sort!=='size'||listing().descending!==true)throw Error('Home sort not restored on initial canonical path');
|
||||
await until(()=>[...document.querySelectorAll('.file-tree-entry button>span')].some(node=>node.textContent===longFolder));
|
||||
@@ -72,7 +88,34 @@ const changeAddress=async(value:string)=>{const input=document.querySelector<HTM
|
||||
if(listing().sort!=='size')throw Error('Preferences failed across remount');
|
||||
await changeAddress('/alias');await until(()=>ready()&&document.querySelector<HTMLInputElement>('.file-address input')!.value===home);
|
||||
if(listing().sort!=='size')throw Error('Canonical folder preference not restored');
|
||||
const tile=document.querySelector<HTMLButtonElement>('.file-tile-open[title="notes.txt"]')!;tile.click();await until(()=>document.querySelector('.file-editor'));button('Close').click();
|
||||
const tile=document.querySelector<HTMLButtonElement>('.file-tile-open[title="notes.txt"]')!;
|
||||
tile.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:500,clientY:400}));await until(()=>document.querySelector('.file-context-menu'));button('Properties').click();await until(()=>document.querySelector('.file-properties-modal')?.getAttribute('aria-busy')==='false');
|
||||
await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_FILE_PROPERTIES_EN');await wait(100);
|
||||
if(document.querySelectorAll('.file-permission-grid [role=checkbox]').length!==9||!document.querySelector('.file-property-grid')?.textContent?.includes('9007199254740993'))throw Error('Detailed properties missing');
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="Owner: Execute / enter"]')!.click();await wait();
|
||||
if(document.querySelector<HTMLInputElement>('.file-permissions input')!.value!=='744')throw Error('Permission grid and octal mode disagree');
|
||||
const modeInput=document.querySelector<HTMLInputElement>('.file-permissions input')!;
|
||||
const setMode=async(value:string)=>{Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value')!.set!.call(modeInput,value);modeInput.dispatchEvent(new Event('input',{bubbles:true}));await wait();};
|
||||
await setMode('888');if(!button('Save permissions').disabled)throw Error('Invalid octal mode accepted');
|
||||
await setMode('640');failPermissions=true;button('Save permissions').click();await until(()=>document.body.textContent?.includes('Fixture permission denied'));if(!document.querySelector('.file-properties-modal'))throw Error('Permission failure closed panel');
|
||||
failPermissions=false;await wait();button('Save permissions').click();await until(()=>!document.querySelector('.file-properties-modal'));if(permissionMode!==416)throw Error('Permission save failed');
|
||||
tile.click();await until(()=>document.querySelector('.cm-content[contenteditable=true]'));await wait(100);
|
||||
if(!document.querySelector('.cm-lineNumbers')||document.querySelector('.cm-tooltip-autocomplete'))throw Error('Editor features incorrect');
|
||||
await wait(150);console.log('FRAMELY_PREVIEW_INSTALL_CODE_EDITOR_EN');await wait(100);
|
||||
const content=document.querySelector<HTMLElement>('.cm-content')!;content.focus();await wait();(window as any).__framelyEditKeyboard('中文');await wait();if(!content.textContent?.includes('中文'))throw Error('Native keyboard input missing');
|
||||
(window as any).__framelyEditKeyboard('\n');(window as any).__framelyEditKeyboard('\b');await wait();
|
||||
document.querySelector<HTMLButtonElement>('.file-code-toolbar [aria-label="Undo"]')!.click();await wait();document.querySelector<HTMLButtonElement>('.file-code-toolbar [aria-label="Redo"]')!.click();await wait();
|
||||
button('Close').click();await until(()=>document.querySelector('.modal[aria-label="Unsaved changes"]'));button('Cancel').click();await until(()=>!document.querySelector('.modal[aria-label="Unsaved changes"]'));if(document.querySelector('.cm-content')!==content)throw Error('Canceling close discarded editor history');
|
||||
const probe=document.createElement('div');probe.style.cssText='position:fixed;left:-10000px;width:400px;height:200px';document.body.append(probe);
|
||||
const syntax=window.FramelyCodeEditor!.mount({parent:probe,value:'const enabled: boolean = true;\nfunction demo() {\n return enabled;\n}\n',path:'settings.ts',readOnly:false,label:'Syntax test',phrases:{},onChange:()=>{},onSave:()=>{}});await wait(150);
|
||||
if(probe.querySelectorAll('.cm-line span').length<3||!probe.querySelector('.cm-foldGutter'))throw Error('Code highlighting or folding missing');if(document.querySelector('.cm-tooltip-autocomplete'))throw Error('Completion popup enabled');syntax.destroy();probe.remove();
|
||||
button('Find and replace').click();await until(()=>document.querySelector('.cm-search'));document.querySelector<HTMLInputElement>('.cm-search input')!.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));await wait();if(!document.querySelector('.file-editor-modal'))throw Error('Search Escape closed file');
|
||||
button('Save').click();await until(()=>savedText.includes('中文'));await until(()=>!button('Close').disabled);button('Close').click();await until(()=>!document.querySelector('.file-editor-modal'));
|
||||
// Text entry is available even for images, without invoking media preview.
|
||||
await until(ready);document.querySelector<HTMLButtonElement>('.file-tile-open[title="A very long photograph name with spaces.png"]')!.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:500,clientY:400}));await until(()=>document.querySelector('.file-context-menu')&&!button('Edit as text').disabled);button('Edit as text').click();await until(()=>document.querySelector('.cm-content[contenteditable=false]'));
|
||||
if(!button('Save').disabled||!document.body.textContent?.includes('read-only to protect'))throw Error('Binary preview is writable');button('Close').click();await until(()=>!document.querySelector('.file-editor-modal'));
|
||||
document.querySelector<HTMLButtonElement>('.file-tile-open[title="movie.webm"]')!.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:500,clientY:400}));await until(()=>document.querySelector('.file-context-menu'));button('Properties').click();await until(()=>document.querySelector('.file-properties-modal')?.getAttribute('aria-busy')==='false');
|
||||
if(!button('Save permissions').disabled||document.querySelector('.file-permission-grid button:not(:disabled)'))throw Error('Symlink permissions editable');button('Close').click();await until(()=>!document.querySelector('.file-properties-modal'));
|
||||
document.querySelector<HTMLButtonElement>('.file-tile-open[title="A very long photograph name with spaces.png"]')!.click();await until(()=>document.querySelector('.file-media-overlay img'));await until(()=>document.querySelector('.file-media-overlay img')?.getAttribute('src'));document.querySelector<HTMLButtonElement>('.file-media-overlay [aria-label="Close"]')!.click();await until(()=>!document.querySelector('.file-media-overlay'));
|
||||
await wait(400);console.log('FRAMELY_PREVIEW_INSTALL_FILE_MANAGER_ICONS_EN');await wait(300);
|
||||
configureLanguage('zh-CN',[]);render('three');await wait();await until(()=>ready()&&document.querySelector('.icons-medium'));await select('展示方式','大图标');await until(()=>document.querySelector('.icons-large'));
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import {build} from 'esbuild';
|
||||
import {mkdir,copyFile} from 'node:fs/promises';
|
||||
export async function buildCodeEditor(out='ui/dist/assets'){
|
||||
await mkdir(out,{recursive:true});
|
||||
await build({entryPoints:['ui/src/file-code-engine.ts'],bundle:true,minify:true,outfile:out+'/code-editor.js',globalName:'FramelyCodeEditor',format:'iife',target:'chrome140',legalComments:'eof'});
|
||||
// Preserve third-party licenses in the installed assets as well as source.
|
||||
const packages=['@codemirror/view','@codemirror/state','@codemirror/commands','@codemirror/language','@codemirror/search','@codemirror/lang-javascript','@codemirror/lang-json','@codemirror/lang-css','@codemirror/lang-html','@codemirror/lang-python','@codemirror/lang-rust','@codemirror/autocomplete','@codemirror/lint','@lezer/common','@lezer/highlight','@lezer/lr','@lezer/javascript','@lezer/json','@lezer/css','@lezer/html','@lezer/python','@lezer/rust','style-mod','w3c-keyname','crelt'];
|
||||
await mkdir(out+'/code-editor-licenses',{recursive:true});
|
||||
for(const name of packages)await copyFile(`node_modules/${name}/LICENSE`,`${out}/code-editor-licenses/${name.replaceAll('/','-')}.txt`);
|
||||
}
|
||||
if(import.meta.url===new URL(process.argv[1],'file://'+process.cwd()+'/').href)await buildCodeEditor(process.argv[2]);
|
||||
@@ -1,5 +1,7 @@
|
||||
import {build} from 'esbuild';
|
||||
import {mkdir,copyFile,cp} from 'node:fs/promises';
|
||||
import {buildCodeEditor} from './build-code-editor.mjs';
|
||||
await buildCodeEditor();
|
||||
await mkdir('ui/dist/assets',{recursive:true});
|
||||
await build({entryPoints:['ui/src/main.tsx'],bundle:true,minify:true,outfile:'ui/dist/assets/app.js',define:{'process.env.NODE_ENV':'"production"'}});
|
||||
await build({entryPoints:['sdk/src/bootstrap.ts'],bundle:true,minify:true,outfile:'ui/dist/assets/plugin-bootstrap.js'});
|
||||
|
||||
@@ -86,7 +86,7 @@ mkdir -p "$stage/tools/source/assets"
|
||||
cp -a assets/branding "$stage/tools/source/assets/"
|
||||
cp -a examples/showcase "$stage/tools/source/examples/"
|
||||
cp -a ui/src ui/locales ui/index.html ui/package.json "$stage/tools/source/ui/"
|
||||
cp tools/build-ui.mjs tools/build-branding.mjs tools/plugin-dev.mjs "$stage/tools/source/tools/"
|
||||
cp tools/build-ui.mjs tools/build-code-editor.mjs tools/build-branding.mjs tools/plugin-dev.mjs "$stage/tools/source/tools/"
|
||||
cp tools/extract-release.py tools/bootstrap.py tools/cef-runtime.py tools/q2f-runtime.py tools/export-diagnostics.py "$stage/tools/"
|
||||
find "$stage/tools/source" -type d -name __pycache__ -prune -exec rm -rf -- {} +
|
||||
cp -a docs "$stage/share/"
|
||||
|
||||
@@ -12,7 +12,9 @@ export FRAMELY_INSTALL_REVIEW_FIXTURE="${2:-tests/install_review_fixture.tsx}"
|
||||
node --input-type=module - <<'JS'
|
||||
import {build} from 'esbuild';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {buildCodeEditor} from './tools/build-code-editor.mjs';
|
||||
const out=process.env.FRAMELY_INSTALL_REVIEW_OUTPUT;
|
||||
await buildCodeEditor(`${out}/assets`);
|
||||
await build({entryPoints:[process.env.FRAMELY_INSTALL_REVIEW_FIXTURE],bundle:true,outfile:`${out}/fixture.js`,define:{'process.env.NODE_ENV':'"production"'}});
|
||||
await writeFile(`${out}/index.html`,'<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="fixture.css"><div id="root"></div><script src="fixture.js"></script></html>');
|
||||
JS
|
||||
|
||||
@@ -3,6 +3,52 @@
|
||||
"locale": "en-US",
|
||||
"name": "English",
|
||||
"messages": {
|
||||
"以文本编辑": "Edit as text",
|
||||
"文本编辑器": "Text editor",
|
||||
"纯文本": "Plain text",
|
||||
"语言模式": "Language mode",
|
||||
"可编辑": "Editable",
|
||||
"行 {0},列 {1}": "Ln {0}, Col {1}",
|
||||
"选中 {0}": "Selected {0}",
|
||||
"缩进:{0} 个空格": "Indent: {0} spaces",
|
||||
"普通文件": "Regular file",
|
||||
"此文件包含二进制内容或不是 UTF-8,仅以文本预览,不允许保存以避免损坏原文件。": "This file contains binary data or is not UTF-8. Text preview is read-only to protect the original file.",
|
||||
"完整路径": "Full path",
|
||||
"文件类型": "File type",
|
||||
"符号链接": "Symbolic link",
|
||||
"未知": "Unknown",
|
||||
"Inode": "Inode",
|
||||
"特殊文件": "Special file",
|
||||
"目录自身大小(不含内容)": "Directory entry size (excludes contents)",
|
||||
"磁盘占用": "Allocated space",
|
||||
"所有者": "Owner",
|
||||
"所属组": "Group",
|
||||
"访问时间": "Accessed",
|
||||
"状态变更时间": "Metadata changed",
|
||||
"创建时间": "Created",
|
||||
"设备编号": "Device ID",
|
||||
"硬链接数": "Hard links",
|
||||
"链接目标": "Link target",
|
||||
"解析后的路径": "Resolved path",
|
||||
"访问权限": "Permissions",
|
||||
"其他用户": "Others",
|
||||
"读取": "Read",
|
||||
"写入": "Write",
|
||||
"执行/进入": "Execute / enter",
|
||||
"保存权限": "Save permissions",
|
||||
"正在读取属性…": "Reading properties…",
|
||||
"请输入 000 至 777 的八进制权限。": "Enter an octal mode from 000 to 777.",
|
||||
"符号链接权限不可修改;此处显示链接本身的属性。": "Link permissions cannot be changed. These properties describe the link itself.",
|
||||
"当前用户不是文件所有者,无法修改权限。": "The current user does not own this file and cannot change its permissions.",
|
||||
"现有特殊权限位会保留,本面板仅修改读、写、执行权限。": "Existing special bits are preserved. Only read, write and execute permissions are changed.",
|
||||
"File changed outside the properties panel; reopen properties": "The file changed outside this panel. Reopen properties.",
|
||||
"不可用": "Unavailable",
|
||||
"撤销": "Undo",
|
||||
"重做": "Redo",
|
||||
"查找替换": "Find and replace",
|
||||
"自动换行": "Word wrap",
|
||||
"正在加载代码编辑器…": "Loading code editor…",
|
||||
"代码编辑器加载失败,可以使用普通文本编辑。": "The code editor could not load. You can use the plain text editor.",
|
||||
"GitHub 连接失败时自动使用 gh-proxy.com": "Automatically try gh-proxy.com when GitHub cannot be reached",
|
||||
"公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。": "After a network failure, public GitHub requests go through a third-party proxy once. Download verification remains enabled.",
|
||||
"显示录制指示器": "Show recording indicator",
|
||||
|
||||
@@ -3,6 +3,52 @@
|
||||
"locale": "zh-CN",
|
||||
"name": "简体中文",
|
||||
"messages": {
|
||||
"以文本编辑": "以文本编辑",
|
||||
"文本编辑器": "文本编辑器",
|
||||
"纯文本": "纯文本",
|
||||
"语言模式": "语言模式",
|
||||
"可编辑": "可编辑",
|
||||
"行 {0},列 {1}": "行 {0},列 {1}",
|
||||
"选中 {0}": "选中 {0}",
|
||||
"缩进:{0} 个空格": "缩进:{0} 个空格",
|
||||
"普通文件": "普通文件",
|
||||
"此文件包含二进制内容或不是 UTF-8,仅以文本预览,不允许保存以避免损坏原文件。": "此文件包含二进制内容或不是 UTF-8,仅以文本预览,不允许保存以避免损坏原文件。",
|
||||
"完整路径": "完整路径",
|
||||
"文件类型": "文件类型",
|
||||
"符号链接": "符号链接",
|
||||
"未知": "未知",
|
||||
"Inode": "Inode",
|
||||
"特殊文件": "特殊文件",
|
||||
"目录自身大小(不含内容)": "目录自身大小(不含内容)",
|
||||
"磁盘占用": "磁盘占用",
|
||||
"所有者": "所有者",
|
||||
"所属组": "所属组",
|
||||
"访问时间": "访问时间",
|
||||
"状态变更时间": "状态变更时间",
|
||||
"创建时间": "创建时间",
|
||||
"设备编号": "设备编号",
|
||||
"硬链接数": "硬链接数",
|
||||
"链接目标": "链接目标",
|
||||
"解析后的路径": "解析后的路径",
|
||||
"访问权限": "访问权限",
|
||||
"其他用户": "其他用户",
|
||||
"读取": "读取",
|
||||
"写入": "写入",
|
||||
"执行/进入": "执行/进入",
|
||||
"保存权限": "保存权限",
|
||||
"正在读取属性…": "正在读取属性…",
|
||||
"请输入 000 至 777 的八进制权限。": "请输入 000 至 777 的八进制权限。",
|
||||
"符号链接权限不可修改;此处显示链接本身的属性。": "符号链接权限不可修改;此处显示链接本身的属性。",
|
||||
"当前用户不是文件所有者,无法修改权限。": "当前用户不是文件所有者,无法修改权限。",
|
||||
"现有特殊权限位会保留,本面板仅修改读、写、执行权限。": "现有特殊权限位会保留,本面板仅修改读、写、执行权限。",
|
||||
"File changed outside the properties panel; reopen properties": "文件已在属性面板外发生变化,请重新打开属性。",
|
||||
"不可用": "不可用",
|
||||
"撤销": "撤销",
|
||||
"重做": "重做",
|
||||
"查找替换": "查找替换",
|
||||
"自动换行": "自动换行",
|
||||
"正在加载代码编辑器…": "正在加载代码编辑器…",
|
||||
"代码编辑器加载失败,可以使用普通文本编辑。": "代码编辑器加载失败,可以使用普通文本编辑。",
|
||||
"GitHub 连接失败时自动使用 gh-proxy.com": "GitHub 连接失败时自动使用 gh-proxy.com",
|
||||
"公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。": "公开 GitHub 请求在网络失败后将经过第三方代理,最多尝试一次,仍保留下载校验。",
|
||||
"显示录制指示器": "显示录制指示器",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React,{useEffect,useRef,useState} from 'react';
|
||||
import {IconArrowBackUp,IconArrowForwardUp,IconSearch} from '@tabler/icons-react';
|
||||
import type {mount,MountedCodeEditor} from './file-code-engine';
|
||||
import {t,currentLanguage} from './i18n';
|
||||
import {Select} from './localized-select';
|
||||
declare global {interface Window {FramelyCodeEditor?:{mount:typeof mount;languageForPath?:(path:string)=>string};}}
|
||||
let loading:Promise<void>|null=null;
|
||||
function loadEditor(){
|
||||
if(window.FramelyCodeEditor)return Promise.resolve();
|
||||
if(!loading)loading=new Promise<void>((resolve,reject)=>{const script=document.createElement('script');script.src='/assets/code-editor.js';script.onload=()=>{if(window.FramelyCodeEditor)resolve();else{script.remove();loading=null;reject(Error('Code editor failed to initialize'));}};script.onerror=()=>{script.remove();loading=null;reject(Error('Code editor failed to load'));};document.head.append(script);});
|
||||
return loading;
|
||||
}
|
||||
export function FileCodeEditor({path,value,readOnly,onChange,onSave}:{path:string;value:string;readOnly:boolean;onChange:(text:string)=>void;onSave:()=>void}){
|
||||
const parent=useRef<HTMLDivElement>(null),editor=useRef<MountedCodeEditor|null>(null),position=useRef<HTMLOutputElement>(null),latest=useRef({value,readOnly,onChange,onSave});latest.current={value,readOnly,onChange,onSave};
|
||||
const [ready,setReady]=useState(false),[error,setError]=useState(''),[wrap,setWrap]=useState(false),[attempt,setAttempt]=useState(0);
|
||||
const [language,setLanguage]=useState('text');
|
||||
const languages=[{value:'text',label:t('纯文本')},{value:'javascript',label:'JavaScript'},{value:'typescript',label:'TypeScript'},{value:'json',label:'JSON'},{value:'html',label:'HTML'},{value:'css',label:'CSS'},{value:'python',label:'Python'},{value:'rust',label:'Rust'}];
|
||||
useEffect(()=>{let alive=true;setReady(false);setError('');void loadEditor().then(()=>{
|
||||
if(!alive||!parent.current)return;
|
||||
const phrases:Record<string,string>=currentLanguage().startsWith('zh')?{Find:'查找',Replace:'替换',next:'下一个',previous:'上一个',all:'全部','match case':'区分大小写','by word':'全词匹配',regexp:'正则表达式',replace:'替换','replace all':'全部替换',close:'关闭','go to line':'跳转到行',go:'跳转','Toggle replace':'展开或收起替换','Invalid regular expression':'正则表达式无效','No results':'没有匹配结果','Read-only document':'只读文档,无法替换'}:{};
|
||||
setLanguage(window.FramelyCodeEditor!.languageForPath?.(path)??'text');
|
||||
editor.current=window.FramelyCodeEditor!.mount({parent:parent.current,path,value:latest.current.value,readOnly:latest.current.readOnly,label:t('文本内容'),phrases,onChange:text=>latest.current.onChange(text),onSave:()=>latest.current.onSave(),onCursor:status=>{if(position.current)position.current.textContent=t('行 {0},列 {1}',{0:status.line,1:status.column})+(status.selected?` / ${t('选中 {0}',{0:status.selected})}`:'');}});setReady(true);
|
||||
}).catch(e=>{if(alive)setError(String(e));});return()=>{alive=false;editor.current?.destroy();editor.current=null;};},[path,attempt]);
|
||||
useEffect(()=>{editor.current?.setValue(value);},[value,ready]);
|
||||
useEffect(()=>{editor.current?.setReadOnly(readOnly);},[readOnly,ready]);
|
||||
useEffect(()=>{editor.current?.wrap(wrap);},[wrap,ready]);
|
||||
useEffect(()=>{editor.current?.setLanguage(language);},[language,ready]);
|
||||
return <div className="file-code-editor" onKeyDownCapture={e=>{if(e.key==='Escape'&&editor.current?.closeSearch())e.stopPropagation();}} onKeyDown={e=>{if(e.key==='Escape'&&(e.target as HTMLElement).closest('.framely-select'))e.stopPropagation();}}>
|
||||
<div className="file-code-toolbar"><div className="file-code-history"><button disabled={!ready||readOnly} onClick={()=>editor.current?.undo()} aria-label={t('撤销')} title={t('撤销')}><IconArrowBackUp size={18}/></button><button disabled={!ready||readOnly} onClick={()=>editor.current?.redo()} aria-label={t('重做')} title={t('重做')}><IconArrowForwardUp size={18}/></button></div><button disabled={!ready} onClick={()=>editor.current?.find()}><IconSearch size={18}/>{t('查找替换')}</button><button disabled={!ready} aria-pressed={wrap} onClick={()=>setWrap(v=>!v)}>{t('自动换行')}</button><Select label={t('语言模式')} options={languages} value={language} disabled={!ready} onChange={setLanguage}/></div>
|
||||
{!ready&&!error&&<p role="status">{t('正在加载代码编辑器…')}</p>}
|
||||
{error&&<div className="file-code-fallback" role="alert"><div><p>{t('代码编辑器加载失败,可以使用普通文本编辑。')}</p><button onClick={()=>setAttempt(v=>v+1)}>{t('重试')}</button></div><textarea aria-label={t('文本内容')} className="file-editor" readOnly={readOnly} value={value} onChange={e=>onChange(e.target.value)}/></div>}
|
||||
<div className="file-code-surface file-editor" ref={parent} hidden={!ready}/>
|
||||
<div className="file-editor-status"><span>{readOnly?t('只读'):t('可编辑')}</span><output ref={position}>{t('行 {0},列 {1}',{0:1,1:1})}</output><span>UTF-8</span><span>{value.includes('\r\n')?'CRLF':value.includes('\r')&&!value.includes('\n')?'CR':'LF'}</span><span className="file-editor-indent">{t('缩进:{0} 个空格',{0:2})}</span></div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Separately bundled and loaded only when the text editor opens. No language
|
||||
// servers, autocomplete, lint diagnostics or network requests are installed.
|
||||
import {EditorState,Compartment,countColumn,Text} from '@codemirror/state';
|
||||
import {EditorView,keymap,lineNumbers,highlightActiveLineGutter,highlightSpecialChars,drawSelection,highlightActiveLine} from '@codemirror/view';
|
||||
import {history,historyKeymap,defaultKeymap,indentWithTab,undo,redo,deleteCharBackward,cursorCharLeft,cursorCharRight,cursorLineUp,cursorLineDown,insertNewlineAndIndent} from '@codemirror/commands';
|
||||
import {foldGutter,foldKeymap,indentOnInput,indentUnit,bracketMatching,syntaxHighlighting,HighlightStyle} from '@codemirror/language';
|
||||
import {tags} from '@lezer/highlight';
|
||||
import {search,searchKeymap,highlightSelectionMatches,openSearchPanel,closeSearchPanel} from '@codemirror/search';
|
||||
import {createSearchPanel} from './file-code-search';
|
||||
import {javascript} from '@codemirror/lang-javascript';
|
||||
import {json} from '@codemirror/lang-json';
|
||||
import {css} from '@codemirror/lang-css';
|
||||
import {html} from '@codemirror/lang-html';
|
||||
import {python} from '@codemirror/lang-python';
|
||||
import {rust} from '@codemirror/lang-rust';
|
||||
export type CursorStatus={line:number;column:number;selected:number;lines:number};
|
||||
export type CodeEditorOptions={parent:HTMLElement;value:string;path:string;readOnly:boolean;label:string;phrases:Record<string,string>;onChange:(text:string)=>void;onSave:()=>void;onCursor?:(status:CursorStatus)=>void};
|
||||
function language(mode:string){switch(mode){case 'javascript':return javascript({jsx:true});case 'typescript':return javascript({typescript:true,jsx:true});case 'json':return json();case 'css':return css();case 'html':return html();case 'python':return python();case 'rust':return rust();default:return [];}}
|
||||
export function languageForPath(path:string){const ext=path.split('.').pop()?.toLowerCase()??'';return ({js:'javascript',mjs:'javascript',cjs:'javascript',jsx:'javascript',ts:'typescript',tsx:'typescript',json:'json',css:'css',html:'html',htm:'html',py:'python',rs:'rust'} as Record<string,string>)[ext]??'text';}
|
||||
const separator=(value:string)=>value.includes('\r\n')?'\r\n':value.includes('\r')&&!value.includes('\n')?'\r':'\n';
|
||||
const highlighting=HighlightStyle.define([{tag:tags.keyword,color:'#c9a3e8'},{tag:tags.string,color:'#b6cea4'},{tag:[tags.number,tags.bool,tags.null],color:'#dfbe91'},{tag:tags.typeName,color:'#8ecbd1'},{tag:tags.function(tags.variableName),color:'#e0d0a0'},{tag:tags.comment,color:'#909f8c',fontStyle:'italic'},{tag:[tags.propertyName,tags.attributeName],color:'#a5c9eb'},{tag:tags.tagName,color:'#a5c9eb'},{tag:tags.operator,color:'#c5cbd5'}]);
|
||||
export function mount(options:CodeEditorOptions){
|
||||
const editable=new Compartment(),wrapping=new Compartment(),syntax=new Compartment(),endings=new Compartment();let updating=false;
|
||||
const cursor=(state:EditorState)=>{const selection=state.selection.main,line=state.doc.lineAt(selection.head);options.onCursor?.({line:line.number,column:countColumn(state.sliceDoc(line.from,selection.head),state.tabSize)+1,selected:selection.to-selection.from,lines:state.doc.lines});};
|
||||
const view=new EditorView({parent:options.parent,state:EditorState.create({doc:options.value,extensions:[
|
||||
lineNumbers(),highlightActiveLineGutter(),highlightSpecialChars(),history(),foldGutter(),drawSelection(),indentOnInput(),bracketMatching(),syntaxHighlighting(highlighting),highlightActiveLine(),highlightSelectionMatches(),syntax.of(language(languageForPath(options.path))),indentUnit.of(' '),EditorState.tabSize.of(4),endings.of(EditorState.lineSeparator.of(separator(options.value))),
|
||||
editable.of([EditorState.readOnly.of(options.readOnly),EditorView.editable.of(!options.readOnly)]),wrapping.of([]),
|
||||
search({createPanel:createSearchPanel}),EditorState.phrases.of(options.phrases),EditorView.contentAttributes.of({'aria-label':options.label,'data-framely-editor-input':''}),
|
||||
keymap.of([{key:'Mod-s',run:()=>{options.onSave();return true;}},...defaultKeymap,...historyKeymap,...searchKeymap,...foldKeymap,indentWithTab]),
|
||||
EditorView.updateListener.of(update=>{if(update.docChanged&&!updating)options.onChange(update.state.sliceDoc());if(update.docChanged||update.selectionSet)cursor(update.state);}),
|
||||
EditorView.theme({'&':{height:'100%',backgroundColor:'#1c1e22',color:'#dce1e8',fontSize:'14px'},'.cm-scroller':{fontFamily:'ui-monospace,SFMono-Regular,Consolas,monospace',overflow:'auto',lineHeight:'1.7'},'.cm-content':{padding:'18px 0'},'.cm-line':{padding:'0 20px'},'.cm-gutters':{backgroundColor:'#1c1e22',color:'#7f8998',borderRight:'none',padding:'0 4px 0 12px'},'.cm-activeLineGutter':{backgroundColor:'transparent',color:'#e0e6ef'},'.cm-activeLine':{backgroundColor:'#ffffff05'},'.cm-cursor':{borderLeftColor:'#b9d8f4',borderLeftWidth:'2px'},'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection':{backgroundColor:'#42638480'},'.cm-panels':{backgroundColor:'#25282f',color:'#e0e2e6'},'.cm-textfield':{backgroundColor:'#1c1d20',color:'#e0e2e6',border:'1px solid #656b76'},'.cm-button':{backgroundImage:'none',backgroundColor:'#35383e',color:'#e0e2e6',border:'1px solid #656b76'}},{dark:true})
|
||||
]})});
|
||||
const input=view.contentDOM;
|
||||
const open=(event:Event)=>{(event as CustomEvent).detail.existing=view.state.sliceDoc(view.state.selection.main.from,view.state.selection.main.to);};
|
||||
const commit=(event:Event)=>{event.preventDefault();if(!view.state.readOnly)view.dispatch(view.state.replaceSelection(String((event as CustomEvent).detail)));};
|
||||
const edit=(event:Event)=>{
|
||||
event.preventDefault();if(view.state.readOnly)return;const value=String((event as CustomEvent).detail);
|
||||
const commands:Record<string,(v:EditorView)=>boolean>={'\b':deleteCharBackward,'\x1b[D':cursorCharLeft,'\x1b[C':cursorCharRight,'\x1b[A':cursorLineUp,'\x1b[B':cursorLineDown,'\n':insertNewlineAndIndent,'\r':insertNewlineAndIndent};
|
||||
if(commands[value])commands[value](view);else if(value&&value.charCodeAt(0)>=32)view.dispatch(view.state.replaceSelection(value));
|
||||
view.scrollDOM.querySelector('.cm-cursor')?.scrollIntoView({block:'nearest'});
|
||||
};
|
||||
input.addEventListener('framely.keyboard.open',open);input.addEventListener('framely.keyboard.commit',commit);input.addEventListener('framely.keyboard.edit',edit);
|
||||
cursor(view.state);
|
||||
return {
|
||||
setValue(value:string){if(value!==view.state.sliceDoc()){updating=true;try{view.dispatch({changes:{from:0,to:view.state.doc.length,insert:Text.of(value.split(/\r\n|\r|\n/))},effects:endings.reconfigure(EditorState.lineSeparator.of(separator(value)))});}finally{updating=false;}}},
|
||||
setLanguage(mode:string){view.dispatch({effects:syntax.reconfigure(language(mode))});},
|
||||
setReadOnly(readOnly:boolean){view.dispatch({effects:editable.reconfigure([EditorState.readOnly.of(readOnly),EditorView.editable.of(!readOnly)])});},
|
||||
wrap(enabled:boolean){view.dispatch({effects:wrapping.reconfigure(enabled?EditorView.lineWrapping:[])});},
|
||||
undo(){undo(view);},redo(){redo(view);},find(){openSearchPanel(view);},closeSearch(){const closed=closeSearchPanel(view);if(closed)view.focus();return closed;},
|
||||
destroy(){input.removeEventListener('framely.keyboard.open',open);input.removeEventListener('framely.keyboard.commit',commit);input.removeEventListener('framely.keyboard.edit',edit);view.destroy();}
|
||||
};
|
||||
}
|
||||
export type MountedCodeEditor=ReturnType<typeof mount>;
|
||||
@@ -0,0 +1,55 @@
|
||||
import {EditorView,runScopeHandlers,type Panel,type ViewUpdate} from '@codemirror/view';
|
||||
import {SearchQuery,getSearchQuery,setSearchQuery,findNext,findPrevious,replaceNext,replaceAll,closeSearchPanel} from '@codemirror/search';
|
||||
|
||||
// A compact editor find widget. Matching, replacement and undo remain owned by
|
||||
// CodeMirror, so regex captures, word boundaries and document edits stay in sync.
|
||||
export function createSearchPanel(view:EditorView):Panel {
|
||||
const dom=document.createElement('div');dom.className='cm-search file-code-search';
|
||||
const phrase=(key:string)=>view.state.phrase(key);
|
||||
const row=(name:string)=>{const el=document.createElement('div');el.className=name;dom.append(el);return el;};
|
||||
const main=row('file-search-row'),replacement=row('file-search-row file-search-replacement');
|
||||
const icons:Record<string,string>={previous:'M12 19V5m-6 6 6-6 6 6',next:'M12 5v14m-6-6 6 6 6-6',close:'m6 6 12 12M6 18 18 6','Toggle replace':'m9 5 7 7-7 7'};
|
||||
const button=(parent:HTMLElement,name:string,glyph:string,action:()=>void)=>{const el=document.createElement('button');el.type='button';el.name=name;
|
||||
if(icons[name]){const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 24 24');svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','1.7');svg.setAttribute('aria-hidden','true');const path=document.createElementNS(svg.namespaceURI,'path');path.setAttribute('d',icons[name]);svg.append(path);el.append(svg);}else el.textContent=glyph;
|
||||
el.title=phrase(name);el.setAttribute('aria-label',phrase(name));el.addEventListener('click',action);parent.append(el);return el;};
|
||||
const field=(parent:HTMLElement,name:string)=>{const el=document.createElement('input');el.type='text';el.name=name;el.placeholder=phrase(name);el.setAttribute('aria-label',phrase(name));el.autocomplete='off';el.spellcheck=false;parent.append(el);return el;};
|
||||
let expanded=false,query=getSearchQuery(view.state),matches:{from:number;to:number}[]=[],truncated=false;
|
||||
const expand=button(main,'Toggle replace','›',()=>{expanded=!expanded;refresh();view.requestMeasure();});expand.setAttribute('aria-expanded','false');
|
||||
const inputGroup=document.createElement('div');inputGroup.className='file-search-input';main.append(inputGroup);
|
||||
const input=field(inputGroup,'Find');input.setAttribute('main-field','true');
|
||||
const options=document.createElement('div');options.className='file-search-options';inputGroup.append(options);
|
||||
const toggles=(['caseSensitive','wholeWord','regexp'] as const).map((key,index)=>{const el=button(options,['match case','by word','regexp'][index],['Aa','ab','.*'][index],()=>{commit({[key]:!query[key]});input.focus();});return {key,el};});
|
||||
const count=document.createElement('output');count.className='file-search-count';count.setAttribute('aria-live','polite');main.append(count);
|
||||
const previous=button(main,'previous','↑',()=>findPrevious(view)),next=button(main,'next','↓',()=>findNext(view));
|
||||
button(main,'close','×',()=>{closeSearchPanel(view);view.focus();});
|
||||
const replaceInput=field(replacement,'Replace');
|
||||
const one=button(replacement,'replace',phrase('Replace'),()=>replaceNext(view)),all=button(replacement,'replace all',phrase('replace all'),()=>replaceAll(view));
|
||||
const message=row('file-search-message');message.setAttribute('role','status');
|
||||
function commit(change:Partial<{caseSensitive:boolean;wholeWord:boolean;regexp:boolean}>={}) {
|
||||
const nextQuery=new SearchQuery({...query,search:input.value,replace:replaceInput.value,...change});
|
||||
if(!nextQuery.eq(query))view.dispatch({effects:setSearchQuery.of(nextQuery)});
|
||||
}
|
||||
function scan(){matches=[];truncated=false;if(query.valid){const cursor=query.getCursor(view.state);for(let step=cursor.next();!step.done;step=cursor.next()){if(matches.length===10000){truncated=true;break;}matches.push(step.value);}}}
|
||||
function refresh(){
|
||||
input.value=query.search;replaceInput.value=query.replace;
|
||||
for(const {key,el} of toggles)el.setAttribute('aria-pressed',String(query[key]));
|
||||
expand.setAttribute('aria-expanded',String(expanded));
|
||||
replacement.hidden=!expanded;replaceInput.disabled=view.state.readOnly;
|
||||
const selection=view.state.selection.main,index=matches.findIndex(m=>m.from===selection.from&&m.to===selection.to);
|
||||
const invalid=!!query.search&&!query.valid;
|
||||
input.setAttribute('aria-invalid',String(invalid));
|
||||
count.textContent=query.search?(invalid?'—':`${index<0?'–':index+1} / ${matches.length}${truncated?'+':''}`):'';
|
||||
previous.disabled=next.disabled=!matches.length;
|
||||
one.disabled=all.disabled=view.state.readOnly||!matches.length;
|
||||
message.textContent=invalid?phrase('Invalid regular expression'):query.search&&!matches.length?phrase('No results'):expanded&&view.state.readOnly?phrase('Read-only document'):'';
|
||||
message.hidden=!message.textContent;
|
||||
}
|
||||
input.addEventListener('input',()=>commit());replaceInput.addEventListener('input',()=>commit());
|
||||
dom.addEventListener('keydown',event=>{
|
||||
if(event.key==='Escape'){event.preventDefault();event.stopPropagation();closeSearchPanel(view);view.focus();}
|
||||
else if(runScopeHandlers(view,event,'search-panel')){event.preventDefault();event.stopPropagation();}
|
||||
else if(event.key==='Enter'&&!event.isComposing){event.preventDefault();event.stopPropagation();if(event.target===replaceInput){if(!view.state.readOnly)(event.altKey?replaceAll:replaceNext)(view);}else(event.shiftKey?findPrevious:findNext)(view);}
|
||||
});
|
||||
scan();refresh();
|
||||
return {dom,top:true,update(update:ViewUpdate){const nextQuery=getSearchQuery(update.state),changed=!query.eq(nextQuery);query=nextQuery;if(changed||update.docChanged)scan();refresh();}};
|
||||
}
|
||||
@@ -139,6 +139,15 @@
|
||||
.file-manager .file-preview img,.file-manager .file-preview video{max-width:100%;max-height:60vh;object-fit:contain}
|
||||
.file-manager .file-editor{width:100%;resize:vertical}
|
||||
.file-manager .file-details{overflow-wrap:anywhere}
|
||||
.file-manager .modal.file-properties-modal{left:max(16px,calc((100% - 720px)/2));right:max(16px,calc((100% - 720px)/2))}
|
||||
.file-properties-heading{display:flex;align-items:center;gap:16px;margin-bottom:20px}.file-properties-heading>div{min-width:0}.file-manager .file-properties-heading h2{margin:0 0 4px;overflow-wrap:anywhere}.file-properties-heading p{margin:0;color:var(--file-muted)}
|
||||
.file-property-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px 24px;margin:0;padding:18px 0;border-top:1px solid var(--file-line)}
|
||||
.file-property-grid>div:first-child{grid-column:1/-1}.file-property-grid dt{font-size:12px;color:var(--file-muted);margin-bottom:5px}.file-property-grid dd{margin:0;font-size:14px;overflow-wrap:anywhere;user-select:text}.file-property-grid code{font:13px/1.6 ui-monospace,monospace}
|
||||
.file-permissions{padding-top:18px;border-top:1px solid var(--file-line)}.file-manager .file-permissions h3{display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:16px;margin:0 0 16px}.file-permissions h3 code{font:12px ui-monospace,monospace;color:var(--file-muted);margin-left:auto}
|
||||
.file-permission-grid{display:grid;grid-template-columns:minmax(80px,1.1fr) repeat(3,minmax(44px,1fr));gap:6px;align-items:center}.file-permission-grid>span{text-align:center;font-size:12px;color:var(--file-muted)}.file-permission-grid>strong{font-size:13px;font-weight:500}.file-manager .file-permission-grid button{min-height:44px;margin:0;border:1px solid var(--file-line);border-radius:8px}.file-permission-grid [aria-checked=true]{background:#d7dce0;color:#202125}.file-manager .file-permissions label{display:grid;grid-template-columns:1fr 120px;align-items:center;margin-top:16px}.file-manager .file-permissions input{font:15px ui-monospace,monospace}.file-manager .file-permissions p{color:var(--file-muted);font-size:12px;margin:8px 0}
|
||||
.file-manager .file-editor-path{font:12px ui-monospace,monospace;color:var(--file-muted);overflow-wrap:anywhere;user-select:text;margin:-8px 0 12px}
|
||||
.file-code-editor{border:1px solid var(--file-line);border-radius:8px;overflow:hidden;min-width:0}.file-code-toolbar{display:flex;align-items:center;gap:6px;padding:6px;background:#292b30;flex-wrap:wrap}.file-manager .file-code-toolbar button{min-height:44px;padding:8px 10px;margin:0;font-size:13px;border-radius:6px}.file-code-toolbar span{font:12px ui-monospace,monospace;color:var(--file-muted);margin-left:auto;padding:0 8px}.file-manager .modal .file-code-surface{height:clamp(240px,48dvh,600px);min-height:0;resize:none}.file-code-surface .cm-editor.cm-focused{outline:2px solid var(--blue);outline-offset:-2px}.file-manager .file-code-surface .cm-content{outline:none;min-height:100%;white-space:pre}.file-manager .file-code-surface .cm-lineWrapping{white-space:pre-wrap}.file-manager .file-code-surface .cm-panels input{width:auto;min-height:32px;margin:4px;font-size:13px;padding:5px}.file-manager .file-code-surface .cm-panels input[type=checkbox]{width:18px;min-height:18px;height:18px}.file-manager .file-code-surface .cm-panels label{display:inline-flex;gap:4px;margin:4px;font-size:12px}.file-manager .file-code-surface .cm-panels button{min-height:32px;margin:4px;font-size:13px}.file-code-editor>p{padding:20px}
|
||||
@media(max-width:480px){.file-property-grid{grid-template-columns:minmax(0,1fr);gap:14px}.file-permissions h3 code{margin-left:0}.file-code-toolbar span{display:none}}
|
||||
@media(prefers-reduced-motion:reduce){.file-manager button:active:not(:disabled){transform:none}.file-manager *{transition:none!important;animation:none!important}}
|
||||
|
||||
.file-manager .file-address,.file-manager .file-search{margin:0;font-size:13px}
|
||||
@@ -332,3 +341,41 @@
|
||||
@media(max-width:767px){.file-upload-confirm h2{margin-bottom:20px}.file-upload-review .file-upload-destination code{font-size:16px}.file-upload-policies label{min-height:70px}.file-upload-summary{margin-bottom:20px}}
|
||||
|
||||
.file-manager:has(.modal) .file-upload-queue{visibility:hidden}
|
||||
/* The editor replaces the file workspace, not a generic floating form. */
|
||||
.file-manager .file-editor-workspace{position:absolute;inset:0;z-index:12;display:flex;flex-direction:column;min-width:0;min-height:0;background:#1c1e22;color:#dce1e8;overflow:hidden;border:0;border-radius:0;padding:0;box-shadow:none}
|
||||
.file-manager .file-editor-workspace[hidden]{display:none}
|
||||
.file-editor-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:56px;padding:8px 16px;background:#23262c;border-bottom:1px solid #343941;flex:none}
|
||||
.file-editor-heading>div{display:flex;align-items:center;gap:10px;min-width:0}.file-editor-heading strong{font-size:14px;font-weight:550}.file-editor-heading>div:first-child>svg{color:#a5c9eb}
|
||||
.file-manager .file-editor-heading button{min-height:40px;border:1px solid #454c57;border-radius:5px;padding:8px 13px;font-size:13px;background:transparent}.file-manager .file-editor-heading .file-editor-save{background:#a5c9eb;color:#182330;border-color:#a5c9eb}
|
||||
.file-editor-tabs{display:flex;align-items:center;gap:20px;min-height:44px;background:#181a1e;border-bottom:1px solid #343941;flex:none;overflow:hidden}
|
||||
.file-editor-tab{align-self:stretch;display:flex;align-items:center;gap:10px;padding:10px 18px;border-top:2px solid #a5c9eb;border-right:1px solid #343941;background:#1c1e22;min-width:120px;max-width:60%;flex:none}.file-editor-tab>svg{color:#a5c9eb}.file-editor-tab strong{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-editor-dirty{width:7px;height:7px;border-radius:50%;background:#dce1e8;flex:none}
|
||||
.file-editor-tabs>code{font:12px ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#9ca6b6;padding-right:16px;user-select:text}
|
||||
.file-manager .file-editor-workspace>.error,.file-manager .file-editor-workspace>.banner{font-size:13px;padding:10px 18px;margin:0;border-radius:0;flex:none}
|
||||
.file-editor-workspace .file-code-editor{display:flex;flex-direction:column;flex:1;min-height:0;border:0;border-radius:0;background:#1c1e22;overflow:hidden}
|
||||
.file-editor-workspace .file-code-toolbar{flex:none;position:relative;z-index:2;padding:5px 12px;gap:4px;background:#1c1e22;border-bottom:1px solid #30353d;flex-wrap:nowrap}
|
||||
.file-code-history{display:flex;align-items:center;gap:2px;margin-right:6px;padding-right:10px;border-right:1px solid #343941}
|
||||
.file-manager .file-editor-workspace .file-code-toolbar button{background:transparent;border:1px solid transparent;min-height:40px;padding:8px 10px;border-radius:4px;font-size:12px;white-space:nowrap}.file-manager .file-editor-workspace .file-code-toolbar button:hover:not(:disabled){background:#2d333d}.file-manager .file-editor-workspace .file-code-toolbar [aria-pressed=true]{background:#2d333d;color:#bcdcf6}
|
||||
.file-code-toolbar .framely-select{margin-left:auto;width:148px;flex:none}.file-code-toolbar [role=listbox]{background:#252931!important;border-color:#4c5664!important;box-shadow:0 8px 24px #0005}
|
||||
.file-manager .file-editor-workspace .file-code-surface{flex:1;width:100%;height:0;min-height:0;resize:none;overflow:hidden}
|
||||
.file-editor-workspace .file-code-surface .cm-editor.cm-focused{outline:none}.file-editor-workspace .file-code-surface:focus-within{box-shadow:inset 2px 0 #a5c9eb}
|
||||
.file-manager .file-code-surface .cm-panels-top{position:absolute;top:8px;right:18px;left:auto;z-index:4;width:min(540px,calc(100% - 36px));border:0;background:transparent}
|
||||
.file-manager .file-code-surface .file-code-search{box-sizing:border-box;width:100%;padding:8px;background:#252a32;border:1px solid #495361;border-top:2px solid #8aaac3;border-radius:5px;box-shadow:0 5px 18px #0006;font:12px/1.4 ui-sans-serif,system-ui,sans-serif}
|
||||
.file-search-row{display:flex;align-items:center;gap:4px;min-width:0}.file-search-row[hidden],.file-search-message[hidden]{display:none}
|
||||
.file-manager .file-code-surface .file-code-search input{box-sizing:border-box;flex:1;width:0;min-width:48px;height:32px;min-height:32px;margin:0;padding:5px 8px;border:1px solid #505967;border-radius:3px;background:#1b1f25;color:#e0e6ef;font:13px/1.4 ui-monospace,monospace;outline:none}
|
||||
.file-search-input{display:flex;align-items:center;flex:1;min-width:0;border:1px solid #505967;border-radius:3px;background:#1b1f25;padding-right:3px}.file-search-input:focus-within{border-color:#8bb9dd;box-shadow:0 0 0 1px #8bb9dd30}.file-search-input:has([aria-invalid=true]){border-color:#e29a90}.file-manager .file-code-surface .file-search-input input{border:0;background:transparent}.file-manager .file-code-surface .file-search-input input:focus{box-shadow:none}
|
||||
.file-manager .file-code-surface .file-code-search input:focus{border-color:#8bb9dd;box-shadow:0 0 0 1px #8bb9dd40}.file-manager .file-code-surface .file-code-search input[aria-invalid=true]{border-color:#e29a90}
|
||||
.file-manager .file-code-surface .file-search-input input:focus{border-color:transparent;box-shadow:none}
|
||||
.file-manager .file-code-surface .file-code-search button{display:inline-flex;align-items:center;justify-content:center;flex:none;min-width:28px;width:auto;min-height:32px;height:32px;padding:0 5px;margin:0;border:1px solid transparent;border-radius:3px;background:transparent;color:#bcc6d3;font:13px/1 ui-monospace,monospace;cursor:pointer}.file-code-search button svg{width:17px;height:17px;flex:none}.file-code-search button[name="Toggle replace"][aria-expanded=true] svg{transform:rotate(90deg)}
|
||||
.file-manager .file-code-surface .file-code-search button:hover:not(:disabled){background:#3b434f;color:#fff}.file-manager .file-code-surface .file-code-search button[aria-pressed=true]{border-color:#7299b7;background:#354b5e;color:#d6edff}.file-manager .file-code-surface .file-code-search button:focus-visible{outline:2px solid #8bb9dd;outline-offset:-2px}.file-manager .file-code-surface .file-code-search button:disabled{opacity:.3;cursor:default}
|
||||
.file-search-options{display:flex;gap:1px}.file-search-options button:nth-child(2){text-decoration:underline;text-underline-offset:3px}.file-search-count{min-width:58px;text-align:center;color:#aeb9c8;font:11px ui-monospace,monospace;white-space:nowrap}
|
||||
.file-manager .file-code-surface .file-code-search button[name=close]{position:static;top:auto;right:auto;font-size:22px}.file-manager .file-code-surface .cm-searchMatch{background:#c9ab5130;outline:1px solid #c9ab5160}.file-manager .file-code-surface .cm-searchMatch.cm-searchMatch-selected{background:#c28d3c60;outline:1px solid #ddb369}
|
||||
.file-search-replacement{margin-top:6px;padding-left:34px}.file-manager .file-code-surface .file-search-replacement button{font:12px/1.4 ui-sans-serif,system-ui,sans-serif;border-color:#46505d;padding-inline:12px}.file-search-message{padding:7px 0 0 34px;color:#e1b4a2;font-size:12px}
|
||||
@media(max-width:767px){.file-manager .file-code-surface .cm-panels-top{right:12px;width:calc(100% - 24px);max-width:540px}.file-manager .file-code-surface .file-code-search button{min-height:36px;height:36px;min-width:30px}.file-manager .file-code-surface .file-code-search input{height:36px;min-height:36px}.file-search-count{min-width:48px}.file-search-replacement{padding-left:34px}}
|
||||
@media(max-width:480px){.file-search-row{flex-wrap:wrap}.file-search-input{flex-basis:calc(100% - 42px)}.file-search-count{margin-left:34px;margin-right:auto}.file-manager .file-code-surface .file-search-replacement>input{flex-basis:100%}}
|
||||
.file-editor-status{display:flex;align-items:center;justify-content:flex-end;gap:18px;min-height:32px;padding:6px 16px;background:#23262c;border-top:1px solid #343941;color:#b7c0ce;font:11px/1.4 ui-monospace,monospace;flex:none;font-variant-numeric:tabular-nums}.file-editor-status>span{flex:none;white-space:nowrap}.file-editor-status>span:first-child{margin-right:auto}.file-editor-status output{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#dce1e8}
|
||||
.file-editor-workspace .file-editor-status{box-sizing:border-box;width:100%;min-width:0;max-width:100%}
|
||||
.file-editor-workspace .file-code-fallback{display:flex;flex-direction:column;flex:1;min-height:0}.file-code-fallback>div{display:flex;align-items:center;gap:16px;padding:12px 16px;background:#302b25;font-size:13px;flex:none}.file-code-fallback p{margin:0}.file-manager .file-editor-workspace .file-code-fallback textarea{flex:1;height:0;min-height:0;resize:none;background:#1c1e22;border:0;border-radius:0;font:14px/1.7 ui-monospace,monospace;padding:18px 24px}
|
||||
.file-manager:has(.file-editor-workspace:not([hidden])) .file-upload-queue{visibility:hidden}
|
||||
@media(max-width:767px){.file-editor-heading{min-height:56px;padding:8px 12px}.file-manager .file-editor-heading button,.file-manager .file-editor-workspace .file-code-toolbar button{min-height:44px}.file-editor-tab{max-width:65%;padding-inline:12px}.file-editor-tabs{gap:12px}.file-code-toolbar .framely-select{width:120px}.file-editor-status{gap:12px;padding-inline:12px}.file-editor-indent{display:none}}
|
||||
@media(max-width:480px){.file-editor-heading strong{font-size:13px}.file-editor-heading>div{gap:6px}.file-manager .file-editor-heading button{padding:8px 10px}.file-editor-workspace .file-code-toolbar{flex-wrap:wrap;padding:4px 8px}.file-code-toolbar .framely-select{width:112px}.file-editor-status{flex-wrap:wrap;gap:6px 12px}.file-editor-tab{max-width:75%}.file-editor-tabs>code{display:none}}
|
||||
.file-manager .file-property-grid code,.file-manager .file-permissions h3 code{display:inline;padding:0;background:none;border-radius:0;font:13px/1.6 ui-monospace,monospace}
|
||||
+17
-7
@@ -10,6 +10,9 @@ import {FileUploadQueue} from './file-upload-queue';
|
||||
import {FileUploadPanel} from './file-upload-panel';
|
||||
import {Select} from './localized-select';
|
||||
import {SwitchRow} from './switch';
|
||||
import {FileProperties} from './file-properties';
|
||||
import {FileCodeEditor} from './file-code-editor';
|
||||
import {IconCode,IconDeviceFloppy} from '@tabler/icons-react';
|
||||
import {t} from './i18n';
|
||||
import './manager-tools.css';
|
||||
import './file-manager.css';
|
||||
@@ -42,6 +45,7 @@ export function FileManager({active=true}:{active?:boolean}){
|
||||
useEffect(()=>{if(!navigationOpen)return;const previous=document.activeElement as HTMLElement|null;const sidebar=document.querySelector<HTMLElement>('.file-navigation-open .file-sidebar');sidebar?.querySelector<HTMLButtonElement>('button')?.focus();const key=(e:KeyboardEvent)=>{if(e.key==='Escape'){e.preventDefault();setNavigationOpen(false);}if(e.key==='Tab'){const list=Array.from(sidebar?.querySelectorAll<HTMLElement>('button:not(:disabled),[tabindex="0"]')??[]).filter(el=>el.getClientRects().length);if(e.shiftKey&&document.activeElement===list[0]){e.preventDefault();list.at(-1)?.focus();}else if(!e.shiftKey&&document.activeElement===list.at(-1)){e.preventDefault();list[0]?.focus();}}};document.addEventListener('keydown',key,true);return()=>{document.removeEventListener('keydown',key,true);if(previous?.isConnected)previous.focus();};},[navigationOpen]);
|
||||
const[data,setData]=useState<Listing>({path:'',entries:[],total:0,roots:[]}),[path,setPath]=useState(''),[address,setAddress]=useState(''),[query,setQuery]=useState(''),[hidden,setHidden]=useState(false),[offset,setOffset]=useState(0),[selected,setSelected]=useState<string[]>([]),[bookmarks,setBookmarks]=useState<Bookmark[]>([]),[error,setError]=useState(''),[busy,setBusy]=useState(false),[loading,setLoading]=useState(true),[dialog,setDialog]=useState<Dialog|null>(null),[value,setValue]=useState(''),[policy,setPolicy]=useState('keep'),[permanent,setPermanent]=useState(false),[job,setJob]=useState<Job|null>(null),[clipboard,setClipboard]=useState<{paths:string[];cut:boolean}|null>(null),[preview,setPreview]=useState<{entries:MediaEntry[];initial:number}|null>(null),[editor,setEditor]=useState<{path:string;text:string;revision:string}|null>(null),[modified,setModified]=useState(false),[trash,setTrash]=useState<any[]|null>(null),[results,setResults]=useState<any[]|null>(null),[counter,setCounter]=useState(0),[context,setContext]=useState(false),[bookmarkMenu,setBookmarkMenu]=useState<string|null>(null),[outcomes,setOutcomes]=useState<any[]>([]),[menu,setMenu]=useState<{x:number;y:number;blank:boolean}|null>(null);
|
||||
const[filePreferences,setFilePreferences]=useState<FilePreferences>({view:'list',folders:{}}),[preferencesReady,setPreferencesReady]=useState(false);
|
||||
const [editorReadOnly,setEditorReadOnly]=useState(false);
|
||||
const preferenceWrites=useRef<Promise<unknown>>(Promise.resolve());
|
||||
const sort=filePreferences.folders[path]?.sort??'name',descending=filePreferences.folders[path]?.descending??false,view=filePreferences.view;
|
||||
function saveFilePreferences(patch:Record<string,unknown>,update:(previous:FilePreferences)=>FilePreferences){
|
||||
@@ -81,10 +85,11 @@ export function FileManager({active=true}:{active?:boolean}){
|
||||
// UTF-8 text remains editable. Other formats download instead of opening properties.
|
||||
if(e.size>5*1024*1024){await getDownload([e.path]);return;}
|
||||
setBusy(true);setError('');
|
||||
try{const v=await managerApi('files',{operation:'read',path:e.path});setEditor({path:e.path,...v});setModified(false);}
|
||||
try{const v=await managerApi('files',{operation:'read',path:e.path});setEditor({path:e.path,...v});setEditorReadOnly(false);setModified(false);}
|
||||
catch(error){const raw=error instanceof ManagerError?error.raw:String(error);if(['Binary files cannot be edited as text','Only UTF-8 text can be edited','Text editor supports files up to 5 MiB'].some(message=>raw.includes(message)))await getDownload([e.path]);else setError(String(error));}
|
||||
finally{setBusy(false);}
|
||||
}
|
||||
async function editAsText(e:Entry){setMenu(null);setBusy(true);setError('');try{const v=await managerApi('files',{operation:'read',path:e.path,asText:true});setEditor({path:e.path,...v});setEditorReadOnly(!!v.readOnly);setModified(false);}catch(error){setError(String(error));}finally{setBusy(false);}}
|
||||
|
||||
const [,updateQueue]=useState(0),[dragTarget,setDragTarget]=useState(''),[preparing,setPreparing]=useState(false);
|
||||
const prepareController=useRef<AbortController|null>(null),queueTimer=useRef<ReturnType<typeof setTimeout>|null>(null),refreshTimer=useRef<ReturnType<typeof setTimeout>|null>(null);
|
||||
@@ -124,7 +129,6 @@ export function FileManager({active=true}:{active?:boolean}){
|
||||
else if(d.kind==='rename'){await perform({operation:'rename',path:d.path,name:value});}
|
||||
else if(d.kind==='delete'){await perform({operation:'delete',paths:selected,permanent,approve:permanent});setSelected([]);}
|
||||
else if(d.kind==='paste'){await perform({operation:clipboard?.cut?'move':'copy',paths:clipboard?.paths,destination:path,conflict:policy,approve:policy==='overwrite'});if(clipboard?.cut)setClipboard(null);}
|
||||
else if(d.kind==='properties'){await perform({operation:'chmod',path:d.entry!.path,mode:parseInt(value,8)});}
|
||||
else if(d.kind==='compress'){const format=value.endsWith('.tar.gz')?'tar.gz':value.endsWith('.tar')?'tar':'zip';await perform({operation:'compress',paths:selected,path:path+'/'+value,format,conflict:policy,approve:policy==='overwrite'});}
|
||||
else if(d.kind==='extract'){await perform({operation:'extract',path:d.path,destination:value,conflict:policy,approve:policy==='overwrite'});}
|
||||
else if(d.kind==='trash.purge'){await perform({operation:'trash.purge',id:d.path,all:!d.path,approve:true});setTrash(await managerApi('files',{operation:'trash.list'}));}
|
||||
@@ -134,7 +138,7 @@ export function FileManager({active=true}:{active?:boolean}){
|
||||
else if(d.kind==='editor.overwrite'){const v=await perform({operation:'write',...editor,approve:true});setEditor(e=>e?{...e,...v}:null);setModified(false);}
|
||||
setDialog(null);
|
||||
}catch{}}
|
||||
async function saveText(){if(!editor)return;try{const v=await perform({operation:'write',...editor});setEditor(e=>e?{...e,...v}:null);setModified(false);}catch(e){if((e instanceof ManagerError?e.raw:String(e)).includes('File changed outside the editor'))openDialog({title:t('保存冲突'),kind:'editor.overwrite'});}}
|
||||
async function saveText(){if(!editor||editorReadOnly||busy)return;try{const v=await perform({operation:'write',...editor});setEditor(e=>e?{...e,...v}:null);setModified(false);}catch(e){if((e instanceof ManagerError?e.raw:String(e)).includes('File changed outside the editor'))openDialog({title:t('保存冲突'),kind:'editor.overwrite'});}}
|
||||
const picked=shown.find(e=>e.path===selected[0]);
|
||||
const selectedCount=selected.length;
|
||||
const bookmarkCurrent=bookmarks.some(b=>b.path===path);
|
||||
@@ -151,6 +155,7 @@ export function FileManager({active=true}:{active?:boolean}){
|
||||
<button role={menu?'menuitem':undefined} disabled={!selectedCount||busy} onClick={()=>openDialog({title:t('创建压缩包'),kind:'compress',value:'archive.zip'})}><IconFileZip size={18}/>{t('压缩')}</button>
|
||||
<button role={menu?'menuitem':undefined} disabled={selectedCount!==1||!picked||busy||loading} onClick={()=>openDialog({title:t('解压文件'),kind:'extract',path:selected[0],value:path})}><IconFolder size={18}/>{t('解压')}</button>
|
||||
<button role={menu?'menuitem':undefined} disabled={selectedCount!==1||!picked||busy||loading} onClick={()=>void open(picked!)}><IconExternalLink size={18}/>{t('打开')}</button>
|
||||
<button role={menu?'menuitem':undefined} disabled={selectedCount!==1||!picked||picked.directory||busy||loading} onClick={()=>void editAsText(picked!)}><IconFileText size={18}/>{t('以文本编辑')}</button>
|
||||
</>;
|
||||
const mobileCreateBar=<div className="file-mobile-action-list"><button disabled={busy} onClick={()=>openDialog({title:t('新建文件夹'),kind:'folder'})}><IconFolderPlus size={20}/>{t('新建文件夹')}</button><button disabled={busy} onClick={()=>openDialog({title:t('新建文件'),kind:'create'})}><IconPlus size={20}/>{t('新建文件')}</button><button disabled={busy} onClick={()=>fileInput.current?.click()}><IconUpload size={20}/>{t('上传文件')}</button><button disabled={busy} onClick={()=>void chooseUploadDirectory()}><IconFolderPlus size={20}/>{t('上传文件夹')}</button><button disabled={!clipboard||busy} onClick={()=>openDialog({title:t('粘贴文件'),kind:'paste'})}><IconClipboard size={20}/>{t('粘贴')}</button></div>;
|
||||
const commandBar=<div className="file-command-bar"><div className="file-actions"><button disabled={busy} onClick={()=>openDialog({title:t('新建文件夹'),kind:'folder'})}><IconFolderPlus size={18}/>{t('新建文件夹')}</button><button disabled={busy} onClick={()=>openDialog({title:t('新建文件'),kind:'create'})}><IconPlus size={18}/>{t('新建文件')}</button><button disabled={!clipboard||busy} onClick={()=>openDialog({title:t('粘贴文件'),kind:'paste'})}><IconClipboard size={18}/>{t('粘贴')}</button></div><div className="file-actions"><button className="file-primary" disabled={busy} onClick={()=>fileInput.current?.click()}><IconUpload size={18}/>{t('上传文件')}</button>
|
||||
@@ -165,7 +170,7 @@ export function FileManager({active=true}:{active?:boolean}){
|
||||
{job&&<div className="file-notice file-progress"><span>{t(job.phase)}</span>{job.totalBytes?<progress value={job.bytes??0} max={job.totalBytes}/>:<progress/>}<span>{job.total?`${job.completed??0}/${job.total}`:job.visited?`${job.visited} · ${job.matches??0}`:job.bytes?bytes(job.bytes):''}</span>{!['done','failed','cancelled'].includes(job.phase)?<button onClick={()=>void api('job.cancel',{job:job.id}).catch(e=>setError(String(e)))}>{t('取消')}</button>:<button aria-label={t('关闭')} onClick={()=>setJob(null)}><IconX size={18}/></button>}</div>}
|
||||
{outcomes.length>0&&<details className="file-outcomes"><summary>{t('逐项结果')} · {outcomes.length}</summary>{outcomes.map((item,index)=><p key={index}>{item.path} · {item.error??t(item.result?.skipped||item.skipped?'已跳过':'已完成')}</p>)}</details>}
|
||||
{mobile&&navigationOpen&&<button className="file-navigation-scrim" aria-label={t('关闭文件导航')} onClick={()=>setNavigationOpen(false)}/>}
|
||||
<div className="file-layout" ref={layout}>
|
||||
<div className="file-layout" ref={layout} inert={!!editor||!!dialog}>
|
||||
<aside className="file-sidebar" aria-label={t('文件导航')} inert={mobile&&!navigationOpen?true:undefined} role={mobile?'dialog':undefined} aria-modal={mobile&&navigationOpen?true:undefined}>
|
||||
{mobile&&<div className="file-navigation-heading"><strong>{t('浏览文件夹')}</strong><button aria-label={t('关闭文件导航')} onClick={()=>setNavigationOpen(false)}><IconX size={22}/></button></div>}
|
||||
<section className="file-quick-access"><h3>{t('快速访问')}</h3><div className="file-quick-access-list">
|
||||
@@ -194,13 +199,14 @@ export function FileManager({active=true}:{active?:boolean}){
|
||||
<footer className="file-status-bar"><span>{view!=='list'&&!trash&&<label className="file-grid-select-all"><input type="checkbox" aria-label={t('选择本页')} checked={shown.length>0&&shown.every(entry=>selected.includes(entry.path))} onChange={event=>setSelected(event.target.checked?shown.map(entry=>entry.path):[])}/>{t('选择本页')}</label>}{selectedCount?t('已选择 {0} 项',{0:selectedCount}):t('共 {0} 项',{0:results?results.length:data.total})}{clipboard&&<small>{t(clipboard.cut?'已剪切 {0} 项':'已复制 {0} 项',{0:clipboard.paths.length})}</small>}</span>{!results&&<div className="file-pagination"><button className="file-icon-button" aria-label={t('上一页')} disabled={offset===0} onClick={()=>setOffset(v=>Math.max(0,v-200))}><IconChevronRight className="file-chevron-back" size={18}/></button><span>{Math.floor(offset/200)+1} / {Math.max(1,Math.ceil(data.total/200))}</span><button className="file-icon-button" aria-label={t('下一页')} disabled={offset+200>=data.total} onClick={()=>setOffset(v=>v+200)}><IconChevronRight size={18}/></button></div>}</footer>
|
||||
</>}
|
||||
</div></div>
|
||||
{mobile&&selected.length>0&&<div className="file-mobile-selection"><span>{t('已选择 {0} 项',{0:selected.length})}</span><button onClick={()=>setMobileTool('actions')}><IconDots size={20}/>{t('操作')}</button></div>}
|
||||
{mobile&&selected.length>0&&<div className="file-mobile-selection" inert={!!editor||!!dialog}><span>{t('已选择 {0} 项',{0:selected.length})}</span><button onClick={()=>setMobileTool('actions')}><IconDots size={20}/>{t('操作')}</button></div>}
|
||||
{active&&mobileTool&&mobile&&<MobileSheet title={t(mobileTool==='create'?'新建与上传':mobileTool==='filters'?'筛选与显示':'文件操作')} onClose={()=>setMobileTool(null)}><div onClick={event=>{if((event.target as HTMLElement).closest('button')&&mobileTool!=='filters')setMobileTool(null);}}>{mobileTool==='create'?mobileCreateBar:mobileTool==='filters'?filterBar:<div className="file-mobile-action-list">{actions}<button disabled={busy} onClick={()=>void getDownload()}><IconDownload size={20}/>{t('下载文件')}</button></div>}</div></MobileSheet>}
|
||||
<FileUploadPanel active={active} queue={queue} limit={filePreferences.uploadConcurrency??3} onLimit={uploadConcurrency=>saveFilePreferences({uploadConcurrency},previous=>({...previous,uploadConcurrency}))}/>
|
||||
{active&&menu&&<div ref={menuElement} className="file-context-menu" role="menu" aria-label={t('文件操作')} style={{left:menu.x,top:menu.y}} onContextMenu={event=>event.preventDefault()} onClick={event=>{if((event.target as HTMLElement).closest('button:not(:disabled)'))setMenu(null);}} onKeyDown={event=>{const buttons=Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'));if(['ArrowDown','ArrowUp','Home','End'].includes(event.key)){event.preventDefault();const index=buttons.indexOf(document.activeElement as HTMLButtonElement),next=event.key==='Home'?0:event.key==='End'?buttons.length-1:(index+(event.key==='ArrowDown'?1:-1)+buttons.length)%buttons.length;buttons[next]?.focus();}if(event.key==='Escape')setMenu(null);}}>
|
||||
{menu.blank?<><button role="menuitem" disabled={busy} onClick={()=>openDialog({title:t('新建文件夹'),kind:'folder'})}><IconFolderPlus size={18}/>{t('新建文件夹')}</button><button role="menuitem" disabled={busy} onClick={()=>openDialog({title:t('新建文件'),kind:'create'})}><IconPlus size={18}/>{t('新建文件')}</button><button role="menuitem" disabled={!clipboard||busy} onClick={()=>openDialog({title:t('粘贴文件'),kind:'paste'})}><IconClipboard size={18}/>{t('粘贴')}</button><div role="separator"/><button role="menuitem" disabled={busy} onClick={()=>fileInput.current?.click()}><IconUpload size={18}/>{t('上传文件')}</button><button role="menuitem" disabled={busy} onClick={()=>void chooseUploadDirectory()}><IconFolderPlus size={18}/>{t('上传文件夹')}</button><button role="menuitem" onClick={refresh}><IconRefresh size={18}/>{t('刷新')}</button></>:<>{actions}<div role="separator"/><button role="menuitem" disabled={!selectedCount||busy} onClick={()=>void getDownload()}><IconDownload size={18}/>{t('下载文件')}</button>{picked?.directory&&selectedCount===1&&<button role="menuitem" disabled={busy} onClick={()=>openDialog({title:t('添加到快速访问'),kind:'bookmark',path:picked.path,value:picked.name})}><IconStar size={18}/>{t('添加到快速访问')}</button>}</>}
|
||||
</div>}
|
||||
{active&&dialog&&<div className={dialog.kind==='upload'?'modal file-upload-confirm':'modal'} role="dialog" aria-modal="true" aria-label={dialog.title}><h2>{dialog.title}</h2>{error&&<p className="error">{error}</p>}{dialog.kind==='properties'&&dialog.entry&&<div className="file-details"><p>{dialog.entry.path}</p><p>{bytes(dialog.entry.size)} · UID {dialog.entry.uid} / GID {dialog.entry.gid}</p>{dialog.entry.linkTarget&&<p>{dialog.entry.linkTarget}</p>}</div>}{['create','folder','rename','bookmark','bookmark.rename','compress','extract','properties'].includes(dialog.kind)&&<label>{t(dialog.kind==='properties'?'权限(八进制)':dialog.kind==='extract'?'目标目录':'名称')}<input autoFocus value={value} onChange={e=>setValue(e.target.value)}/></label>}
|
||||
{active&&dialog?.kind==='properties'&&dialog.entry&&<FileProperties key={dialog.entry.path} entry={dialog.entry} onClose={()=>setDialog(null)} onSaved={refresh}/>}
|
||||
{active&&dialog&&dialog.kind!=='properties'&&<div className={dialog.kind==='upload'?'modal file-upload-confirm':'modal'} role="dialog" aria-modal="true" aria-label={dialog.title}><h2>{dialog.title}</h2>{error&&<p className="error">{error}</p>}{['create','folder','rename','bookmark','bookmark.rename','compress','extract'].includes(dialog.kind)&&<label>{t(dialog.kind==='extract'?'目标目录':'名称')}<input autoFocus value={value} onChange={e=>setValue(e.target.value)}/></label>}
|
||||
{dialog.kind==='upload'&&<div className="file-upload-review">
|
||||
<div className="file-upload-destination"><IconFolder size={24}/><div><span>{t('上传到')}</span><code>{dialog.path}</code></div></div>
|
||||
<div className="file-upload-summary"><span>{t('{0} 个文件',{0:dialog.entries?.filter(e=>!e.directory).length??0})}</span><span>{t('{0} 个文件夹',{0:dialog.entries?.filter(e=>e.directory).length??0})}</span><strong>{bytes(dialog.entries?.reduce((n,e)=>n+(e.file?.size??0),0)??0)}</strong></div>
|
||||
@@ -217,6 +223,10 @@ export function FileManager({active=true}:{active?:boolean}){
|
||||
{['paste','compress','extract','trash.restore'].includes(dialog.kind)&&<><p>{t('遇到同名文件时')}</p><Select label={t('同名处理')} value={policy} onChange={setPolicy} options={[{value:'keep',label:t('保留两份')},{value:'skip',label:t('跳过')},...(dialog.kind==='trash.restore'?[]:[{value:'overwrite',label:t('覆盖')}])]}/>{policy==='overwrite'&&<p>{t(dialog.kind==='upload'?'确认后会替换同名文件,不删除目录中的其他文件。':'确认后会覆盖同名文件或整个同名文件夹。')}</p>}</>}{dialog.kind==='compress'&&<p>{t('支持 .zip、.tar 和 .tar.gz。')}</p>}{dialog.kind==='trash.purge'&&<p>{t('永久删除无法撤销。')}</p>}{dialog.kind==='editor.discard'&&<p>{t('关闭将丢弃未保存的修改。')}</p>}{dialog.kind==='editor.overwrite'&&<p>{t('文件已被外部修改。可重新加载,或确认覆盖。')}</p>}
|
||||
<footer>{dialog.kind==='editor.overwrite'&&<button onClick={()=>void perform({operation:'read',path:editor?.path}).then(v=>{setEditor(e=>e?{...e,...v}:null);setModified(false);setDialog(null);}).catch(()=>{})}>{t('重新加载')}</button>}<button disabled={busy} onClick={()=>setDialog(null)}>{t('取消')}</button><button disabled={busy} className={permanent||dialog.kind==='trash.purge'?'danger':'primary'} onClick={()=>void submit()}>{t(dialog.kind==='upload'?'开始上传':'确认')}</button></footer></div>}
|
||||
{active&&preview&&<FileMediaPreview entries={preview.entries} initial={preview.initial} onClose={()=>setPreview(null)} onDownload={path=>void getDownload([path])}/>}
|
||||
{active&&editor&&!dialog&&<div className="modal file-editor-modal" role="dialog" aria-modal="true"><h2>{editor.path.split('/').pop()}{modified?' *':''}</h2>{error&&<p className="error">{error}</p>}<textarea aria-label={t('文本内容')} className="file-editor" value={editor.text} onChange={e=>{setEditor(v=>v?{...v,text:e.target.value}:null);setModified(true);}}/><footer><button disabled={busy} onClick={()=>void saveText()}>{t('保存')}</button><button onClick={()=>{if(!modified)setEditor(null);else openDialog({title:t('未保存的修改'),kind:'editor.discard'});}}>{t('关闭')}</button></footer></div>}
|
||||
{active&&editor&&<section className="file-editor-workspace file-editor-modal" hidden={!!dialog} inert={!!dialog} role="region" aria-label={t('文本编辑器')}>
|
||||
<header className="file-editor-heading"><div><IconCode size={21}/><strong>{t('文本编辑器')}</strong></div><div><button disabled={busy||editorReadOnly||!modified} className="file-editor-save" title="Ctrl / Cmd + S" onClick={()=>void saveText()}><IconDeviceFloppy size={18}/>{t('保存')}</button><button disabled={busy} onClick={()=>{if(!modified)setEditor(null);else openDialog({title:t('未保存的修改'),kind:'editor.discard'});}}><IconX size={18}/>{t('关闭')}</button></div></header>
|
||||
<div className="file-editor-tabs"><div className="file-editor-tab"><IconFileText size={17}/><strong>{editor.path.split('/').pop()}</strong>{modified&&<span className="file-editor-dirty" aria-label={t('未保存的修改')}/>}</div><code title={editor.path}>{editor.path.slice(0,editor.path.lastIndexOf('/'))||'/'}</code></div>
|
||||
{error&&<p className="error" role="alert">{error}</p>}{editorReadOnly&&<p className="banner">{t('此文件包含二进制内容或不是 UTF-8,仅以文本预览,不允许保存以避免损坏原文件。')}</p>}<FileCodeEditor path={editor.path} value={editor.text} readOnly={editorReadOnly||busy||!!dialog} onSave={()=>void saveText()} onChange={text=>{setEditor(v=>v?{...v,text}:null);setModified(true);}}/>
|
||||
</section>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React,{useEffect,useState} from 'react';
|
||||
import {IconFile,IconFolder,IconLock} from '@tabler/icons-react';
|
||||
import {managerApi} from './manager-api';
|
||||
import {t} from './i18n';
|
||||
export type FilePropertiesData={name:string;path:string;directory:boolean;regular?:boolean;symlink:boolean;size:number;allocated?:number;modified?:number;accessed?:number;changed?:number;created?:number;mode:number;uid:number;gid:number;owner?:string;group?:string;inode?:string;device?:string;links?:number;linkTarget?:string;resolvedPath?:string;canChmod?:boolean};
|
||||
export const validFileMode=(value:string)=>/^0?[0-7]{3}$/.test(value);
|
||||
export function permissionText(mode:number){return [8,7,6,5,4,3,2,1,0].map((bit,i)=>{const present=!!(mode&(1<<bit));if(i===2&&(mode&0o4000))return present?'s':'S';if(i===5&&(mode&0o2000))return present?'s':'S';if(i===8&&(mode&0o1000))return present?'t':'T';return present?'rwx'[i%3]:'-';}).join('');}
|
||||
const size=(n:number)=>`${n.toLocaleString()} B${n>=1024?` (${(n/1024**(n>=1024**3?3:n>=1024**2?2:1)).toFixed(2)} ${n>=1024**3?'GiB':n>=1024**2?'MiB':'KiB'})`:''}`;
|
||||
export function FileProperties({entry,onClose,onSaved}:{entry:FilePropertiesData;onClose:()=>void;onSaved:()=>void}){
|
||||
const [data,setData]=useState(entry),[mode,setMode]=useState((entry.mode&0o777).toString(8).padStart(3,'0')),[loading,setLoading]=useState(true),[busy,setBusy]=useState(false),[error,setError]=useState('');
|
||||
useEffect(()=>{let alive=true;setLoading(true);void managerApi<FilePropertiesData>('files',{operation:'properties',path:entry.path}).then(value=>{if(alive){setData(value);setMode((value.mode&0o777).toString(8).padStart(3,'0'));}}).catch(e=>{if(alive)setError(String(e));}).finally(()=>{if(alive)setLoading(false);});return()=>{alive=false;};},[entry.path]);
|
||||
const date=(value?:number)=>value===undefined||value===null?t('不可用'):new Date(value*1000).toLocaleString(document.documentElement.lang,{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||||
const writable=!loading&&!busy&&data.canChmod===true&&!data.symlink&&!!data.inode&&!!data.device;
|
||||
const numeric=validFileMode(mode)?parseInt(mode,8):null;
|
||||
const changed=numeric!==null&&numeric!==(data.mode&0o777);
|
||||
const rows:[[string,React.ReactNode],...[string,React.ReactNode][]]=[
|
||||
['完整路径',<code>{data.path}</code>],['文件类型',t(data.symlink?'符号链接':data.directory?'文件夹':data.regular===false?'特殊文件':'普通文件')],
|
||||
[data.directory?'目录自身大小(不含内容)':'文件大小',size(data.size)],['磁盘占用',data.allocated===undefined?t('不可用'):size(data.allocated)],
|
||||
['所有者',`${data.owner??t('未知')} (UID ${data.uid})`],['所属组',`${data.group??t('未知')} (GID ${data.gid})`],
|
||||
['修改时间',date(data.modified)],['访问时间',date(data.accessed)],['状态变更时间',date(data.changed)],['创建时间',date(data.created)],
|
||||
['Inode',data.inode??t('不可用')],['设备编号',data.device??t('不可用')],['硬链接数',data.links??t('不可用')]
|
||||
];
|
||||
if(data.linkTarget)rows.splice(2,0,['链接目标',<code>{data.linkTarget}</code>]);
|
||||
if(data.symlink)rows.splice(3,0,['解析后的路径',<code>{data.resolvedPath??t('不可用')}</code>]);
|
||||
async function save(){if(!writable||numeric===null||!changed)return;setBusy(true);setError('');try{await managerApi('files',{operation:'chmod',path:data.path,mode:numeric,identity:{device:data.device,inode:data.inode}});onSaved();onClose();}catch(e){setError(String(e));}finally{setBusy(false);}}
|
||||
return <div className="modal file-properties-modal" role="dialog" aria-modal="true" aria-label={t('文件属性')} aria-busy={loading||busy}>
|
||||
<header className="file-properties-heading">{data.directory?<IconFolder size={30}/>:<IconFile size={30}/>}<div><h2>{data.name}</h2><p>{t('文件属性')}</p></div></header>
|
||||
{loading&&<p role="status">{t('正在读取属性…')}</p>}
|
||||
{error&&<p className="error" role="alert">{error}</p>}
|
||||
<dl className="file-property-grid">{rows.map(([label,value])=><div key={label}><dt>{t(label)}</dt><dd>{value}</dd></div>)}</dl>
|
||||
<section className="file-permissions"><h3><IconLock size={19}/>{t('访问权限')} <code>{(data.mode&0o7000).toString(8)}{numeric===null?'???':mode.replace(/^0(?=\d{3}$)/,'')} / {permissionText((data.mode&0o7000)|(numeric??(data.mode&0o777)))}</code></h3>
|
||||
<div className="file-permission-grid"><span/>{['读取','写入','执行/进入'].map(label=><span key={label}>{t(label)}</span>)}{['所有者','所属组','其他用户'].map((label,row)=><React.Fragment key={label}><strong>{t(label)}</strong>{[4,2,1].map((bit,col)=>{const mask=bit<<(6-row*3);return <button key={bit} type="button" role="checkbox" aria-label={`${t(label)}: ${t(['读取','写入','执行/进入'][col])}`} aria-checked={numeric!==null&&!!(numeric&mask)} disabled={!writable||numeric===null} onClick={()=>setMode((numeric!^mask).toString(8).padStart(3,'0'))}>{numeric!==null&&(numeric&mask)?'✓':'-'}</button>;})}</React.Fragment>)}</div>
|
||||
<label>{t('权限(八进制)')}<input value={mode} maxLength={4} inputMode="numeric" disabled={!writable} aria-invalid={!validFileMode(mode)} onChange={e=>setMode(e.target.value)}/></label>
|
||||
{!validFileMode(mode)&&<p className="error" role="alert">{t('请输入 000 至 777 的八进制权限。')}</p>}
|
||||
{!loading&&(data.canChmod===false||data.symlink)&&<p>{t(data.symlink?'符号链接权限不可修改;此处显示链接本身的属性。':'当前用户不是文件所有者,无法修改权限。')}</p>}
|
||||
{!!(data.mode&0o7000)&&<p>{t('现有特殊权限位会保留,本面板仅修改读、写、执行权限。')}</p>}
|
||||
</section>
|
||||
<footer><button disabled={busy} onClick={onClose}>{t('关闭')}</button><button className="primary" disabled={!writable||!validFileMode(mode)||!changed} onClick={()=>void save()}>{t('保存权限')}</button></footer>
|
||||
</div>;
|
||||
}
|
||||
Reference in new issue
Block a user