Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3529bb9dc | ||
|
|
12ee779040 | ||
|
|
64982993f3 | ||
|
|
6a218ec6c0 | ||
|
|
172cce1962 | ||
|
|
e23010f950 | ||
|
|
81ac112dac | ||
|
|
0289dcbd1d | ||
|
|
06cfc61392 | ||
|
|
e9e0f7592f | ||
|
|
3ab4b50afb | ||
|
|
6e7c9d5376 | ||
|
|
5602949a4f | ||
|
|
9e5e959f80 | ||
|
|
309de439e8 | ||
|
|
a381d7be03 | ||
|
|
14f5963706 | ||
|
|
40336d014d |
No files matched your search
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# npm test 会先自动跑 pretest(typecheck),再重建 esbuild bundle 并跑 13 个离线测试
|
||||
# npm test 会先自动跑 pretest(typecheck),再重建 esbuild bundle 并跑 19 个离线测试
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@ node_modules/
|
||||
# build output
|
||||
out/
|
||||
dist/
|
||||
tests/.session-e2e.cjs
|
||||
tests/.sftp-svc.*
|
||||
tests/.hl-smoke.*
|
||||
|
||||
# research artifacts (XTerminal reverse-engineering notes, not part of the app)
|
||||
research/
|
||||
@@ -52,7 +50,6 @@ release/
|
||||
# stray local test artifacts
|
||||
.min-test.bin
|
||||
.min2-test.bin
|
||||
.exact-test.bin
|
||||
tests/.zm-src-*/
|
||||
tests/.zm-out-*/
|
||||
|
||||
|
||||
@@ -7,12 +7,15 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
- 开发:`npm run dev`(主进程改动不热重建,需重启)
|
||||
- dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置
|
||||
- 类型检查:`npm run typecheck`(tsconfig.node.json + tsconfig.web.json;只看渲染层可单跑 `npx tsc --noEmit -p tsconfig.web.json`)
|
||||
- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 18 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、hl-split-smoke、hl-rules、reserved-accelerators、ipc-guard、updater-fallback、log-sanitizer、sftp-timeout、terminal-title、zmodem-e2e、ssh-session-e2e、sysinfo-e2e;真实服务器测试需 JD_* 凭据,不在此列)
|
||||
- 测试:`npm test`(**npm 生命周期先自动跑 `pretest` 做类型检查**,再 `node tests/build-bundles.cjs` 重建 esbuild bundle,然后依次跑可离线运行的 22 个测试:ssh-loopback、commands-store、connections-store、settings-store、local-path-grants、lock-store、lock-controller、lock-shortcuts、hl-split-smoke、hl-rules、reserved-accelerators、ipc-guard、updater-fallback、log-sanitizer、sftp-timeout、terminal-title、zmodem-e2e、ssh-session-e2e、sysinfo-e2e、terminal-cwd、terminal-links、broadcast-store;真实服务器测试需 JD_* 凭据,不在此列)。后三个渲染层纯函数测试用 Node 22.18+ 的原生 TS 类型剥离**直接 import `.ts` 源文件**(不经 esbuild bundle),Node 20/22.17 以下会 `ERR_UNKNOWN_FILE_EXTENSION`
|
||||
- 依赖分类规则:**只有 `src/main/`/`src/preload/` 实际 import 的包才能进 `dependencies`**(node-pty/ssh2/zmodem.js/font-list/electron-updater);纯渲染层依赖一律 devDependencies(Vite 全量打包进 out/renderer,`externalizeDepsPlugin` 不作用渲染层)——这条让 asar 从 98MB 瘦到 8.1MB,加新依赖时别放错边
|
||||
- 下载量统计:`node scripts/download-stats.cjs`(Gitea + GitHub release 资产的 download_count 汇总;GitHub 优先直连、失败自动回退 `HTTPS_PROXY`/本地 7897;更新通道无计数接口不计入)
|
||||
- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 18 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(msi + exe + latest.yml + blockmap)
|
||||
- 打包:`npm run dist`(**生命周期先自动跑 `predist` → `npm test`,即类型检查 + 22 个离线测试全部通过后才 build/package**,typecheck 全程只跑一次;predist 末尾的 `npm install --package-lock-only` 会把 `package-lock.json` 根版本号对齐 `package.json`,**发布提交必须带上 package-lock.json**),产物在 `release/`(exe + latest.yml + blockmap)
|
||||
- GitHub Actions:`.github/workflows/ci.yml` 在 windows-latest + Node 22 上跑 `npm ci` / `npm test`(含 pretest typecheck)/ `npm run build`,只做验证,不打包安装器、不发布
|
||||
- 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
|
||||
- 依赖一致性闸门:`predist` 开头跑 `node scripts/verify-deps.cjs`(逐条比对 `node_modules` 与 `package-lock.json` 的版本,跨平台 optional 依赖缺失跳过;不一致就 exit 1)——`npm install --package-lock-only` **只重算 lockfile、不碰 node_modules**(本版 npm 还会把 `node_modules/.package-lock.json` 一并重写成理想树,制造「已经装好了」的假象),所以**改动依赖版本后必须真实 `npm install` 或 `npm ci` 再构建**,别指望 lockfile 对齐就等于树里换了包;v1.0.21 的中文输入法回归正是这个坑(`@xterm/xterm` 锁 6.1.0-beta.304,树里还是 6.0.0,打进去的是旧代码)
|
||||
- **换机/重装环境后第一次打包前必须先跑 `npm ci`**(verify-deps 会兜底拦截,但别浪费一次构建);在任何机器上都先 `node scripts/verify-deps.cjs` 确认再 `npm run dist`
|
||||
- electron-builder `rename win-unpacked.tmp` 撞 EPERM = 安全软件实时扫描新解压文件短暂持锁(实测约 4s,进程退出后数秒即可手动重命名)。应对:`NODE_OPTIONS="--require $(pwd -W)/scripts/rename-retry-shim.cjs" npm run dist`(注入重试垫片,stderr 会打印每次重试);根治是把仓库目录加进安全软件信任区
|
||||
|
||||
## 仓库与远程
|
||||
|
||||
@@ -31,20 +34,21 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
2. `node scripts/sync-changelog.cjs` 把发布说明并入 `CHANGELOG*.md` —— **必须在 `npm run dist` 之前**:更新日志以 `?raw` 打进安装包(「关于」页离线读),`release.cjs` 也校验 `CHANGELOG.md` 已含该版本号
|
||||
3. `npm run dist` 构建
|
||||
4. `node scripts/release.cjs <版本号>`,例如 `node scripts/release.cjs 1.0.14`(**不带 skip 参数**,Gitea 与 GitHub 一起发)
|
||||
- Gitea:release(msi + exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`:exe.blockmap → exe → release-notes.md → **latest.yml 最后**)
|
||||
- Gitea:release(exe 资产)→ Gitea 更新通道(`api/packages/admin/generic/openterminal-update/stable`:exe.blockmap → exe → release-notes.md → **latest.yml 最后**)
|
||||
- GitHub:release 资产再次随版本同步发布(exe + exe.blockmap + latest.yml),electron-updater 标准 GitHub provider 直接吃 release 资产
|
||||
- 通道不再先删旧版:新版本文件全部传完、latest.yml 生效后才清掉上一版 exe/blockmap,中途失败不会把通道打空;同一版本可重复运行(release 复用、已传资产跳过)
|
||||
- 上传前先比通道版本:`assertNoDowngrade()` 读通道 latest.yml,若线上版本**高于**待发布版本就直接拒绝——三条本地护栏只比本地产物,旧分支发旧版本号会一路通过,覆盖 latest.yml 之后 `pruneChannel` 会把线上新版本的 exe/blockmap 删掉
|
||||
- 上传前先比通道版本:`assertNoDowngrade()` 读通道 latest.yml,若线上版本**高于**待发布版本就直接拒绝——三条本地护栏只比本地产物,旧分支发旧版本号会一路通过,覆盖 latest.yml 之后 `pruneChannel` 会把线上新版本的 exe/blockmap 删掉。该探测**fail-closed**:网络错误 / 非 404 失败 / 解析不出 version 一律抛错中止发布,只有通道真空(404)才放行;`pruneChannel` 前那次探测是 best-effort(失败只跳过清理并打日志)
|
||||
- 只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub)
|
||||
- 跳过部分目标:`--skip-github` 只跳 GitHub release;`--skip-gitea-release` **只跳 Gitea release、更新通道照常上传**;`--skip-gitea` 两者都跳(会打印醒目警告——国内用户将收不到该版本,只想跳 release 请用 `--skip-gitea-release`)
|
||||
- 国内通道全程直连,**不需要设代理**;GitHub 请求走 `HTTPS_PROXY=http://127.0.0.1:7897`(脚本只把它用于 GitHub 请求)
|
||||
5. 验证更新通道:`curl https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/latest.yml` 应返回新版本号
|
||||
6. `git tag v<版本号>` 并推送两个远程(代码/tag 与 release 资产的镜像保持同步)
|
||||
6. `git tag v<版本号>` 并推送两个远程(代码/tag 与 release 资产的镜像保持同步)。注意顺序坑:release.cjs 创建 release 时若远端尚无该 tag,Gitea/GitHub 会在**默认分支 HEAD** 自动建一个指向错误 commit 的 tag,第 6 步推送会被拒——要么先建 tag 推上去再跑 release.cjs,要么事后 `git push -f <远端> v<版本号>` 强制修正到 release commit(v1.0.22 即踩过)
|
||||
|
||||
## 更新机制
|
||||
|
||||
- 检查更新:**GitHub 优先**(走系统代理;前置 20 秒连通性探测 `probeGithub`——探测失败/超时直接兜底 Gitea,不给 electron-updater 挂起的机会),失败回退国内 Gitea 通用包通道(强制直连,不走系统代理)。 electron-updater 用独立 session(partition `electron-updater`),代理模式在 `useFeed` 里按源切换
|
||||
- 更新日志:Gitea 仓库是私有的(匿名 API 404),改为从更新通道的 `release-notes.md` 读取(直连 session `openterminal-update-direct`),再回退 Gitea/GitHub releases API
|
||||
- electron-updater 不支持 MSI 自动更新,自动更新只走 NSIS exe
|
||||
- 自动更新只走 NSIS exe;自 v1.0.22 起不再构建 MSI 安装包(electron-updater 本就不支持 MSI 自动更新)
|
||||
- 每次 checkForUpdates 都被 **30s 整体超时**包住(`withTimeout`):electron-updater 自带的 60s 只是 socket 空闲超时,慢滴流响应能一直占住它。超时按普通失败走 GitHub→Gitea 回退,但**被放弃的检查取消不掉**,而 electron-updater 对并发检查去重(返回同一个 in-flight promise),所以兜底那次共用被放弃的 promise——它同样被超时兜住,最终落到错误态,不会永远停在「检查中」
|
||||
- 更新日志 / releases API 的 fetch 都带 `AbortSignal.timeout(15s)`(`directFetch` 与 `fetchChangelog` 里的 `net.fetch`):卡住的通道必须让位给下一个来源,不能把「关于」页吊住
|
||||
|
||||
@@ -63,12 +67,14 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
|
||||
- `TerminalView` 用 **5 组 `useShallow` 字段级订阅**(fontOpts/themeOpts/bgOpts/highlightOpts/toolbar + rendererMode),**不要再回到整对象订阅**——主进程 `mutateSettings` 回推整个 settings 对象,任何写入都会变身份,整对象订阅会让所有 pane 重写 options + refit。`useResolvedTheme` 也是 shallow + memo 后的稳定引用
|
||||
- `TerminalView` **已无 imperative handle**(`TerminalHandle`/forwardRef 全删,无人传 ref);要加「外部聚焦/清屏」需重新引入
|
||||
- `FilePanel` 虚拟化:固定行高自实现 windowing(>200 条目启用)。**`.sftp-row` 的 `height: 24px`(`sftp.css`)与 `FilePanel.tsx` 的 `ROW_HEIGHT` 必须同步改**——本仓库没有全局 box-sizing reset,行高写成 `box-sizing: border-box` 是有意的,去掉会让虚拟列表算术错位
|
||||
- `FilePanel` 虚拟化:自实现 windowing(>200 条目启用)。**行高与字号单一数据源在 `FilePanel.tsx`**:`BASE_ROW_HEIGHT = 26` × 缩放状态 `scale`(0.8–1.8,`localStorage` 的 `openterminal.sftpFontScale`)算出 `rowHeight`,经面板根元素 inline style 写成 CSS 变量 `--sftp-row-h` / `--sftp-fs` / `--sftp-fs-meta` / `--sftp-scale`;`windowStart(scrollTop, rowHeight)` 与 slice 计算都用这个动态值。**`sftp.css` 只消费变量、不得再写死行高或字号**(写死会让虚拟列表算术与真实行高脱节)。表头 `.sftp-head` 与 `.sftp-row` 共用同一套 grid 模板(列宽 `calc(Npx * var(--sftp-scale))`)才能对齐;行高写着 `box-sizing: border-box` 是有意的——本仓库没有全局 box-sizing reset,去掉会让 offset 算错。**别的坑**:antd 的 `Dropdown` 会把 `ant-dropdown-trigger` 类**克隆到子元素本身**(不是外面套一层),所以绝不能再写 `.sftp-list .ant-dropdown-trigger { display: block }` 这类后代选择器——特异性高于 `.sftp-row`,会把 `display: grid` 悄悄顶掉(旧代码正是如此);行与滚动容器各挂一个 contextMenu Dropdown,容器的 `onContextMenuCapture`(capture 早于 antd 的冒泡 trigger)记录「是否点在行内」,`onOpenChange` 据此拒绝打开,避免两个菜单叠加
|
||||
- 每个 dockview pane 内有 `PanelErrorBoundary`(`ErrorBoundary.tsx`):pane 崩溃只卸载自己,根部边界仍兜底
|
||||
- **本地路径准入**(`src/main/localPathGrants.ts`):SFTP 上传/下载与 zmodem 收发的本地路径必须来自系统对话框授权(pickFiles/pickDirectory 是唯一授权源,进程级内存注册表、不落盘),realpath+stat 双重校验、Windows 大小写折叠、解析后的路径才是要打开的路径。新增「渲染层构造本地路径传给主进程」的调用点时**必须过这道门**,别绕
|
||||
- `keyPath`(SSH 私钥):realpath → stat → 普通文件且 ≤1MB 才读(防设备文件永久阻塞 UI 线程/符号链接逃逸)
|
||||
- `src/shared/reservedAccelerators.ts`:设置页录制器与主进程 `applyGlobalShortcut` **共用同一张保留键表**(Ctrl+L、Ctrl+=/-/0/PgUp/PgDn),两处分表曾漂移出洞,加新全局快捷键时两边自动一致
|
||||
- SFTP 操作有 per-op 超时(`sftp.ts` 的 `bounded()`:元数据 30s / 传输块 60s / open 10s),超时按 transport 错误驱逐半死通道并重试一次;`setSftpTimeouts`/`setUpdateTimeouts` 是**测试缝**,生产无调用者,别接设置项
|
||||
- **PTY_EXIT 是一次性广播、不做重放**:绑定晚于退出的窗格靠 `SESSION_STATE` 查询通道(`sessionState()` + 退出码登记表 `sessionExits`,上限 512 条 FIFO)补偿,`TerminalView` 订阅完成后查一次。同理,SSH **飞行中连接**(`connectSsh` 未返回)登记在 `pendingOpens`(带 owner 与 `onClient` 最早引用),`killPtysByOwner`/`killAllPtys` 靠它中止握手,否则 renderer 崩溃后会留下无主孤儿会话
|
||||
- **zmodem 接收路径的背压靠 `pauseSource`/`resumeSource` 钩子**(`pty.ts` 注入,暂停喂入 sentry 的 ssh 流):fs WriteStream 缓冲无上限,慢盘 + 快对端会 OOM;`touchActivity` 只在写入被接受或 drain 恢复时计数,否则 stall 看门狗会把「堆在缓冲里」当进展。删除路径的 ENOENT 一律视为已删成功(`withSftp` 会整函数重跑,非幂等操作必须自己容忍「目标已消失」)
|
||||
- `webPreferences` 显式写死 `contextIsolation: true / nodeIntegration: false / webSecurity: true`(`index.ts` 唯一窗口创建点),防默认值被将来改动
|
||||
- 终端标签自动标题(「终端 N」)是**存储的显示文本**(进布局模板/会话快照/广播注册),`src/shared/terminalTitle.ts` 负责两个方向:反解用**全部 4 语言**的 pattern(任何语言生成的都能认出编号),渲染用当前语言;语言切换/快照恢复/模板应用时 `retitleAutoTitles` 原地重渲染(SSH 面板标题是用户起的连接名,永不动)。需要指定语言渲染时用 i18n 的 `tFor(lang, key, vars)`
|
||||
|
||||
@@ -91,6 +97,8 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
|
||||
- 应用模板(`Workspace.handleApplyTemplate`)读的是**外来 JSON**。`fromJSON` 一旦中途失败(别的版本写的模板、面板组件已不存在),dockview 会**先把目标 dockview 清空再抛错**(`failed to deserialize layout. Reverting changes`)。清空是逐面板走 `onDidRemovePanel` 的,所以**旧会话在抛错之前就已经被 `killSession` 杀掉了**——恢复出来的面板接不回它们,必须换新会话
|
||||
- 失败路径:应用前 `toJSON()` 快照两个 dockview → 抛错时只对**这次真的调用过 `fromJSON` 的** dockview 回灌快照(回灌本身会清空该 dockview;把快照灌进没被碰过的那个会连带杀掉它活着的会话)→ 对恢复出来的面板跑 `rebindRestoredPanels` → 再把「没有任何面板引用的 `previousSessions`」kill 掉 → `message.error`。`JSON.parse` 失败同样要提示,不能静默 return
|
||||
- 会话计数不靠累加器:`releaseSession` 直接扫 `api.panels` 判断还有没有面板在显示该会话——累加器与「`updateParameters` 原地换会话」「整块布局替换」这类无事件变化脱节,会漏杀或误杀
|
||||
- **旧版(M6.1 前单 dockview)模板只喂 terminal dockview**:成功路径的 rebind 与收尾 kill 都必须按 `sshTouched` / `live` 集合门控——rebind 会把面板参数改写成新**本地**会话,对没被 fromJSON 触碰的 ssh dockview 跑一遍等于把活 SSH 面板降级成终端再杀会话
|
||||
- 主机密钥队列只渲染队头,且主进程 `DEFAULT_TIMEOUTS.prompt`(120s)超时后**不再广播**:渲染层用同 TTL 的定时清扫(`HOST_KEY_PROMPT_TTL_MS`)移除过期条目,否则陈旧弹窗永远占住队头挡住后续主机;两处 TTL 必须同步改。`respondHostKey` 等 IPC 调用**不得写进 setState 的 updater**(React 可能重放 updater)
|
||||
|
||||
## 关键词高亮
|
||||
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# OpenTerminal Changelog
|
||||
|
||||
## v1.0.24 - 2026-10-09
|
||||
|
||||
## v1.0.23
|
||||
|
||||
### SFTP file management
|
||||
- **The list is now a real table**: Name / Size / Modified / Permissions / Owner / Group are aligned in six columns, with a sticky header that stays visible while scrolling long listings.
|
||||
- **Click-to-sort headers**: all six columns sort, with arrow indicators for ascending/descending; directories always come before files.
|
||||
- **Adjustable font size**: use the A- / A+ toolbar buttons or Ctrl+mouse wheel to scale between 0.8x and 1.8x. Row height follows the font size, and the setting is remembered locally, so it survives reopening the panel.
|
||||
- **Real owner / group names**: previously only numeric UID / GID were shown; the client now parses the real names (`eveuser`, `root`, ...) out of the data returned by the SSH server.
|
||||
- **Context menu on empty space**: right-click anywhere in the blank area of the list to get New Folder, Upload, Refresh and friends without having to select a row first.
|
||||
- The modified column now reads `2026/10/08 15:23`, and sizes read `30.7 KB`.
|
||||
|
||||
### Fixes
|
||||
- Fixed the file panel's six columns not actually aligning to the grid (the sort icon's style class was being cloned onto the row element, overriding the row's grid layout).
|
||||
|
||||
## v1.0.23 - 2026-10-09
|
||||
|
||||
### SFTP file management
|
||||
- **The list is now a real table**: Name / Size / Modified / Permissions / Owner / Group are aligned in six columns, with a sticky header that stays visible while scrolling long listings.
|
||||
- **Click-to-sort headers**: all six columns sort, with arrow indicators for ascending/descending; directories always come before files.
|
||||
- **Adjustable font size**: use the A- / A+ toolbar buttons or Ctrl+mouse wheel to scale between 0.8x and 1.8x. Row height follows the font size, and the setting is remembered locally, so it survives reopening the panel.
|
||||
- **Real owner / group names**: previously only numeric UID / GID were shown; the client now parses the real names (`eveuser`, `root`, ...) out of the data returned by the SSH server.
|
||||
- **Context menu on empty space**: right-click anywhere in the blank area of the list to get New Folder, Upload, Refresh and friends without having to select a row first.
|
||||
- The modified column now reads `2026/10/08 15:23`, and sizes read `30.7 KB`.
|
||||
|
||||
### Fixes
|
||||
- Fixed the file panel's six columns not actually aligning to the grid (the sort icon's style class was being cloned onto the row element, overriding the row's grid layout).
|
||||
|
||||
## v1.0.22 - 2026-10-08
|
||||
|
||||
### IME
|
||||
- **Fixed: Chinese IME candidate window not following the caret** (regression in v1.0.21): the v1.0.21 build environment had a terminal component dependency that did not match the locked version, so the installer shipped an older build without the upstream IME fix; this release restores the terminal component version used by v1.0.20, and the candidate window follows the caret again.
|
||||
|
||||
### Installer
|
||||
- The MSI installer is discontinued (electron-updater never supported MSI auto-updates); the NSIS exe is now the only installer format.
|
||||
|
||||
### Internal
|
||||
- The packaging pipeline now verifies dependency consistency before building: installed dependencies are compared against the lockfile and the build aborts on any mismatch, preventing this class of issue from recurring.
|
||||
|
||||
## v1.0.21 - 2026-10-07
|
||||
|
||||
### Background image experience
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# OpenTerminal 更新履歴
|
||||
|
||||
## v1.0.24 - 2026-10-09
|
||||
|
||||
## v1.0.23
|
||||
|
||||
### SFTP ファイル管理
|
||||
- **一覧が本物の表に**:名前 / サイズ / 更新日時 / パーミッション / 所有者 / グループの 6 列で整列表示。ヘッダーは固定され、長い一覧をスクロールしても常に見えます。
|
||||
- **ヘッダーをクリックで並べ替え**:6 列すべてが並べ替え可能。昇順・降順は矢印で表示され、ディレクトリは常にファイルより前に来ます。
|
||||
- **文字サイズの変更**:ツールバーの A- / A+ または Ctrl+ホイールで 0.8〜1.8 倍に調整。行の高さも文字サイズに追従し、設定はローカルに保存されるためパネルを開き直しても維持されます。
|
||||
- **所有者 / グループを実名表示**:従来は数字の UID / GID のみでしたが、SSH サーバーから返る情報を解析して `eveuser` や `root` といった実際の名前を表示します。
|
||||
- **空白部分の右クリックメニュー**:一覧の何もない場所を右クリックすると、新規フォルダー・アップロード・更新などを呼び出せます。行を先に選択する必要はありません。
|
||||
- 更新日時は `2026/10/08 15:23`、サイズは `30.7 KB` といった読みやすい形式になりました。
|
||||
|
||||
### 修正
|
||||
- ファイルパネルの 6 列が実際にはグリッド整列していなかった問題を修正(並べ替えアイコンのスタイルクラスが行要素に複製され、行のグリッドレイアウトを上書きしていました)。
|
||||
|
||||
## v1.0.23 - 2026-10-09
|
||||
|
||||
### SFTP ファイル管理
|
||||
- **一覧が本物の表に**:名前 / サイズ / 更新日時 / パーミッション / 所有者 / グループの 6 列で整列表示。ヘッダーは固定され、長い一覧をスクロールしても常に見えます。
|
||||
- **ヘッダーをクリックで並べ替え**:6 列すべてが並べ替え可能。昇順・降順は矢印で表示され、ディレクトリは常にファイルより前に来ます。
|
||||
- **文字サイズの変更**:ツールバーの A- / A+ または Ctrl+ホイールで 0.8〜1.8 倍に調整。行の高さも文字サイズに追従し、設定はローカルに保存されるためパネルを開き直しても維持されます。
|
||||
- **所有者 / グループを実名表示**:従来は数字の UID / GID のみでしたが、SSH サーバーから返る情報を解析して `eveuser` や `root` といった実際の名前を表示します。
|
||||
- **空白部分の右クリックメニュー**:一覧の何もない場所を右クリックすると、新規フォルダー・アップロード・更新などを呼び出せます。行を先に選択する必要はありません。
|
||||
- 更新日時は `2026/10/08 15:23`、サイズは `30.7 KB` といった読みやすい形式になりました。
|
||||
|
||||
### 修正
|
||||
- ファイルパネルの 6 列が実際にはグリッド整列していなかった問題を修正(並べ替えアイコンのスタイルクラスが行要素に複製され、行のグリッドレイアウトを上書きしていました)。
|
||||
|
||||
## v1.0.22 - 2026-10-08
|
||||
|
||||
### IME
|
||||
- **中国語 IME の候補ウィンドウがキャレットに追従しない問題を修正**(v1.0.21 でのリグレッション):v1.0.21 のビルド環境では端末コンポーネントの依存がロックされたバージョンと一致しておらず、インストーラーには上流の IME 修正を含まない古いビルドが同梱されていました。本バージョンでは v1.0.20 と同じ端末コンポーネントに戻し、候補ウィンドウが再びキャレットに追従します。
|
||||
|
||||
### インストーラー
|
||||
- MSI インストーラーの提供を終了しました(electron-updater は MSI の自動更新に非対応のため)。今後は NSIS exe インストーラーに統一されます。
|
||||
|
||||
### 内部
|
||||
- パッケージング前に依存関係の整合性チェックを追加:インストール済み依存とロックファイルを照合し、不一致があればビルドを中止します。同種の問題の再発を防ぎます。
|
||||
|
||||
## v1.0.21 - 2026-10-07
|
||||
|
||||
### 背景画像の体験
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
# OpenTerminal 更新日志
|
||||
|
||||
## v1.0.24 - 2026-10-09
|
||||
|
||||
一轮全面的审查修复:两个数据安全级问题、多处稳定性与内存问题,以及一批界面细节修补。
|
||||
|
||||
### 稳定性与数据安全
|
||||
- **修复应用旧版布局模板会误杀全部 SSH 会话的问题**:应用 v0.8 之前保存的单工作区布局模板时,SSH 工作区的活动会话会被静默断开、面板被错误替换为本地终端;现在未受模板影响的工作区保持原样。
|
||||
- **配置文件损坏时先备份再重置**:命令历史/命令库、连接书签、设置文件若是「合法 JSON 但内容结构不对」,此前会静默按空数据继续运行并在下次保存时整体覆盖;现在会先把原文件备份为 `.bak` 再重置,不再无声销毁数据。
|
||||
- **会话日志退出不再丢尾部**:托盘退出/关闭应用时,正在记录的会话日志最后一段也能完整落盘。
|
||||
|
||||
### SSH 与传输
|
||||
- **连接建立期间更稳**:SSH 握手尚未完成时渲染层崩溃或应用退出,不再遗留无法回收的孤儿连接;启动瞬间的双窗口竞态也已修复。
|
||||
- **快速退出的 shell 不再留下空白终端**:进程在面板绑定前就退出的极端情况下,窗格现在能正确显示退出状态,不再表现为「活着但永远空白」。
|
||||
- **主机密钥确认弹窗自动过期**:主机密钥确认晾置 2 分钟后(与服务端等待时限一致)自动消失,不再挡住后续主机的确认弹窗。
|
||||
- **ZMODEM 接收大文件更省内存**:向慢速磁盘保存大文件时增加背压控制,内存占用不再随文件大小无上限增长。
|
||||
- **SFTP 删除不再误报失败**:删除的目标文件已被其他方式删除(或重复删除)时视为成功,不再报「删除失败」。
|
||||
|
||||
### 界面与设置
|
||||
- 服务器监控首次采样即失败时显示中断提示,不再一直显示「采集中」。
|
||||
- 高亮规则导入增加格式版本校验,错误文件给出明确原因;内置「百分比」规则的名称在英文/繁中/日文界面下正确本地化。
|
||||
- 高亮规则导出下载修复偶发保存出空文件的问题。
|
||||
- 删除连接书签、清空命令历史等操作失败时给出错误提示,不再无响应。
|
||||
- 应用布局模板期间快速连点不再产生错乱(并发保护)。
|
||||
|
||||
### 内部
|
||||
- 更新通道发布护栏加固:通道版本探测失败时拒绝发布,防止旧版本误覆盖线上新版本。
|
||||
- 新增 3 组渲染层纯函数测试(共 200 项断言),离线测试增至 22 个。
|
||||
|
||||
## v1.0.23 - 2026-10-09
|
||||
|
||||
### SFTP 文件管理
|
||||
- **列表改为真正的表格**:名称 / 大小 / 修改时间 / 权限 / 用户 / 组六列对齐显示,表头吸顶,长列表滚动时始终可见
|
||||
- **点击表头排序**:六列均可排序,升降序切换有箭头指示,目录始终排在文件之前
|
||||
- **字体可缩放**:工具栏 A- / A+ 或 Ctrl+滚轮在 0.8–1.8 倍之间调整,行高与字号联动,设置记入本地,重开面板保持
|
||||
- **用户 / 组显示真实名称**:此前只显示数字 UID / GID,现在从 SSH 服务器返回的信息中解析出 `eveuser` / `root` 这样的真实名字
|
||||
- **空白处右键菜单**:在列表空白区域右键即可唤出新建文件夹、上传、刷新等操作,不必先点中某一行
|
||||
- 时间列改为 `2026/10/08 15:23` 格式,大小列改为 `30.7 KB` 这类易读写法
|
||||
|
||||
### 修复
|
||||
- 修复文件面板六列此前并未真正按网格对齐的问题(排序图标的样式类被误加到行元素上,顶掉了行的网格布局)
|
||||
|
||||
## v1.0.22 - 2026-10-08
|
||||
|
||||
### 输入法
|
||||
- **修复中文输入法候选窗不跟随光标**(v1.0.21 回归):v1.0.21 的构建环境中终端组件依赖未按锁定版本安装,安装包实际打进的是不含上游输入法修复的旧版本;本版本恢复 v1.0.20 所用的终端组件版本,候选窗重新紧贴光标。
|
||||
|
||||
### 安装包
|
||||
- 不再提供 MSI 安装包(electron-updater 本就不支持 MSI 自动更新),统一使用 NSIS exe 安装包。
|
||||
|
||||
### 内部
|
||||
- 打包流程新增依赖一致性校验:构建前自动比对实际安装的依赖与锁定清单,不一致即中止,防止同类问题再次发生。
|
||||
|
||||
## v1.0.21 - 2026-10-07
|
||||
|
||||
### 背景图体验
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# OpenTerminal 更新日誌
|
||||
|
||||
## v1.0.24 - 2026-10-09
|
||||
|
||||
## v1.0.23
|
||||
|
||||
### SFTP 檔案管理
|
||||
- **清單改為真正的表格**:名稱 / 大小 / 修改時間 / 權限 / 使用者 / 群組六欄對齊顯示,表頭吸頂,長清單捲動時始終可見
|
||||
- **點擊表頭排序**:六欄皆可排序,升降序切換有箭頭指示,目錄始終排在檔案之前
|
||||
- **字型可縮放**:工具列 A- / A+ 或 Ctrl+滾輪在 0.8–1.8 倍之間調整,行高與字號聯動,設定記入本機,重開面板保持
|
||||
- **使用者 / 群組顯示真實名稱**:先前只顯示數字 UID / GID,現在從 SSH 伺服器回傳的資訊中解析出 `eveuser` / `root` 這樣的真實名稱
|
||||
- **空白處右鍵選單**:在清單空白區域右鍵即可喚出新增資料夾、上傳、重新整理等操作,不必先點中某一列
|
||||
- 時間欄改為 `2026/10/08 15:23` 格式,大小欄改為 `30.7 KB` 這類易讀寫法
|
||||
|
||||
### 修復
|
||||
- 修復檔案面板六欄先前並未真正按網格對齊的問題(排序圖示的樣式類別被誤加到列元素上,頂掉了列的網格佈局)
|
||||
|
||||
## v1.0.23 - 2026-10-09
|
||||
|
||||
### SFTP 檔案管理
|
||||
- **清單改為真正的表格**:名稱 / 大小 / 修改時間 / 權限 / 使用者 / 群組六欄對齊顯示,表頭吸頂,長清單捲動時始終可見
|
||||
- **點擊表頭排序**:六欄皆可排序,升降序切換有箭頭指示,目錄始終排在檔案之前
|
||||
- **字型可縮放**:工具列 A- / A+ 或 Ctrl+滾輪在 0.8–1.8 倍之間調整,行高與字號聯動,設定記入本機,重開面板保持
|
||||
- **使用者 / 群組顯示真實名稱**:先前只顯示數字 UID / GID,現在從 SSH 伺服器回傳的資訊中解析出 `eveuser` / `root` 這樣的真實名稱
|
||||
- **空白處右鍵選單**:在清單空白區域右鍵即可喚出新增資料夾、上傳、重新整理等操作,不必先點中某一列
|
||||
- 時間欄改為 `2026/10/08 15:23` 格式,大小欄改為 `30.7 KB` 這類易讀寫法
|
||||
|
||||
### 修復
|
||||
- 修復檔案面板六欄先前並未真正按網格對齊的問題(排序圖示的樣式類別被誤加到列元素上,頂掉了列的網格佈局)
|
||||
|
||||
## v1.0.22 - 2026-10-08
|
||||
|
||||
### 輸入法
|
||||
- **修復中文輸入法候選視窗不跟隨游標**(v1.0.21 回歸):v1.0.21 的建置環境中終端機元件相依未按鎖定版本安裝,安裝包實際打包的是不含上游輸入法修復的舊版本;本版本恢復 v1.0.20 所用的終端機元件版本,候選視窗重新緊貼游標。
|
||||
|
||||
### 安裝包
|
||||
- 不再提供 MSI 安裝包(electron-updater 本就不支援 MSI 自動更新),統一使用 NSIS exe 安裝包。
|
||||
|
||||
### 內部
|
||||
- 打包流程新增相依一致性校驗:建置前自動比對實際安裝的相依與鎖定清單,不一致即中止,防止同類問題再次發生。
|
||||
|
||||
## v1.0.21 - 2026-10-07
|
||||
|
||||
### 背景圖體驗
|
||||
|
||||
@@ -4,6 +4,25 @@
|
||||
|
||||
> 产品官网(GitHub Pages):<https://billowliu2.github.io/OpenTerminal/>,源码在 [`website/`](website/)。
|
||||
|
||||
## 截图
|
||||
|
||||
| 多路分屏并行 AI 编程 Agent | 无背景图的干净分屏 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
| 主题与个性化(背景图 / 不透明度 / 压暗层) | 锁屏(Ctrl+L,遮罩下会话不断线) |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
## 优势
|
||||
|
||||
- **轻量**:渲染层依赖全量打包进 bundle、不重复进安装包,应用本体 asar 仅 8.1MB,NSIS 安装包约 109MB;单一 exe 安装包,应用内自动更新
|
||||
- **隐私与安全**:SSH 凭据经 DPAPI 加密落盘、渲染层接触不到明文;锁屏密码只存 scrypt 派生值;SFTP/ZMODEM 的本地路径必须经系统对话框授权,阻断构造路径读写
|
||||
- **性能**:xterm.js WebGL 渲染;关键词高亮引擎实测 0.02–0.05ms/KB(200KB 混合输出 4–9ms);SFTP 大目录虚拟滚动;分屏尺寸去抖同步,TUI 不反复重绘
|
||||
- **AI 编程友好**:为 Kimi Code / Claude Code / Codex 等高频刷新 TUI 优化;中文输入法候选窗紧贴光标
|
||||
- **国内可用**:更新检查 GitHub 优先(跟随系统代理),失败自动回退国内直连通道,两边都拿不到时 30s 整体超时兜底
|
||||
- **开源可审计**:MIT 协议;主/预载/渲染三端契约集中在 `src/shared/`,模块间零横向依赖;19 个离线测试覆盖存储、锁屏、高亮、传输与更新回退
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
@@ -11,7 +30,7 @@ npm install # 已安装可跳过
|
||||
npm run dev # 开发模式(热更新)
|
||||
npm run build # 生产构建到 out/
|
||||
npm run typecheck # 全量类型检查
|
||||
npm test # 先自动跑 typecheck,再重建测试 bundle 并跑 12 个离线测试(详见「测试」)
|
||||
npm test # 先自动跑 typecheck,再重建测试 bundle 并跑 19 个离线测试(详见「测试」)
|
||||
```
|
||||
|
||||
> Electron 二进制下载失败时:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js`
|
||||
@@ -60,12 +79,12 @@ npm test # 先自动跑 typecheck,再重建测试 bundle 并跑 12
|
||||
- 锁定期间吞掉 F5 / Ctrl+R / Ctrl+±0 / Ctrl+Shift+I/J/C,并整块摘除应用菜单,防止菜单键绕过遮罩
|
||||
|
||||
**外观与窗口**
|
||||
- 12 款内置主题(Dracula / One Half / Solarized / Gruvbox / Nord / Monokai / GitHub 等)
|
||||
- 11 款内置主题(Dracula / One Half / Solarized / Gruvbox / Nord / Monokai / GitHub 等)
|
||||
- 自定义主题编辑器:16 色 ANSI 调色板 + 前景/背景/光标/选区色,实时预览
|
||||
- 窗口标题栏与背景跟随终端主题
|
||||
- 系统托盘:关闭按钮可设「每次询问 / 最小化到托盘 / 直接退出」,托盘菜单可切换
|
||||
- 单实例运行:重复启动唤出已有窗口
|
||||
- 自动更新:GitHub 优先(走系统代理),失败回退国内 Gitea 更新通道(强制直连);单次检查 30s 整体超时,MSI 仅分发、自动更新走 NSIS 安装包
|
||||
- 自动更新:GitHub 优先(走系统代理),失败回退国内 Gitea 更新通道(强制直连);单次检查 30s 整体超时;统一使用 NSIS exe 安装包(自 v1.0.22 起不再提供 MSI)
|
||||
- 设置持久化(userData/settings.json,原子写入)+ 多窗口实时同步
|
||||
|
||||
**界面与语言**
|
||||
@@ -74,15 +93,22 @@ npm test # 先自动跑 typecheck,再重建测试 bundle 并跑 12
|
||||
|
||||
**测试**
|
||||
|
||||
`npm test` 会先跑 `pretest`(= `npm run typecheck`),再重建 esbuild bundle,最后依次跑下列 12 个测试(全部离线,无需服务器与凭据):
|
||||
`npm test` 会先跑 `pretest`(= `npm run typecheck`),再重建 esbuild bundle,最后依次跑下列 19 个测试(全部离线,无需服务器与凭据):
|
||||
|
||||
- `node tests/ssh-loopback.mjs` — ssh2 客户端/服务端回环(认证、shell、数据、resize、指纹)
|
||||
- `node tests/commands-store.mjs` — 命令库 / 历史 / 会话日志存储
|
||||
- `node tests/connections-store.mjs` — SSH 书签 CRUD、公开/密文字段切分、损坏文件备份
|
||||
- `node tests/settings-store.mjs` — 设置清洗器(closeAction/高亮规则修复/旧预设升级/告警日志)
|
||||
- `node tests/local-path-grants.mjs` — 本地路径准入(对话框授权、realpath+stat 双重校验、大小写折叠)
|
||||
- `node tests/lock-store.mjs` — 锁屏密码校验器(scrypt 往返、文件损坏处理)
|
||||
- `node tests/lock-controller.mjs` — 锁屏控制器(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)
|
||||
- `node tests/lock-shortcuts.mjs` — 锁屏快捷键分类器(表驱动:Ctrl+L 恐慌锁 / 锁定时禁用的组合键)
|
||||
- `node tests/reserved-accelerators.mjs` — 保留快捷键表(设置录制器与全局注册共用同一张表)
|
||||
- `node tests/.terminal-title.cjs` — 终端标签自动标题(四语言反解与重渲染)
|
||||
- `node tests/ipc-guard.mjs` — IPC 发送方守卫(仅信任本应用渲染帧)
|
||||
- `node tests/updater-fallback.mjs` — 更新通道回退(GitHub 优先 / Gitea 兜底、超时、防降级)
|
||||
- `node tests/log-sanitizer.mjs` — 会话日志脱敏(原始 PTY 字节流转可读文本)
|
||||
- `node tests/sftp-timeout.mjs` — SFTP 操作超时(半死通道驱逐与重试)
|
||||
- `node tests/.hl-split-smoke.cjs` — 关键词高亮流分块回归(bundle 由 `node tests/build-bundles.cjs` 生成)
|
||||
- `node tests/.hl-rules.cjs` — 内置高亮预设(词边界、大小写、负向词、危险命令)
|
||||
- `node tests/zmodem-e2e.mjs` — ZMODEM 双向传输(与第二个 zmodem.js Sentry 对接,内容一致性)
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
| M9 | 体验打磨:系统托盘 + 关闭行为 + 单实例 + 主题联动标题栏 + 布局菜单重做 + 补全交互修正 + 应用图标 | ✅ 已完成 |
|
||||
| M10 | 多语言界面(zh-CN / zh-TW / en / ja)+ 离线多语言更新日志 + 快捷键录制 + 可配终端工具条 + 设置健壮性 | ✅ 已完成(v1.0.13) |
|
||||
| M11 | 审查修复第二轮:渲染层性能 + asar 瘦身 + 安全纵深 + 测试补全 + 文档刷新 | ✅ 已完成(2026-10,Dev_20261001) |
|
||||
| M12 | 锁屏:主窗口遮罩 + scrypt 密码 + Ctrl+L 立即锁屏 + 冷却阶梯 + 落盘状态 | ✅ 已完成(v1.0.17–1.0.20;v1.0.18 修输入回归,v1.0.20 摘除菜单加固) |
|
||||
| M13 | 更新源改为 GitHub 优先 + Gitea 通道兜底;高亮引擎性能优化 | ✅ 已完成(v1.0.19–1.0.20) |
|
||||
|
||||
## M2 — SSH 远程会话 ✅
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 当前版本与仓库
|
||||
|
||||
- v1.0.20,远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取
|
||||
- v1.0.23,远程 `git.codingplan.site/admin/OpenTerminal.git`(国内仓)+ `github.com/billowliu2/OpenTerminal.git`(GitHub 镜像仓);凭据存于 `.env`(已 git 忽略),凭据助手按 host 自动读取
|
||||
- 开源协议:MIT(LICENSE)
|
||||
- 更新通道 = `https://git.codingplan.site/api/packages/admin/generic/openterminal-update/stable/`(公网可读,含 latest.yml/exe/blockmap)
|
||||
- 技术栈:Electron + electron-vite + React 19 + TS strict + antd 6(全局深色)+ zustand + dockview-react 8 + @xterm/xterm 6 + @lydell/node-pty + ssh2 + zmodem.js + electron-updater + electron-builder
|
||||
@@ -56,15 +56,16 @@
|
||||
- **广播输入**:渲染层 zustand(broadcastStore)维护 enabled/targets/sessions;TerminalView 全部写路径(onData/补全/粘贴/Workspace runCommand)走 writeBroadcast 扇出;目标 <2 自动禁用;tab 目标圆点 + 按钮计数徽标
|
||||
- **ZMODEM**:主进程引擎(src/main/zmodem.ts,仅 SSH 会话;本地 pty 走 ConPTY 只给 UTF-8 字符串,二进制会损坏——不支持,注释已说明);Sentry 常驻分流非 zmodem 字节;offer→渲染层选文件/目录→respond;传输期抑制 PTY_DATA/replay/日志并丢弃用户键入;offer 120s/传输 90s 看门狗;进度复用 TransferPanel(kind=zmodem-upload/download);测试 tests/zmodem-e2e.mjs 用第二个 zmodem.js Sentry 模拟远端,双向内容一致性断言
|
||||
- **快捷键**:Ctrl+=/-/0 字号(main.tsx capture 监听,xterm-helper-textarea 放行——隐藏 textarea 曾被误判为输入框导致终端聚焦时失效,已修);globalShowHide accelerator(globalShortcuts.ts,设置页系统分区可配,注册失败仅 warn);Ctrl+PgUp/PgDn 面板循环(Workspace capture 监听)
|
||||
- **打包**:electron-builder.yml(msi 固定 upgradeCode 5ab9f79e-e4eb-4052-9df6-3af3a301ab0a + nsis;asarUnpack @lydell/node-pty + ssh2;npmRebuild false);updater.ts(仅 packaged 启用,OT_UPDATE_URL/OT_UPDATE_TOKEN env,默认 feed=上述 generic package 地址)
|
||||
- **打包**:electron-builder.yml(**NSIS x64 是唯一安装包**——v1.0.22 起不再构建 MSI,electron-updater 本就不支持 MSI 自动更新;asarUnpack @lydell/node-pty + ssh2;npmRebuild false);updater.ts(仅 packaged 启用,OT_UPDATE_URL/OT_UPDATE_TOKEN env,默认 feed=上述 generic package 地址)
|
||||
|
||||
## 发布流程(下一版本照抄)
|
||||
|
||||
1. `package.json` version 升位 + 写 `RELEASE_NOTES.md`(可选 `.zh-TW/.en/.ja` 译文)→ `node scripts/sync-changelog.cjs`(**在 dist 之前**:更新日志会打进安装包)→ `npm run dist`(env:ELECTRON_MIRROR + ELECTRON_BUILDER_BINARIES_MIRROR=npmmirror;dist:dir 后先删 release/win-unpacked 避免占用 EPERM)。`predist` 会先跑 `npm test`(typecheck + 12 个离线测试)再 `npm install --package-lock-only` 同步锁文件根版本号,**release 提交要包含 package-lock.json**(否则根版本会漂移,v1.0.15–1.0.19 曾漂了 5 个版本)
|
||||
2. `node scripts/release.cjs <版本号>`(**不带 skip 参数**,Gitea 与 GitHub 一起发):脚本自己建 Gitea release(msi/exe 资产)→ 传更新通道 `exe.blockmap → exe → release-notes.md → latest.yml`(**latest.yml 最后**);再把 exe/exe.blockmap/latest.yml 作为 release 资产同步发到 GitHub(electron-updater 标准 GitHub provider 直接吃 release 资产)。不再先删旧版,latest.yml 生效后才清掉上一版 exe/blockmap。上传前 `assertNoDowngrade()` 会读通道 latest.yml,线上版本更高时直接拒绝
|
||||
1. `package.json` version 升位 + 写 `RELEASE_NOTES.md`(可选 `.zh-TW/.en/.ja` 译文)→ `node scripts/sync-changelog.cjs`(**在 dist 之前**:更新日志会打进安装包)→ `npm run dist`(env:ELECTRON_MIRROR + ELECTRON_BUILDER_BINARIES_MIRROR=npmmirror;dist:dir 后先删 release/win-unpacked 避免占用 EPERM)。`predist` 会先跑 `npm test`(typecheck + 19 个离线测试)再 `npm install --package-lock-only` 同步锁文件根版本号,**release 提交要包含 package-lock.json**(否则根版本会漂移,v1.0.15–1.0.19 曾漂了 5 个版本)
|
||||
2. `node scripts/release.cjs <版本号>`(**不带 skip 参数**,Gitea 与 GitHub 一起发):脚本自己建 Gitea release(exe 资产)→ 传更新通道 `exe.blockmap → exe → release-notes.md → latest.yml`(**latest.yml 最后**);再把 exe/exe.blockmap/latest.yml 作为 release 资产同步发到 GitHub(electron-updater 标准 GitHub provider 直接吃 release 资产)。不再先删旧版,latest.yml 生效后才清掉上一版 exe/blockmap。上传前 `assertNoDowngrade()` 会读通道 latest.yml,线上版本更高时直接拒绝——**该探测已 fail-closed**:网络错误 / 非 404 失败 / 解析不出 version 一律抛错中止发布,只有通道真空(404)才放行;通道探测在 `pruneChannel` 前那次是 best-effort(失败只跳过清理并打日志,不中断已生效的发布)
|
||||
3. 通道传坏了只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub;同一版本可重复运行:release 复用、已传资产跳过)
|
||||
4. 校验:无 token `curl .../generic/openterminal-update/stable/latest.yml` 应 200 且 version 正确
|
||||
5. `git tag vX.Y.Z && git push origin main vX.Y.Z`(GitHub 镜像推代码/tag + release 资产,与 Gitea 保持同步)
|
||||
4. 跳过部分目标的 flag:`--skip-github` 只跳 GitHub release;`--skip-gitea-release` 只跳 Gitea release,**更新通道照常上传**;`--skip-gitea` 两者都跳(打印醒目警告——国内用户将收不到该版本,只想跳 release 请用 `--skip-gitea-release`)
|
||||
5. 校验:无 token `curl .../generic/openterminal-update/stable/latest.yml` 应 200 且 version 正确
|
||||
6. `git tag vX.Y.Z && git push origin main vX.Y.Z`(GitHub 镜像推代码/tag + release 资产,与 Gitea 保持同步)
|
||||
|
||||
> GitHub 请求走 `HTTPS_PROXY=http://127.0.0.1:7897`(脚本只把它用于 GitHub);国内通道全程直连,不设代理
|
||||
|
||||
@@ -73,22 +74,31 @@
|
||||
```bash
|
||||
npm run typecheck # tsconfig.node.json + tsconfig.web.json
|
||||
npm run build
|
||||
npm test # = pretest(typecheck) + node tests/build-bundles.cjs + 下面 12 个测试(依次,全部离线可跑)
|
||||
npm test # = pretest(typecheck) + node tests/build-bundles.cjs + 下面 19 个测试(依次,全部离线可跑)
|
||||
node tests/ssh-loopback.mjs
|
||||
node tests/commands-store.mjs
|
||||
node tests/connections-store.mjs # SSH 书签 CRUD / 公开-密文切分 / 损坏文件备份
|
||||
node tests/settings-store.mjs # 设置清洗器(closeAction/高亮规则修复/旧预设升级/告警日志)
|
||||
node tests/local-path-grants.mjs # 本地路径准入(对话框授权、realpath+stat、大小写折叠)
|
||||
node tests/lock-store.mjs # 锁屏密码校验值(scrypt 往返、损坏文件)
|
||||
node tests/lock-controller.mjs # 冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动
|
||||
node tests/lock-shortcuts.mjs # 锁屏快捷键分类器(表驱动)
|
||||
node tests/reserved-accelerators.mjs # 保留快捷键表(设置页录制器与主进程注册守卫共用一张表)
|
||||
node tests/.terminal-title.cjs # 终端自动标题多语言反解与原地重渲染
|
||||
node tests/ipc-guard.mjs # IPC sender guard(走真实注册路径灌伪造帧)
|
||||
node tests/updater-fallback.mjs # 更新源回退与超时预算(GitHub→Gitea)
|
||||
node tests/log-sanitizer.mjs # 会话日志纯文本转换(ANSI 状态机 + 字节切分 fuzz)
|
||||
node tests/sftp-timeout.mjs # SFTP per-op 超时与半死通道驱逐
|
||||
node tests/.hl-split-smoke.cjs
|
||||
node tests/.hl-rules.cjs # 内置高亮预设(词边界、大小写、负向词、危险命令)
|
||||
node tests/zmodem-e2e.mjs
|
||||
node tests/ssh-session-e2e.mjs
|
||||
node tests/sysinfo-e2e.mjs
|
||||
# `node tests/build-bundles.cjs` 单独重建全部 esbuild bundle(别名只存在于该脚本):
|
||||
# .session-e2e.cjs(pty.ts) .sftp-svc.mjs(sftp.ts,ESM) .commands-store.cjs .connections-store.cjs
|
||||
# .known-hosts.cjs .settings-store.cjs .lock-store.cjs .lock-controller.cjs .lock-shortcuts.cjs
|
||||
# .session-e2e.cjs(pty.ts) .ssh.cjs(ssh.ts) .sftp-svc.mjs(sftp.ts,ESM) .commands-store.cjs
|
||||
# .connections-store.cjs .known-hosts.cjs .settings-store.cjs .local-path-grants.cjs
|
||||
# .lock-store.cjs .lock-controller.cjs .lock-shortcuts.cjs .reserved-accelerators.cjs
|
||||
# .terminal-title.cjs .updater.cjs .ipc.cjs .log-sanitizer.cjs .ipc-channels.cjs
|
||||
# .zmodem-e2e.cjs .hl-split-smoke.cjs .hl-rules.cjs —— 少 --alias:@shared=./src/shared 会编译失败
|
||||
# 真实服务器测试(需 JD 环境变量凭据,旧凭据已过期,不在 npm test 内):
|
||||
# JD_HOST=... JD_USER=root JD_PASS=... node tests/sftp-real.mjs / tests/sftp-chmod.mjs
|
||||
|
||||
@@ -16,23 +16,12 @@ asarUnpack:
|
||||
# @lydell/node-pty ships prebuilt binaries; do not trigger a rebuild.
|
||||
npmRebuild: false
|
||||
|
||||
# Electron-updater only supports NSIS auto-updates (MSI has no update channel).
|
||||
# The NSIS .exe + latest.yml get uploaded together; MSI is manual/enterprise only.
|
||||
# NSIS exe is the only installer: electron-updater only supports NSIS
|
||||
# auto-updates, so the MSI (no update channel) was dropped in v1.0.22.
|
||||
win:
|
||||
target:
|
||||
- target: msi
|
||||
- target: nsis
|
||||
|
||||
msi:
|
||||
oneClick: false
|
||||
# Fixed upgrade code keeps the upgrade chain consistent across releases.
|
||||
upgradeCode: "{5ab9f79e-e4eb-4052-9df6-3af3a301ab0a}"
|
||||
artifactName: ${productName}-${version}-setup.${ext}
|
||||
# Shortcuts need an <Icon>; build/icon.png (auto-converted to .ico) provides it.
|
||||
# Without an icon the WiX shortcut Icon reference fails to link (LGHT0094).
|
||||
createDesktopShortcut: true
|
||||
createStartMenuShortcut: true
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "open-terminal",
|
||||
"version": "1.0.21",
|
||||
"version": "1.0.24",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-terminal",
|
||||
"version": "1.0.21",
|
||||
"version": "1.0.24",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "open-terminal",
|
||||
"productName": "OpenTerminal",
|
||||
"version": "1.0.21",
|
||||
"version": "1.0.24",
|
||||
"description": "Open-source terminal with SSH, split panes, themes and fonts",
|
||||
"main": "out/main/index.js",
|
||||
"author": "CodingPlan.Site",
|
||||
@@ -13,9 +13,9 @@
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
|
||||
"pretest": "npm run typecheck",
|
||||
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs",
|
||||
"predist": "npm test && npm install --package-lock-only",
|
||||
"dist": "electron-vite build && electron-builder --win msi nsis",
|
||||
"test": "node tests/build-bundles.cjs && node tests/ssh-loopback.mjs && node tests/commands-store.mjs && node tests/connections-store.mjs && node tests/settings-store.mjs && node tests/local-path-grants.mjs && node tests/lock-store.mjs && node tests/lock-controller.mjs && node tests/lock-shortcuts.mjs && node tests/reserved-accelerators.mjs && node tests/.terminal-title.cjs && node tests/ipc-guard.mjs && node tests/updater-fallback.mjs && node tests/log-sanitizer.mjs && node tests/sftp-timeout.mjs && node tests/.hl-split-smoke.cjs && node tests/.hl-rules.cjs && node tests/zmodem-e2e.mjs && node tests/ssh-session-e2e.mjs && node tests/sysinfo-e2e.mjs && node tests/terminal-cwd.mjs && node tests/terminal-links.mjs && node tests/broadcast-store.mjs",
|
||||
"predist": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only",
|
||||
"dist": "electron-vite build && electron-builder --win nsis",
|
||||
"dist:dir": "electron-vite build && electron-builder --win --dir"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
// OpenTerminal release publisher
|
||||
// Usage: node scripts/release.cjs <version> [--skip-github] [--skip-gitea] [--channel-only]
|
||||
// Usage: node scripts/release.cjs <version> [--skip-github] [--skip-gitea-release] [--skip-gitea] [--channel-only]
|
||||
// node scripts/release.cjs 1.0.2
|
||||
// node scripts/release.cjs 1.0.2 --channel-only # update channel only (repair)
|
||||
// Flags:
|
||||
// --skip-github skip the GitHub release (assets included)
|
||||
// --skip-gitea-release skip only the Gitea *release*; the Gitea update
|
||||
// channel still runs (this is what you want if the
|
||||
// release already exists / is not needed)
|
||||
// --skip-gitea skip the Gitea release AND the Gitea update channel
|
||||
// — Chinese users then never see this version; prefer
|
||||
// --skip-gitea-release unless you really mean both
|
||||
// --channel-only update channel only (repair); no release, no GitHub
|
||||
// Reads release notes from RELEASE_NOTES.md (repo root, gitignored).
|
||||
// Credentials from .env (repo root, gitignored): GIT_TOKEN, GH_TOKEN.
|
||||
// If HTTPS_PROXY / HTTP_PROXY (env or .env) is set, traffic goes through it
|
||||
@@ -13,11 +22,16 @@ const path = require('node:path')
|
||||
const ROOT = path.join(__dirname, '..')
|
||||
const V = process.argv[2]
|
||||
if (!V || !/^\d+\.\d+\.\d+$/.test(V)) {
|
||||
console.error('usage: node scripts/release.cjs <x.y.z> [--skip-github] [--skip-gitea] [--channel-only]')
|
||||
console.error('usage: node scripts/release.cjs <x.y.z> [--skip-github] [--skip-gitea-release] [--skip-gitea] [--channel-only]')
|
||||
console.error(' --skip-github skip the GitHub release')
|
||||
console.error(' --skip-gitea-release skip only the Gitea release; the update channel still runs')
|
||||
console.error(' --skip-gitea skip the Gitea release AND the update channel (CN users get no update)')
|
||||
console.error(' --channel-only update channel only (repair)')
|
||||
process.exit(1)
|
||||
}
|
||||
const SKIP_GH = process.argv.includes('--skip-github')
|
||||
const SKIP_GITEA = process.argv.includes('--skip-gitea')
|
||||
const SKIP_GITEA_RELEASE = process.argv.includes('--skip-gitea-release')
|
||||
const CHANNEL_ONLY = process.argv.includes('--channel-only')
|
||||
|
||||
/** Strip one pair of matching quotes. A .env quotes its values for the shell
|
||||
@@ -42,14 +56,12 @@ const env = Object.fromEntries(
|
||||
)
|
||||
const notes = fs.readFileSync(path.join(ROOT, 'RELEASE_NOTES.md'), 'utf8')
|
||||
const R = path.join(ROOT, 'release')
|
||||
const msi = path.join(R, `OpenTerminal-${V}-setup.msi`)
|
||||
const exe = path.join(R, `OpenTerminal-${V}-setup.exe`)
|
||||
const files = [msi, exe]
|
||||
// --channel-only republishes the update channel, which carries the NSIS exe and
|
||||
// its blockmap (electron-updater cannot consume an MSI) — the msi is a release
|
||||
// asset only, so demanding it here would block the repair path this mode exists
|
||||
// for. The channel's own file list is validated in giteaChannel().
|
||||
for (const f of CHANNEL_ONLY ? [exe] : files) {
|
||||
const files = [exe]
|
||||
// Only the NSIS exe is built (the MSI was dropped in v1.0.22 — electron-updater
|
||||
// never supported it). The channel's own file list is validated in
|
||||
// giteaChannel().
|
||||
for (const f of files) {
|
||||
if (!fs.existsSync(f)) { console.error('missing build artifact:', f); process.exit(1) }
|
||||
}
|
||||
// Guard rails: the artifacts must belong to the version being published —
|
||||
@@ -181,16 +193,24 @@ async function assertNoDowngrade() {
|
||||
console.log(`channel currently serves v${live}`)
|
||||
}
|
||||
|
||||
/** Version the update channel serves right now (read from its latest.yml), or
|
||||
undefined when the channel is empty/unreachable. */
|
||||
/** Version the update channel serves right now (read from its latest.yml).
|
||||
Fail-closed: only an *empty* channel (HTTP 404) reports "unknown". A network
|
||||
error, a non-404 failure or a body without a version all throw — otherwise a
|
||||
probe that fails for any reason would read as "no live version" and let a
|
||||
downgrade through the guard below. */
|
||||
async function channelVersion(base) {
|
||||
let resp
|
||||
try {
|
||||
const resp = await fetch(`${base}/latest.yml`)
|
||||
if (!resp.ok) return undefined
|
||||
return (await resp.text()).match(/^version:\s*(\S+)/m)?.[1]
|
||||
} catch {
|
||||
return undefined
|
||||
resp = await fetch(`${base}/latest.yml`)
|
||||
} catch (e) {
|
||||
throw new Error(`cannot reach update channel (${e.message})`)
|
||||
}
|
||||
// 404 = nothing published yet; that is a legal empty channel, not an error.
|
||||
if (resp.status === 404) return undefined
|
||||
if (!resp.ok) throw new Error(`update channel latest.yml returned HTTP ${resp.status}`)
|
||||
const version = (await resp.text()).match(/^version:\s*(\S+)/m)?.[1]
|
||||
if (!version) throw new Error('update channel latest.yml carries no version field')
|
||||
return version
|
||||
}
|
||||
|
||||
/** Size of a channel file, or -1 when it is not there. */
|
||||
@@ -266,7 +286,17 @@ async function giteaChannel() {
|
||||
// (ECONNRESET) can no longer leave the channel empty and unrepairable.
|
||||
const isLatest = ([f]) => path.basename(f) === 'latest.yml'
|
||||
const ordered = [...channelFiles.filter((e) => !isLatest(e)), ...channelFiles.filter(isLatest)]
|
||||
const previous = await channelVersion(base)
|
||||
// Best effort by design: this probe only decides whether the previous
|
||||
// version's binaries get pruned afterwards. It now throws on a failed probe
|
||||
// (fail-closed for the downgrade guard), and by the time we are here the new
|
||||
// latest.yml is about to be live — a hiccup in a cleanup helper must never
|
||||
// abort the tail of the publish.
|
||||
let previous
|
||||
try {
|
||||
previous = await channelVersion(base)
|
||||
} catch (e) {
|
||||
console.log(` cannot read the live channel version (${e.message}) — skipping prune of the previous release`)
|
||||
}
|
||||
for (const [f, name] of ordered) {
|
||||
const stat = fs.statSync(f)
|
||||
const url = `${base}/${encodeURIComponent(name)}`
|
||||
@@ -409,7 +439,15 @@ async function main() {
|
||||
console.log('done (channel only)')
|
||||
return
|
||||
}
|
||||
if (!SKIP_GITEA) { await gitea(); await giteaChannel() }
|
||||
if (SKIP_GITEA) {
|
||||
console.warn('*** WARNING: --skip-gitea skips the Gitea release AND the Gitea update channel. ***')
|
||||
console.warn('*** Domestic users will NOT receive this version — it is only on GitHub, which most ***')
|
||||
console.warn('*** of them cannot reach. To skip just the release, use --skip-gitea-release. ***')
|
||||
}
|
||||
// --skip-gitea-release: the release object already exists (or is not wanted),
|
||||
// but the channel still has to be fed — that is the whole update path at home.
|
||||
if (!SKIP_GITEA && !SKIP_GITEA_RELEASE) await gitea()
|
||||
if (!SKIP_GITEA) await giteaChannel()
|
||||
if (!SKIP_GH) await github()
|
||||
console.log('done')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// 构建垫片:Windows 上安全软件实时扫描新解压的文件时会短暂持有目录句柄(实测 ~4s),
|
||||
// electron-builder 解包后立即 rename 会撞 EPERM。对 fs.promises 的 rename/rm/unlink/copyFile
|
||||
// 加 EPERM/EBUSY/ENOTEMPTY 重试(30 次 × 1s),stderr 会打印每次重试。
|
||||
// 用法(命中 EPERM 时):NODE_OPTIONS="--require $(pwd -W)/scripts/rename-retry-shim.cjs" npm run dist
|
||||
const fs = require('node:fs');
|
||||
|
||||
const RETRY_CODES = new Set(['EPERM', 'EBUSY', 'ENOTEMPTY']);
|
||||
const MAX_TRIES = 30;
|
||||
const DELAY_MS = 1000;
|
||||
|
||||
function wrap(name) {
|
||||
const orig = fs.promises[name];
|
||||
if (typeof orig !== 'function') return;
|
||||
fs.promises[name] = async function (...args) {
|
||||
for (let i = 0; ; i++) {
|
||||
try {
|
||||
return await orig.apply(fs.promises, args);
|
||||
} catch (err) {
|
||||
if (!RETRY_CODES.has(err && err.code) || i >= MAX_TRIES - 1) throw err;
|
||||
process.stderr.write(`[rename-retry-shim] ${name} ${args[0]} -> ${err.code}, retry ${i + 1}/${MAX_TRIES}\n`);
|
||||
await new Promise(r => setTimeout(r, DELAY_MS));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
wrap('rename');
|
||||
wrap('rm');
|
||||
wrap('unlink');
|
||||
wrap('copyFile');
|
||||
@@ -0,0 +1,59 @@
|
||||
/* Dependency consistency gate: node_modules must match package-lock.json.
|
||||
|
||||
Usage: node scripts/verify-deps.cjs (runs first in `predist`)
|
||||
|
||||
Why it exists: v1.0.21 shipped an input-method fix that never reached the
|
||||
build. @xterm/xterm had been pinned to 6.1.0-beta.304 in package.json, but
|
||||
node_modules still held 6.0.0 from an older install — `npm install
|
||||
--package-lock-only` (the last step of predist) rewrites only the lockfile,
|
||||
so the lockfile agreed with package.json while the tree on disk stayed
|
||||
stale, and the bundler took the stale tree. An install that never happened
|
||||
is invisible to every other check, so it gets its own gate here.
|
||||
|
||||
Scope: version equality only, for every entry the lockfile lists under
|
||||
node_modules. The root entry ("") is deliberately excluded — bumping
|
||||
package.json makes the lockfile root version lag by design until the
|
||||
`npm install --package-lock-only` at the end of predist rewrites it. */
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const ROOT = path.join(__dirname, '..')
|
||||
const lock = JSON.parse(fs.readFileSync(path.join(ROOT, 'package-lock.json'), 'utf8'))
|
||||
const packages = lock.packages ?? {}
|
||||
|
||||
const mismatched = []
|
||||
let checked = 0
|
||||
// Optional entries are the cross-platform variants (darwin/linux/arm64 …) that
|
||||
// npm records in the lockfile but never installs on this machine. Their absence
|
||||
// is expected, not drift.
|
||||
let skippedOptional = 0
|
||||
|
||||
for (const [key, entry] of Object.entries(packages)) {
|
||||
if (key === '' || !key.startsWith('node_modules/')) continue
|
||||
// The key is the path under node_modules, so it also resolves nested
|
||||
// ("node_modules/a/node_modules/b") and scoped ("node_modules/@scope/pkg")
|
||||
// entries without any name reconstruction.
|
||||
let installed
|
||||
try {
|
||||
installed = JSON.parse(fs.readFileSync(path.join(ROOT, key, 'package.json'), 'utf8')).version
|
||||
} catch {
|
||||
installed = undefined
|
||||
}
|
||||
if (installed === undefined) {
|
||||
if (entry.optional) skippedOptional++
|
||||
else mismatched.push([key, entry.version, 'MISSING'])
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
if (installed !== entry.version) mismatched.push([key, entry.version, installed])
|
||||
}
|
||||
|
||||
if (mismatched.length) {
|
||||
console.error('node_modules does not match package-lock.json:')
|
||||
for (const [name, expected, actual] of mismatched) {
|
||||
console.error(` ${name}: lockfile expects ${expected}, installed ${actual}`)
|
||||
}
|
||||
console.error('\nnode_modules 与 package-lock.json 不一致,请运行 npm ci 重装依赖')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`dependencies ok: ${checked} packages verified, ${skippedOptional} optional entries skipped`)
|
||||
@@ -20,7 +20,16 @@
|
||||
|
||||
import { app, shell } from 'electron'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync, statSync, copyFileSync } from 'fs'
|
||||
import {
|
||||
appendFileSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
copyFileSync
|
||||
} from 'fs'
|
||||
import { appendFile } from 'fs/promises'
|
||||
import { basename, dirname, join, resolve, sep } from 'path'
|
||||
import type { CommandItem, SessionLogMeta } from '../shared/commands'
|
||||
@@ -83,6 +92,15 @@ function backupUnparseableCommands(file: string, err: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single exit for an unusable commands.json — parse failure and wrong
|
||||
* shape both end here, so neither can quietly skip the backup.
|
||||
*/
|
||||
function discardCommandsFile(file: string, err: unknown): CommandsFile {
|
||||
backupUnparseableCommands(file, err)
|
||||
return emptyFile()
|
||||
}
|
||||
|
||||
export class CommandsStore {
|
||||
/** <userData>/commands.json */
|
||||
private readonly file: string
|
||||
@@ -106,6 +124,17 @@ export class CommandsStore {
|
||||
private readonly logBuffers = new Map<string, string>()
|
||||
/** In-flight drain loop per log file; absent when the file is settled. */
|
||||
private readonly logDrains = new Map<string, Promise<void>>()
|
||||
/**
|
||||
* Log files with an appendFile actually in flight. logStop reads this to tell
|
||||
* whether a synchronous final flush can race an issued write (see logStop).
|
||||
*/
|
||||
private readonly logInFlight = new Set<string>()
|
||||
/**
|
||||
* Files whose session stopped while a write was in flight: the drain loop
|
||||
* flushes what is left synchronously instead of issuing another async round,
|
||||
* which at quit would not land. Cleared when the loop settles.
|
||||
*/
|
||||
private readonly logFinalFlush = new Set<string>()
|
||||
/** Plain-text transformer per actively-logged session (see logSanitizer). */
|
||||
private readonly sanitizers = new Map<string, LogSanitizer>()
|
||||
|
||||
@@ -130,11 +159,17 @@ export class CommandsStore {
|
||||
library: Array.isArray(data.library) ? data.library : []
|
||||
}
|
||||
}
|
||||
// Valid JSON of the wrong shape (`[1,2,3]`, `{}`) is not "no commands":
|
||||
// it is a file we cannot read, and the next write would rewrite it from
|
||||
// an empty history. Same exit as the parse failure below.
|
||||
return discardCommandsFile(
|
||||
this.file,
|
||||
new Error('commands.json shape mismatch: expected {history,library}')
|
||||
)
|
||||
} catch (err) {
|
||||
// missing -> start fresh; unreadable / corrupt -> keep the original first
|
||||
backupUnparseableCommands(this.file, err)
|
||||
return discardCommandsFile(this.file, err)
|
||||
}
|
||||
return emptyFile()
|
||||
}
|
||||
|
||||
private saveCommands(data: CommandsFile): void {
|
||||
@@ -412,15 +447,34 @@ export class CommandsStore {
|
||||
const chunk = this.logBuffers.get(file)
|
||||
if (chunk === undefined) break
|
||||
this.logBuffers.delete(file)
|
||||
if (this.logFinalFlush.has(file)) {
|
||||
// The session is gone, so this is the file's last flush and it has to
|
||||
// be durable when this turn ends: at quit the process can be gone
|
||||
// before a second async round lands. Reached only after the in-flight
|
||||
// append above completed, so the order is still the write order.
|
||||
try {
|
||||
appendFileSync(file, chunk, 'utf8')
|
||||
} catch {
|
||||
// file may have been removed after stop -> ignore
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Marked around the append itself: logStop's synchronous flush must not
|
||||
// slip in between an issued write and its landing.
|
||||
this.logInFlight.add(file)
|
||||
try {
|
||||
await appendFile(file, chunk, 'utf8')
|
||||
} catch {
|
||||
// file may have been removed after stop -> ignore
|
||||
}
|
||||
this.logInFlight.delete(file)
|
||||
}
|
||||
})()
|
||||
run.finally(() => {
|
||||
if (this.logDrains.get(file) === run) this.logDrains.delete(file)
|
||||
// The loop only stops once the buffer is empty, so nothing more can arrive
|
||||
// for a stopped file: the flag must not outlive the drain.
|
||||
this.logFinalFlush.delete(file)
|
||||
})
|
||||
this.logDrains.set(file, run)
|
||||
return run
|
||||
@@ -432,10 +486,36 @@ export class CommandsStore {
|
||||
if (!meta) return
|
||||
const tail = this.sanitizers.get(sessionId)?.flush() ?? ''
|
||||
this.sanitizers.delete(sessionId)
|
||||
if (tail) {
|
||||
// Best effort: the trailing partial line belongs in the file too.
|
||||
this.logBuffers.set(meta.file, (this.logBuffers.get(meta.file) ?? '') + tail)
|
||||
const pending = (this.logBuffers.get(meta.file) ?? '') + tail
|
||||
if (pending) {
|
||||
if (this.logInFlight.has(meta.file)) {
|
||||
// An appendFile for this file is already issued and cannot be
|
||||
// cancelled, so a synchronous write now would land *before* it and swap
|
||||
// the last two pieces of the log. Put the tail back in the buffer
|
||||
// instead and mark the file: the running loop re-reads the buffer after
|
||||
// that append lands and flushes it synchronously (see drainLog), so the
|
||||
// tail is durable and still in write order.
|
||||
this.logBuffers.set(meta.file, pending)
|
||||
this.logFinalFlush.add(meta.file)
|
||||
this.drainLog(meta.file)
|
||||
} else {
|
||||
// No async write is in flight, so this one cannot race anything. It
|
||||
// must be synchronous: the quit path (killAllPtys -> safeStopLog ->
|
||||
// logStop) has no later sync point, and a fresh appendFile chain may
|
||||
// never get to run.
|
||||
//
|
||||
// Bounded by construction: with nothing in flight the buffer is empty
|
||||
// (the loop only stops looking once it is), so this is just the
|
||||
// sanitizer tail — one partial line, plus at most one marker line. The
|
||||
// sanitizer's only other buffer (a CSI sequence) is capped at 1KB and
|
||||
// never reaches the output. One line's worth of text, once per stop.
|
||||
this.logBuffers.delete(meta.file)
|
||||
try {
|
||||
appendFileSync(meta.file, pending, 'utf8')
|
||||
} catch {
|
||||
// file may have been removed after stop -> ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
this.finishLog(meta)
|
||||
}
|
||||
|
||||
@@ -167,6 +167,15 @@ function backupUnparseableConnections(file: string, err: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single exit for an unusable connections.json — parse failure and wrong
|
||||
* shape both end here, so neither can quietly skip the backup.
|
||||
*/
|
||||
function discardConnectionsFile(file: string, err: unknown): StoredConnection[] {
|
||||
backupUnparseableConnections(file, err)
|
||||
return []
|
||||
}
|
||||
|
||||
export class ConnectionsStore {
|
||||
constructor(private readonly filePath: string) {}
|
||||
|
||||
@@ -182,11 +191,17 @@ export class ConnectionsStore {
|
||||
typeof (x as StoredConnection).host === 'string'
|
||||
)
|
||||
}
|
||||
// Valid JSON of the wrong shape (`{}`) is not "no bookmarks": it is a file
|
||||
// we cannot read, and the next write would rewrite it from an empty list.
|
||||
// Same exit as the parse failure below.
|
||||
return discardConnectionsFile(
|
||||
this.filePath,
|
||||
new Error('connections.json shape mismatch: expected an array')
|
||||
)
|
||||
} catch (err) {
|
||||
// missing -> start fresh; unreadable / corrupt -> keep the original first
|
||||
backupUnparseableConnections(this.filePath, err)
|
||||
return discardConnectionsFile(this.filePath, err)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private save(list: StoredConnection[]): void {
|
||||
|
||||
@@ -100,7 +100,10 @@ if (!gotSingleInstanceLock) {
|
||||
// so the menu teardown is applied here from the flag itself.
|
||||
const lock = initLockController()
|
||||
applyMenuLockState(lock.isLocked())
|
||||
createWindow()
|
||||
// A second launch that raced this startup already created (or surfaced) a
|
||||
// window from its `second-instance` handler; creating another here would
|
||||
// leave two. Same guard the activate handler below uses.
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
initTray(showOrCreate)
|
||||
// Tray labels are resolved from the dictionary at build time, so the menu has
|
||||
// to be rebuilt whenever the interface language changes.
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
import { broadcast } from './broadcast'
|
||||
import { CommandsStore, defaultCommandsPath } from './commands'
|
||||
import type { CommandItem } from '../shared/commands'
|
||||
import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay, registerLogHooks } from './pty'
|
||||
import { createPty, killPty, resizePty, writePty, openSession, configureSessionRuntime, getSessionReplay, sessionState, registerLogHooks } from './pty'
|
||||
import { respondZmodem } from './zmodem'
|
||||
import type { ZmodemResponse } from '../shared/ipc'
|
||||
|
||||
@@ -153,6 +153,9 @@ export function registerIpc(): void {
|
||||
ipcMain.handle(Ipc.PTY_CREATE, (event, opts?: PtyCreateOptions) => createPty(opts, event.sender.id))
|
||||
ipcMain.handle(Ipc.SESSION_OPEN, (event, opts: SessionOpenOptions) => openSession(opts, event.sender.id))
|
||||
ipcMain.handle(Ipc.SESSION_REPLAY, (_event, id: string) => getSessionReplay(id))
|
||||
// Compensates the one-shot PTY_EXIT broadcast: a pane that subscribed after
|
||||
// its shell died asks here instead of waiting forever.
|
||||
ipcMain.handle(Ipc.SESSION_STATE, (_event, id: string) => sessionState(id))
|
||||
ipcMain.on(Ipc.PTY_WRITE, (_event, id: string, data: string) => writePty(id, data))
|
||||
ipcMain.on(Ipc.PTY_RESIZE, (_event, id: string, cols: number, rows: number) =>
|
||||
resizePty(id, cols, rows)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { spawn, type IPty } from '@lydell/node-pty'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
import { StringDecoder } from 'string_decoder'
|
||||
import { Ipc, type PtyCreateOptions, type PtyCreateResult } from '../shared/ipc'
|
||||
import { Ipc, type PtyCreateOptions, type PtyCreateResult, type SessionStateResult } from '../shared/ipc'
|
||||
import type { SessionOpenOptions, HostKeyPromptEvent, SshConnection } from '../shared/connections'
|
||||
import { broadcast } from './broadcast'
|
||||
import { connectSsh, type SshSessionHandle } from './ssh'
|
||||
@@ -104,6 +104,68 @@ export function getSessionReplay(id: string): string {
|
||||
return replayBuffers.get(id) ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit codes of sessions that are already gone. PTY_EXIT is broadcast exactly
|
||||
* once and never replayed, so a pane that subscribes after its shell died
|
||||
* would sit blank forever — the renderer compensates by querying SESSION_STATE
|
||||
* once its subscription is in place. Insertion-ordered: the oldest entry is
|
||||
* evicted first, and the cap only has to cover the panes that are still
|
||||
* catching up.
|
||||
*/
|
||||
const MAX_SESSION_EXITS = 512
|
||||
const sessionExits = new Map<string, number>()
|
||||
|
||||
function rememberSessionExit(id: string, exitCode: number): void {
|
||||
// Re-inserting keeps the map in "most recently exited last" order.
|
||||
sessionExits.delete(id)
|
||||
sessionExits.set(id, exitCode)
|
||||
while (sessionExits.size > MAX_SESSION_EXITS) {
|
||||
const oldest = sessionExits.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
sessionExits.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the main process knows about a session id: alive, exited (with its code),
|
||||
* or unknown — a temp id from an aborted connect, or an evicted exit record.
|
||||
*/
|
||||
export function sessionState(id: string): SessionStateResult {
|
||||
if (sessions.has(id)) return { exists: true, exited: false, exitCode: null }
|
||||
const exitCode = sessionExits.get(id)
|
||||
if (exitCode === undefined) return { exists: false, exited: false, exitCode: null }
|
||||
return { exists: false, exited: true, exitCode }
|
||||
}
|
||||
|
||||
/**
|
||||
* In-flight ssh connects, keyed by a temp id. Until `connectSsh` resolves there
|
||||
* is no entry in `sessions`, so an owner that vanished during the handshake
|
||||
* (renderer crash) or an app quit used to leave the connect running: it
|
||||
* completed later and registered a session nobody owns. The attempt is
|
||||
* registered up front so killPtysByOwner / killAllPtys can abort it — the
|
||||
* client is ended, and openSession tears down whatever still comes back
|
||||
* instead of adopting it.
|
||||
*/
|
||||
interface PendingOpen {
|
||||
owner?: number
|
||||
client?: SshSessionHandle['client']
|
||||
aborted: boolean
|
||||
}
|
||||
|
||||
const pendingOpens = new Map<string, PendingOpen>()
|
||||
|
||||
function abortPendingOpens(matches: (pending: PendingOpen) => boolean): void {
|
||||
for (const pending of pendingOpens.values()) {
|
||||
if (!matches(pending)) continue
|
||||
pending.aborted = true
|
||||
try {
|
||||
pending.client?.end()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies injected once by ipc.ts (configureSessionRuntime) so pty.ts
|
||||
* stays free of store / known-hosts imports and the ssh service can remain
|
||||
@@ -230,6 +292,8 @@ export function createPty(opts: PtyCreateOptions = {}, owner?: number): PtyCreat
|
||||
// The session is gone: drop its replay buffer too (killPty was the only
|
||||
// path that did, so naturally-exiting shells leaked up to 64KB each).
|
||||
replayBuffers.delete(id)
|
||||
// Remember the code for panes that subscribe after this broadcast.
|
||||
rememberSessionExit(id, exitCode)
|
||||
safeStopLog(id)
|
||||
broadcast(Ipc.PTY_EXIT, { id, exitCode })
|
||||
} catch {
|
||||
@@ -258,7 +322,15 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
|
||||
}
|
||||
|
||||
const conn = deps.getConnection(opts.connectionId)
|
||||
const handle = await connectSsh(conn, opts.secretOverride, {
|
||||
// Register the attempt before the handshake: until it resolves there is no
|
||||
// session entry, so this is the only handle an owner-based kill has on it.
|
||||
const tempId = randomUUID()
|
||||
const pending: PendingOpen = { owner, aborted: false }
|
||||
pendingOpens.set(tempId, pending)
|
||||
|
||||
let handle: SshSessionHandle
|
||||
try {
|
||||
handle = await connectSsh(conn, opts.secretOverride, {
|
||||
connections: {
|
||||
getSecret: (c, field) => deps.getSecret(c, field),
|
||||
touch: (id: string) => {
|
||||
@@ -272,8 +344,39 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
|
||||
},
|
||||
knownHosts: deps.knownHosts,
|
||||
broadcast: deps.broadcast,
|
||||
promptHostKey: deps.promptHostKey
|
||||
promptHostKey: deps.promptHostKey,
|
||||
// Earliest reference to the client, so an abort can end it mid-handshake.
|
||||
onClient: (client) => {
|
||||
pending.client = client
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
// An aborted attempt is our own teardown, not a connect failure: whoever
|
||||
// asked for the session is already gone (owner crash / app quit), so the
|
||||
// answer is the temp id — nothing was ever registered under it.
|
||||
if (!pending.aborted) throw err
|
||||
return { id: tempId }
|
||||
} finally {
|
||||
pendingOpens.delete(tempId)
|
||||
}
|
||||
|
||||
// The owner died while the handshake was in flight. Nothing has been
|
||||
// registered yet, so the teardown is the transport alone — same order as
|
||||
// killSession (stream first, then client) and no session-scoped cleanups.
|
||||
if (pending.aborted) {
|
||||
try {
|
||||
handle.stream.close()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
try {
|
||||
handle.client.end()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
return { id: tempId }
|
||||
}
|
||||
|
||||
sessions.set(handle.id, { kind: 'ssh', ssh: handle, owner })
|
||||
sshDecoders.set(handle.id, new StringDecoder('utf8'))
|
||||
|
||||
@@ -301,6 +404,29 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
|
||||
// stream may already be dead
|
||||
}
|
||||
}
|
||||
},
|
||||
// Receive-side backpressure: pausing the ssh stream stops the bytes that
|
||||
// feed the zmodem sentry, so a slow local disk cannot pile the whole
|
||||
// transfer up in the fs WriteStream's unbounded buffer.
|
||||
pauseSource: (id) => {
|
||||
const s = sessions.get(id)
|
||||
if (s?.kind === 'ssh') {
|
||||
try {
|
||||
s.ssh.stream.pause()
|
||||
} catch {
|
||||
// stream may already be dead
|
||||
}
|
||||
}
|
||||
},
|
||||
resumeSource: (id) => {
|
||||
const s = sessions.get(id)
|
||||
if (s?.kind === 'ssh') {
|
||||
try {
|
||||
s.ssh.stream.resume()
|
||||
} catch {
|
||||
// stream may already be dead
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -333,6 +459,7 @@ export async function openSession(opts: SessionOpenOptions, owner?: number): Pro
|
||||
sessions.delete(handle.id)
|
||||
replayBuffers.delete(handle.id)
|
||||
sshDecoders.delete(handle.id)
|
||||
rememberSessionExit(handle.id, exitCode)
|
||||
deps.broadcast(Ipc.PTY_EXIT, { id: handle.id, exitCode })
|
||||
} catch {
|
||||
// never crash the event loop
|
||||
@@ -428,9 +555,13 @@ export function killPtysByOwner(owner: number): void {
|
||||
if (sessions.get(id)?.owner !== owner) continue
|
||||
killSession(id)
|
||||
}
|
||||
// A connect that has not resolved yet has no session entry to match on, so it
|
||||
// would finish later and register an orphan.
|
||||
abortPendingOpens((pending) => pending.owner === owner)
|
||||
}
|
||||
|
||||
export function killAllPtys(): void {
|
||||
abortPendingOpens(() => true)
|
||||
for (const id of sessions.keys()) {
|
||||
forceStopPolling(id)
|
||||
closeSftp(id)
|
||||
@@ -458,5 +589,6 @@ export function killAllPtys(): void {
|
||||
}
|
||||
}
|
||||
sessions.clear()
|
||||
replayBuffers.clear()
|
||||
sshDecoders.clear()
|
||||
}
|
||||
@@ -202,11 +202,23 @@ function refreshBuiltinRules(rules: HighlightRule[], warnings: Warnings): Highli
|
||||
return [...refreshed, ...missing.map((rule) => ({ ...rule }))].sort((a, b) => a.priority - b.priority)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh copies of the preset rules for a caller that may mutate what it gets
|
||||
* back. Handing out the module-level array (or its objects) would let one
|
||||
* store's edit leak into every later load in the process.
|
||||
*/
|
||||
function copyDefaultRules(): HighlightRule[] {
|
||||
return DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule }))
|
||||
}
|
||||
|
||||
function sanitizeRules(value: unknown, warnings: Warnings): HighlightRule[] {
|
||||
// An explicit empty array is a valid choice ("no highlighting"); only
|
||||
// malformed data falls back to the built-in rules. Returning the defaults for
|
||||
// [] made deleting the last rule look like it silently failed.
|
||||
if (!Array.isArray(value)) return DEFAULT_HIGHLIGHT_RULES
|
||||
if (!Array.isArray(value)) {
|
||||
warnings.push('highlightRules: not an array — built-in rules restored')
|
||||
return copyDefaultRules()
|
||||
}
|
||||
const rules: HighlightRule[] = []
|
||||
value.forEach((entry, index) => {
|
||||
const rule = coerceRule(entry, index, warnings)
|
||||
@@ -457,23 +469,48 @@ function backupUnparseableSettings(err: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSettings(): AppSettings {
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8'))
|
||||
const { settings, errors } = deepMerge(raw)
|
||||
reportWarnings(errors)
|
||||
return settings
|
||||
} catch (err) {
|
||||
backupUnparseableSettings(err)
|
||||
/**
|
||||
* The defaults loadSettings falls back to. Fresh objects every call so a caller
|
||||
* mutating what it got back cannot poison the module-level presets.
|
||||
*/
|
||||
function defaultSettings(): AppSettings {
|
||||
return {
|
||||
terminal: { ...DEFAULT_SETTINGS.terminal },
|
||||
customThemes: [...DEFAULT_SETTINGS.customThemes],
|
||||
highlightRules: DEFAULT_HIGHLIGHT_RULES.map((rule) => ({ ...rule })),
|
||||
highlightRules: copyDefaultRules(),
|
||||
highlightProfiles: [],
|
||||
system: { ...DEFAULT_SYSTEM },
|
||||
lock: { ...DEFAULT_SETTINGS.lock }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single exit for an unusable settings.json — parse failure and wrong shape
|
||||
* both end here, so neither can quietly skip the backup.
|
||||
*/
|
||||
function discardUnparseableSettings(err: unknown): AppSettings {
|
||||
backupUnparseableSettings(err)
|
||||
return defaultSettings()
|
||||
}
|
||||
|
||||
export function loadSettings(): AppSettings {
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8'))
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
// Valid JSON of the wrong shape (`[1,2,3]`, `"text"`, `null`) is not "no
|
||||
// settings": it is a file we cannot read, and the next mutation would
|
||||
// rewrite it from the defaults — taking the custom themes and highlight
|
||||
// rules with it. Per-field repair below only applies to a real object.
|
||||
return discardUnparseableSettings(
|
||||
new Error('settings.json shape mismatch: expected an object')
|
||||
)
|
||||
}
|
||||
const { settings, errors } = deepMerge(raw)
|
||||
reportWarnings(errors)
|
||||
return settings
|
||||
} catch (err) {
|
||||
return discardUnparseableSettings(err)
|
||||
}
|
||||
}
|
||||
|
||||
let settingsQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
@@ -256,30 +256,51 @@ export function closeSftp(sessionId: string): void {
|
||||
const FAKE_FS = new Set(['tmpfs', 'overlay', 'udev', 'devtmpfs', 'none', 'squashfs', 'shm'])
|
||||
|
||||
/** readdir, both shapes ssh2 can yield (plain names or `{filename}` objects). */
|
||||
function readdir(sftp: SFTPWrapper, dir: string): Promise<string[]> {
|
||||
return p<Array<string | { filename: string }>>(cb => sftp.readdir(dir, cb)).then(raw =>
|
||||
raw.map(n => (typeof n === 'string' ? n : n.filename))
|
||||
function readdirDetailed(sftp: SFTPWrapper, dir: string): Promise<Array<{ name: string; longname?: string }>> {
|
||||
return p<Array<string | { filename: string; longname?: string }>>(cb => sftp.readdir(dir, cb)).then(raw =>
|
||||
raw.map(n => (typeof n === 'string' ? { name: n } : { name: n.filename, longname: n.longname }))
|
||||
)
|
||||
}
|
||||
|
||||
function readdir(sftp: SFTPWrapper, dir: string): Promise<string[]> {
|
||||
return readdirDetailed(sftp, dir).then(list => list.map(e => e.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner/group names off an OpenSSH longname, e.g.
|
||||
* `-rw-r--r-- 1 eveuser eveuser 231 Apr 1 2020 .bashrc`.
|
||||
* The mode string is required before we trust the field positions — some
|
||||
* servers put a different listing format in `longname` and the columns then
|
||||
* mean nothing. Best effort: any surprise yields `{}`, never a throw.
|
||||
*/
|
||||
function parseLongnameOwner(longname: string | undefined): { owner?: string; group?: string } {
|
||||
if (!longname) return {}
|
||||
const fields = longname.trim().split(/\s+/)
|
||||
if (fields.length < 4) return {}
|
||||
if (!/^[bcdlps-][rwxstST-]{9}$/.test(fields[0])) return {}
|
||||
return { owner: fields[2], group: fields[3] }
|
||||
}
|
||||
|
||||
export function listRemote(sessionId: string, dir: string): Promise<SftpEntry[]> {
|
||||
return withSftp(sessionId, async sftp => {
|
||||
const names = await readdir(sftp, dir)
|
||||
const names = await readdirDetailed(sftp, dir)
|
||||
const entries: SftpEntry[] = []
|
||||
const queue = [...names]
|
||||
const base = dir.replace(/\/+$/, '') || '/'
|
||||
const workers = Array.from({ length: Math.min(8, Math.max(queue.length, 1)) }, async () => {
|
||||
for (;;) {
|
||||
const name = queue.shift()
|
||||
if (name === undefined) return
|
||||
const item = queue.shift()
|
||||
if (item === undefined) return
|
||||
const { name, longname } = item
|
||||
const path = `${base}/${name}`
|
||||
try {
|
||||
const st = await lstat(sftp, path)
|
||||
entries.push({
|
||||
name, path, isDir: st.isDirectory(), size: st.size, mtime: st.mtime * 1000,
|
||||
uid: st.uid, gid: st.gid, mode: formatMode(st.mode)
|
||||
uid: st.uid, gid: st.gid, mode: formatMode(st.mode), ...parseLongnameOwner(longname)
|
||||
})
|
||||
} catch {
|
||||
// no longname data here — the namespace listing is all we have
|
||||
entries.push({ name, path, isDir: false, size: 0, mtime: 0 })
|
||||
}
|
||||
}
|
||||
@@ -304,12 +325,58 @@ export function renameRemote(sessionId: string, from: string, to: string): Promi
|
||||
return withSftp(sessionId, sftp => pVoid(cb => sftp.rename(from, to, cb)))
|
||||
}
|
||||
|
||||
/**
|
||||
* "The file is already gone" — the one server-side failure a delete may treat
|
||||
* as success. ssh2 reports the SFTP status both ways: `code` carries the numeric
|
||||
* status (2 = SSH_FX_NO_SUCH_FILE) and `message` the library's own English text,
|
||||
* so both are checked (builds fill one or the other). Our own translated errors
|
||||
* never reach this check — they arrive as OperationError, which the caller
|
||||
* passes through untouched.
|
||||
*/
|
||||
function isNoSuchFile(err: unknown): boolean {
|
||||
if ((err as { code?: unknown } | undefined)?.code === 2) return true
|
||||
return /no such file/i.test((err as Error | undefined)?.message ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* unlink/rmdir where "already gone" counts as success.
|
||||
*
|
||||
* Deleting is not idempotent by nature: the entry may have been removed by
|
||||
* someone else between the listing and the click, or the same delete may be run
|
||||
* again after an earlier pass already removed it. Reporting a path that is
|
||||
* verifiably gone as "delete failed" is a fake error — the end state the user
|
||||
* asked for already holds.
|
||||
*/
|
||||
async function unlinkIfPresent(sftp: SFTPWrapper, path: string): Promise<void> {
|
||||
try {
|
||||
await pVoid(cb => sftp.unlink(path, cb))
|
||||
} catch (err) {
|
||||
if (!isNoSuchFile(err)) throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function rmdirIfPresent(sftp: SFTPWrapper, path: string): Promise<void> {
|
||||
try {
|
||||
await pVoid(cb => sftp.rmdir(path, cb))
|
||||
} catch (err) {
|
||||
if (!isNoSuchFile(err)) throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecursive(sftp: SFTPWrapper, path: string, isDir: boolean): Promise<void> {
|
||||
if (!isDir) {
|
||||
await pVoid(cb => sftp.unlink(path, cb))
|
||||
await unlinkIfPresent(sftp, path)
|
||||
return
|
||||
}
|
||||
const names = await readdir(sftp, path)
|
||||
let names: string[]
|
||||
try {
|
||||
names = await readdir(sftp, path)
|
||||
} catch (err) {
|
||||
// The directory is gone (removed by someone else, or by an earlier pass of
|
||||
// this same delete): everything inside went with it, so the intent holds.
|
||||
if (isNoSuchFile(err)) return
|
||||
throw err
|
||||
}
|
||||
const base = path.replace(/\/+$/, '') || '/'
|
||||
for (const name of names) {
|
||||
const child = `${base}/${name}`
|
||||
@@ -321,7 +388,7 @@ async function deleteRecursive(sftp: SFTPWrapper, path: string, isDir: boolean):
|
||||
}
|
||||
await deleteRecursive(sftp, child, childIsDir)
|
||||
}
|
||||
await pVoid(cb => sftp.rmdir(path, cb))
|
||||
await rmdirIfPresent(sftp, path)
|
||||
}
|
||||
|
||||
export function deleteRemote(sessionId: string, paths: string[]): Promise<void> {
|
||||
@@ -333,7 +400,7 @@ export function deleteRemote(sessionId: string, paths: string[]): Promise<void>
|
||||
try {
|
||||
isDir = (await lstat(sftp, path)).isDirectory()
|
||||
} catch {
|
||||
// stat failed — fall through to unlink
|
||||
// stat failed — fall through to unlink, which tolerates "already gone"
|
||||
}
|
||||
await deleteRecursive(sftp, path, isDir)
|
||||
} catch (err) {
|
||||
|
||||
@@ -64,6 +64,13 @@ export interface SshServiceDeps {
|
||||
fingerprint: string
|
||||
reason: 'new' | 'changed'
|
||||
}): void
|
||||
/**
|
||||
* Called with the ssh2 client as soon as it exists — the earliest moment it
|
||||
* can be ended. pty.ts keeps the reference so an owner crash / app quit
|
||||
* during the handshake can abort the connect instead of letting it finish and
|
||||
* register a session nobody owns.
|
||||
*/
|
||||
onClient?: (client: Client) => void
|
||||
timeoutMs?: { prompt: number; connect: number }
|
||||
}
|
||||
|
||||
@@ -188,6 +195,12 @@ export async function connectSsh(
|
||||
// failure paths before resolution: `fail` and the connect timer
|
||||
armConnectTimer()
|
||||
|
||||
// Hand out the client before anything is dialled, so an abort during the
|
||||
// handshake has a reference to end(). end() is a no-op until connect()
|
||||
// created the socket, but the executor runs synchronously, so connect()
|
||||
// below is already under way before openSession regains control.
|
||||
deps.onClient?.(handshake)
|
||||
|
||||
handshake.on('error', (err: Error) => {
|
||||
console.error(`[ssh] client error: ${err.message}`)
|
||||
const message = verifierErr ?? err.message
|
||||
@@ -315,7 +328,7 @@ const pendingPrompts = new Map<string, PendingPrompt>()
|
||||
|
||||
/**
|
||||
* Ask the renderer to approve / reject a host key. Resolves `true`/`false`.
|
||||
* Times out (default 30s) -> treated as reject.
|
||||
* Times out (default 120s) -> treated as reject.
|
||||
*/
|
||||
function promptUser(
|
||||
host: string,
|
||||
|
||||
@@ -157,7 +157,17 @@ function pollOnce(id: string, state: PollState): void {
|
||||
|
||||
try {
|
||||
client.exec(COLLECT_CMD, (err: Error | undefined, stream) => {
|
||||
if (state.stopped) return
|
||||
if (state.stopped) {
|
||||
// Polling stopped while this exec was in flight: the channel is already
|
||||
// open, so simply dropping the stream would leak it for the rest of the
|
||||
// ssh session. Close it (best effort — it may be dead already) and go.
|
||||
try {
|
||||
stream?.close()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
return
|
||||
}
|
||||
if (err || !stream) {
|
||||
handleError(id, state, err?.message || t('main.sysinfo.execFailed'))
|
||||
return
|
||||
|
||||
@@ -65,6 +65,19 @@ export interface ZmodemDeps {
|
||||
toTerminal(sessionId: string, data: Buffer): void
|
||||
/** Write bytes out to the ssh stream (zmodem frames + CAN abort sequence). */
|
||||
writeStream(sessionId: string, data: Buffer): void
|
||||
/**
|
||||
* Backpressure over the ssh stream that FEEDS this session's sentry. pty.ts
|
||||
* owns that channel and injects these two, mirroring writeStream: the engine
|
||||
* has no handle on it, and pausing the source is the only thing that bounds
|
||||
* how much a peer can push into a file sink that is over its highWaterMark.
|
||||
*
|
||||
* Optional so a harness with no real stream still runs (and so an injector
|
||||
* that predates this hook keeps working): without it a backed-up sink still
|
||||
* stops counting progress, which turns a wedged disk into a stall-watchdog
|
||||
* failure instead of an unbounded queue — but the queue itself stays.
|
||||
*/
|
||||
pauseSource?(sessionId: string): void
|
||||
resumeSource?(sessionId: string): void
|
||||
}
|
||||
|
||||
interface Engine {
|
||||
@@ -94,7 +107,18 @@ interface Engine {
|
||||
offerTimer: NodeJS.Timeout | null
|
||||
stallTimer: NodeJS.Timeout | null
|
||||
receiveStream: Writable | null
|
||||
/**
|
||||
* Octets the current file's sink ACCEPTED. A write that only landed in the
|
||||
* WriteStream's own queue (write() returned false) is not counted here until
|
||||
* the queue drains — see pendingBytes and the receive on_input.
|
||||
*/
|
||||
bytes: number
|
||||
/** Octets handed to a backed-up sink; folded into `bytes` on drain/teardown. */
|
||||
pendingBytes: number
|
||||
/** True while the source is paused because the sink is over its highWaterMark. */
|
||||
sourcePaused: boolean
|
||||
/** One-shot: the injector supplied no pauseSource, so the pause is a no-op. */
|
||||
warnedNoSource: boolean
|
||||
totalBytes: number
|
||||
}
|
||||
|
||||
@@ -176,6 +200,9 @@ function finalize(
|
||||
// mistaken for a clean finish.
|
||||
engine.ending = true
|
||||
cancelTimers(engine)
|
||||
// A pause must never outlive its transfer: the 'drain' that would lift it may
|
||||
// still be queued, or may never come at all once the sink is ending here.
|
||||
releaseSource(engine)
|
||||
const stream = engine.receiveStream
|
||||
engine.receiveStream = null
|
||||
// Clear the slot *before* end(): an errored/destroyed sink must not be ended
|
||||
@@ -235,6 +262,58 @@ function touchActivity(engine: Engine): void {
|
||||
engine.stallTimer = timer
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the ssh stream that feeds this engine (receive path only: the send path
|
||||
* pushes into the sink instead of pulling from the peer).
|
||||
*
|
||||
* Idempotent — every subpacket of a blocked window arrives through the same
|
||||
* on_input, and pausing an already-paused channel is pointless work on the hot
|
||||
* path. The flag is set even when the injector supplied no hook, so the
|
||||
* accounting below stays consistent either way.
|
||||
*/
|
||||
function pauseSource(engine: Engine): void {
|
||||
if (engine.sourcePaused) return
|
||||
engine.sourcePaused = true
|
||||
if (!engine.deps.pauseSource) {
|
||||
// Say it once per transfer instead of silently doing nothing: the hook is
|
||||
// the whole mechanism, so an injector that forgot it still has an unbounded
|
||||
// sink queue (only the progress accounting below improves).
|
||||
if (!engine.warnedNoSource) {
|
||||
engine.warnedNoSource = true
|
||||
console.warn('[zmodem] receive sink is over its highWaterMark but no pauseSource hook was injected')
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
engine.deps.pauseSource(engine.id)
|
||||
} catch {
|
||||
// the stream may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo a backpressure pause, folding the octets that only ever reached the
|
||||
* sink's queue into the received count.
|
||||
*
|
||||
* Called from the sink's 'drain' AND from every teardown path: a pause that
|
||||
* outlived its transfer would leave the session's ssh stream stopped for good
|
||||
* (terminal output and the peer both look frozen, with no way back), and
|
||||
* waiting for a 'drain' that never comes — dead disk, destroyed stream — is
|
||||
* exactly how that happens. Returns whether a pause was actually outstanding.
|
||||
*/
|
||||
function releaseSource(engine: Engine): boolean {
|
||||
if (!engine.sourcePaused) return false
|
||||
engine.sourcePaused = false
|
||||
engine.bytes += engine.pendingBytes
|
||||
engine.pendingBytes = 0
|
||||
try {
|
||||
engine.deps.resumeSource?.(engine.id)
|
||||
} catch {
|
||||
// the stream may already be gone
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function wireSession(engine: Engine): void {
|
||||
const session = engine.session
|
||||
if (!session) return
|
||||
@@ -383,23 +462,40 @@ function handleOffer(engine: Engine, offer: Zmodem.Offer): void {
|
||||
finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.receiveFailed'))
|
||||
})
|
||||
|
||||
// Backpressure release. The sink's queue is empty again, so the octets that
|
||||
// were parked there are really written, the peer may push more, and this is
|
||||
// the ONLY progress signal while blocked — it must therefore also reset the
|
||||
// stall watchdog (a slow but working disk is not a stalled transfer).
|
||||
stream.on('drain', () => {
|
||||
if (engine.receiveStream !== stream) return
|
||||
if (releaseSource(engine)) touchActivity(engine)
|
||||
})
|
||||
|
||||
offer
|
||||
.accept({
|
||||
on_input: (payload: Uint8Array | number[]) => {
|
||||
// zmodem.js delivers the payload as a plain octet Array (not a
|
||||
// Uint8Array); coerce defensively to a Buffer before writing.
|
||||
const buf = Array.isArray(payload) ? Buffer.from(payload) : Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength)
|
||||
if (!stream.destroyed && !stream.closed) {
|
||||
stream.write(buf)
|
||||
}
|
||||
const writable = !stream.destroyed && !stream.closed
|
||||
if (writable && stream.write(buf)) {
|
||||
engine.bytes += buf.byteLength
|
||||
// Not throttled: the stall watchdog measures byte progress, not UI events.
|
||||
touchActivity(engine)
|
||||
} else if (writable) {
|
||||
// write() === false: the sink is over its highWaterMark, so these
|
||||
// octets only sit in Node's queue — for a 100MB file into a slow disk
|
||||
// the whole file would end up there. Pausing the ssh stream makes the
|
||||
// peer wait instead; counting the bytes or touching the watchdog now
|
||||
// would report progress for data that has not reached the disk yet.
|
||||
engine.pendingBytes += buf.byteLength
|
||||
pauseSource(engine)
|
||||
}
|
||||
emitProgressThrottled(engine, {
|
||||
file: name,
|
||||
bytes: engine.bytes,
|
||||
totalBytes: engine.totalBytes
|
||||
})
|
||||
// Not throttled: the stall watchdog measures byte progress, not UI events.
|
||||
touchActivity(engine)
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
@@ -521,6 +617,9 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void {
|
||||
stallTimer: null,
|
||||
receiveStream: null,
|
||||
bytes: 0,
|
||||
pendingBytes: 0,
|
||||
sourcePaused: false,
|
||||
warnedNoSource: false,
|
||||
totalBytes: 0
|
||||
}
|
||||
engines.set(sessionId, engine)
|
||||
@@ -683,6 +782,10 @@ export function detachZmodem(sessionId: string): void {
|
||||
if (engine.active) {
|
||||
finalize(engine, false, t('main.zmodem.sessionClosed'), 'cancelled')
|
||||
}
|
||||
// The engine is about to be dropped: a pause still outstanding here would
|
||||
// never be lifted (finalize already released the active one; this covers the
|
||||
// path where it was not active).
|
||||
releaseSource(engine)
|
||||
engine.active = false
|
||||
cancelTimers(engine)
|
||||
const stream = engine.receiveStream
|
||||
|
||||
@@ -19,6 +19,7 @@ const api: AppApi = {
|
||||
createPty: (opts?: PtyCreateOptions) => ipcRenderer.invoke(Ipc.PTY_CREATE, opts),
|
||||
openSession: (opts: SessionOpenOptions) => ipcRenderer.invoke(Ipc.SESSION_OPEN, opts),
|
||||
getSessionReplay: (id: string) => ipcRenderer.invoke(Ipc.SESSION_REPLAY, id),
|
||||
getSessionLiveState: (id: string) => ipcRenderer.invoke(Ipc.SESSION_STATE, id),
|
||||
writePty: (id: string, data: string) => ipcRenderer.send(Ipc.PTY_WRITE, id, data),
|
||||
resizePty: (id: string, cols: number, rows: number) =>
|
||||
ipcRenderer.send(Ipc.PTY_RESIZE, id, cols, rows),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Modal, Popconfirm, Tabs } from 'antd'
|
||||
import { App, Button, Modal, Popconfirm, Tabs } from 'antd'
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import type { CommandItem } from '@shared/commands'
|
||||
import { t } from '@shared/i18n'
|
||||
@@ -24,6 +24,7 @@ export function formatClock(ts: number): string {
|
||||
* runs the command via the parent workspace's `onRun`.
|
||||
*/
|
||||
export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element {
|
||||
const { message } = App.useApp()
|
||||
const [history, setHistory] = useState<CommandItem[]>([])
|
||||
const [library, setLibrary] = useState<CommandItem[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
@@ -54,16 +55,27 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
|
||||
}, [refresh])
|
||||
|
||||
const handleClearHistory = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await window.api.clearHistory()
|
||||
} catch (err) {
|
||||
// Nothing was removed, so a refresh would repaint the same rows.
|
||||
message.error((err as Error)?.message || t('common.saveFailed'))
|
||||
return
|
||||
}
|
||||
refresh()
|
||||
}, [refresh])
|
||||
}, [refresh, message])
|
||||
|
||||
const handleDeleteLibrary = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
try {
|
||||
await window.api.deleteLibraryItem(id)
|
||||
} catch (err) {
|
||||
message.error((err as Error)?.message || t('common.saveFailed'))
|
||||
return
|
||||
}
|
||||
refresh()
|
||||
},
|
||||
[refresh]
|
||||
[refresh, message]
|
||||
)
|
||||
|
||||
const handleAdd = useCallback((): void => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'
|
||||
import { Button, Collapse, Popconfirm, Tooltip } from 'antd'
|
||||
import { App, Button, Collapse, Popconfirm, Tooltip } from 'antd'
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import type { SshConnection } from '@shared/connections'
|
||||
import { t } from '@shared/i18n'
|
||||
@@ -61,6 +61,7 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
|
||||
}: ConnectionSidebarProps,
|
||||
ref
|
||||
): React.JSX.Element {
|
||||
const { message } = App.useApp()
|
||||
const mode = useWorkspaceModeStore((s) => s.mode)
|
||||
const isTerminal = mode === 'terminal'
|
||||
const [connections, setConnections] = useState<SshConnection[]>([])
|
||||
@@ -117,7 +118,14 @@ export const ConnectionSidebar = forwardRef<ConnectionSidebarHandle, ConnectionS
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string): Promise<void> => {
|
||||
try {
|
||||
await window.api.deleteConnection(id)
|
||||
} catch (err) {
|
||||
// The row is still there, so a refresh would just re-render the same
|
||||
// list — report the failure and leave it alone.
|
||||
message.error((err as Error)?.message || t('common.saveFailed'))
|
||||
return
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ if (typeof window !== 'undefined' && !window.api) {
|
||||
createPty: async () => ({ id: stubId(), shell: 'stub', cwd: '' }),
|
||||
openSession: async () => ({ id: stubId() }),
|
||||
getSessionReplay: async () => '',
|
||||
getSessionLiveState: async () => ({ exists: false, exited: false, exitCode: null }),
|
||||
writePty: noop,
|
||||
resizePty: noop,
|
||||
killPty: noop,
|
||||
|
||||
@@ -182,6 +182,16 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
|
||||
}, [sessionId, workspaceMode])
|
||||
|
||||
if (!meta || !sample) {
|
||||
// `meta` is only broadcast after a first sample succeeds, so a session that
|
||||
// fails from the start would otherwise spin here forever with its error
|
||||
// sample hidden behind the meta gate.
|
||||
if (sample?.error) {
|
||||
return (
|
||||
<div className="mm-root mm-empty mm-failed">
|
||||
<div className="mm-error">{t('ssh.monitor.interrupted', { error: sample.error })}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="mm-root mm-empty">
|
||||
<span className="mm-dots"><span /> <span /> <span /></span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Alert, Button, Input, Modal, Popconfirm, Radio, Space } from 'antd'
|
||||
import { Alert, App, Button, Input, Modal, Popconfirm, Radio, Space } from 'antd'
|
||||
import { t } from '@shared/i18n'
|
||||
import { exportHighlightRules, mergeRules, parseHighlightRules } from '@shared/highlightIO'
|
||||
import type { ImportError } from '@shared/highlightIO'
|
||||
@@ -24,6 +24,7 @@ export function HighlightImportExport({
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
const { message } = App.useApp()
|
||||
const highlightRules = useSettingsStore((s) => s.settings.highlightRules)
|
||||
const setHighlightRules = useSettingsStore((s) => s.setHighlightRules)
|
||||
|
||||
@@ -59,18 +60,22 @@ export function HighlightImportExport({
|
||||
link.href = url
|
||||
link.download = 'openterminal-highlight-rules.json'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
// Chromium reads the blob asynchronously after the click, so revoking the
|
||||
// URL synchronously aborts the download before it can start.
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
|
||||
const handleFile = (file: File): void => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => setDraft(typeof reader.result === 'string' ? reader.result : '')
|
||||
reader.onerror = () => message.error(t('settings.highlight.importReadFailed'))
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const errorText = (error: ImportError): string => {
|
||||
if (error === 'not-json') return t('settings.highlight.importBadJson')
|
||||
if (error === 'wrong-kind') return t('settings.highlight.importWrongKind')
|
||||
if (error === 'wrong-version') return t('settings.highlight.importWrongVersion')
|
||||
return t('settings.highlight.importEmpty')
|
||||
}
|
||||
|
||||
|
||||
@@ -549,7 +549,10 @@ function HighlightEditor({
|
||||
...(draft.basic ? { basic: true as const } : {}),
|
||||
note: draft.note?.trim() ? draft.note.trim() : undefined
|
||||
}
|
||||
await setHighlightRules([...highlightRules, rule])
|
||||
// read at call time: the render-scoped array goes stale inside a React
|
||||
// batch, and another write in the same batch would silently drop the first
|
||||
const current = useSettingsStore.getState().settings.highlightRules
|
||||
await setHighlightRules([...current, rule])
|
||||
} else if (editing !== undefined) {
|
||||
const rule: HighlightRule = {
|
||||
...editing,
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
FormOutlined,
|
||||
ReloadOutlined,
|
||||
SafetyOutlined,
|
||||
UploadOutlined
|
||||
UploadOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { App, Button, Checkbox, Dropdown, Input, Modal, Popconfirm, Tooltip } from 'antd'
|
||||
import type { MenuProps } from 'antd'
|
||||
@@ -93,16 +95,34 @@ function tOr(key: string, fallback: string): string {
|
||||
return value === key ? fallback : value
|
||||
}
|
||||
|
||||
/** FinalShell-style size: a unit at every magnitude, one decimal from KB up. */
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}G`
|
||||
if (bytes >= 1024) return `${Math.round(bytes / 1024)}K`
|
||||
return String(bytes)
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
/** e.g. "2023/8/10 17:24" — a full year, unpadded month/day, padded clock. */
|
||||
function fmtTime(ms: number): string {
|
||||
if (!ms) return ''
|
||||
const d = new Date(ms)
|
||||
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner column: names parsed from the OpenSSH longname when available, numeric
|
||||
* uid/gid otherwise. The numeric form is what the permission dialog edits, so
|
||||
* it stays reachable through the cell's title tooltip.
|
||||
*/
|
||||
function ownerLabel(e: SftpEntry): string {
|
||||
if (e.owner && e.group) return `${e.owner}/${e.group}`
|
||||
if (e.owner) return e.owner
|
||||
if (e.group) return `/${e.group}`
|
||||
if (e.uid != null && e.gid != null) return `${e.uid}/${e.gid}`
|
||||
if (e.uid != null) return String(e.uid)
|
||||
if (e.gid != null) return String(e.gid)
|
||||
return '—'
|
||||
}
|
||||
|
||||
/** Permission-grid labels are resolved per render so they follow the language. */
|
||||
@@ -114,8 +134,12 @@ function permColLabels(): string[] {
|
||||
return [t('ssh.file.permColRead'), t('ssh.file.permColWrite'), t('ssh.file.permColExec')]
|
||||
}
|
||||
|
||||
/** Row height in px — must match `.sftp-row` in sftp.css. */
|
||||
const ROW_HEIGHT = 24
|
||||
/**
|
||||
* Row height at scale 1, in px — the single source of truth for the list's
|
||||
* geometry. The actual height is `BASE_ROW_HEIGHT * scale` and reaches CSS as
|
||||
* the `--sftp-row-h` variable, so `.sftp-row` must never hard-code a height.
|
||||
*/
|
||||
const BASE_ROW_HEIGHT = 26
|
||||
/** Rows rendered above and below the viewport so a fast scroll never blanks. */
|
||||
const OVERSCAN = 8
|
||||
/**
|
||||
@@ -126,15 +150,44 @@ const OVERSCAN = 8
|
||||
*/
|
||||
const VIRTUALIZE_THRESHOLD = 200
|
||||
|
||||
const FONT_SCALE_KEY = 'openterminal.sftpFontScale'
|
||||
const MIN_FONT_SCALE = 0.8
|
||||
const MAX_FONT_SCALE = 1.8
|
||||
const FONT_SCALE_STEP = 0.1
|
||||
|
||||
/** Clamped scale with a tolerance for float drift across 0.1 steps. */
|
||||
function normalizeScale(value: number): number {
|
||||
const clamped = Math.min(MAX_FONT_SCALE, Math.max(MIN_FONT_SCALE, value))
|
||||
// 0.8 + 0.1 * 3 = 1.1000000000000001, so round to the step's precision.
|
||||
return Math.round(clamped * 10) / 10
|
||||
}
|
||||
|
||||
/** Persisted zoom level; anything unreadable or out of range falls back to 1. */
|
||||
function readStoredScale(): number {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(FONT_SCALE_KEY)
|
||||
if (raw == null) return 1
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed) || parsed < MIN_FONT_SCALE || parsed > MAX_FONT_SCALE) return 1
|
||||
return normalizeScale(parsed)
|
||||
} catch {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
/** Sortable columns. Directories always lead, whatever the key. */
|
||||
type SortKey = 'name' | 'size' | 'mtime'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
/** Slice of the entry list to mount for the current scroll position. */
|
||||
interface WindowSlice {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/** First index to render for a given scroll offset and viewport height. */
|
||||
function windowStart(scrollTop: number): number {
|
||||
return Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN)
|
||||
/** First index to render for a given scroll offset, row height and viewport. */
|
||||
function windowStart(scrollTop: number, rowHeight: number): number {
|
||||
return Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN)
|
||||
}
|
||||
|
||||
interface PermissionModalProps {
|
||||
@@ -357,14 +410,59 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
const [permOpen, setPermOpen] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
/** Sort column + direction; directories always lead regardless of the key. */
|
||||
const [sortKey, setSortKey] = useState<SortKey>('name')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
/** Font zoom, persisted per user rather than per session. */
|
||||
const [scale, setScale] = useState(readStoredScale)
|
||||
|
||||
const rowHeight = Math.round(BASE_ROW_HEIGHT * scale)
|
||||
const scaleVars = {
|
||||
'--sftp-scale': scale,
|
||||
'--sftp-row-h': `${rowHeight}px`,
|
||||
'--sftp-fs': `${13 * scale}px`,
|
||||
'--sftp-fs-meta': `${12 * scale}px`
|
||||
} as React.CSSProperties
|
||||
|
||||
const entriesSorted = useMemo((): SftpEntry[] => {
|
||||
const mul = sortDir === 'asc' ? 1 : -1
|
||||
return entries.slice().sort((a, b) => {
|
||||
// Grouping beats the chosen key: directories always lead.
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
||||
if (sortKey === 'size') return (a.size - b.size) * mul
|
||||
if (sortKey === 'mtime') return (a.mtime - b.mtime) * mul
|
||||
return a.name.localeCompare(b.name) * mul
|
||||
})
|
||||
}, [entries, sortKey, sortDir])
|
||||
|
||||
const toggleSort = (key: SortKey): void => {
|
||||
if (key === sortKey) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'))
|
||||
return
|
||||
}
|
||||
setSortKey(key)
|
||||
setSortDir('asc')
|
||||
}
|
||||
|
||||
const adjustScale = useCallback((delta: number): void => {
|
||||
setScale((prev) => {
|
||||
const next = normalizeScale(prev + delta)
|
||||
try {
|
||||
window.localStorage.setItem(FONT_SCALE_KEY, String(next))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Windowed list state. `viewportH` is measured off the scroll container and
|
||||
// `firstVisible` only advances when the scroll offset crosses a row boundary,
|
||||
// so dragging the scrollbar re-renders at most once per ROW_HEIGHT of travel.
|
||||
// so dragging the scrollbar re-renders at most once per rowHeight of travel.
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const [viewportH, setViewportH] = useState(0)
|
||||
const [firstVisible, setFirstVisible] = useState(0)
|
||||
const virtualized = entries.length > VIRTUALIZE_THRESHOLD
|
||||
const virtualized = entriesSorted.length > VIRTUALIZE_THRESHOLD
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current
|
||||
@@ -378,6 +476,22 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
|
||||
// observer has to be re-attached whenever the container is replaced.
|
||||
}, [error])
|
||||
|
||||
// React's onWheel is registered passively, so preventDefault() there is a
|
||||
// no-op and Ctrl+wheel would zoom the whole window instead. An explicit
|
||||
// non-passive listener is the only way to swallow it.
|
||||
useEffect(() => {
|
||||
const el = listRef.current
|
||||
if (!el) return
|
||||
const onWheel = (e: WheelEvent): void => {
|
||||
if (!e.ctrlKey) return
|
||||
e.preventDefault()
|
||||
adjustScale(e.deltaY < 0 ? FONT_SCALE_STEP : -FONT_SCALE_STEP)
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
// `error` swaps the scroll container, so the listener has to move with it.
|
||||
}, [error, adjustScale])
|
||||
|
||||
// A new directory starts at the top; the stale scroll offset would otherwise
|
||||
// leave the window slice pointing past the (now shorter) listing.
|
||||
useEffect(() => {
|
||||
@@ -386,25 +500,53 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
|
||||
setFirstVisible(0)
|
||||
}, [dir])
|
||||
|
||||
// Zooming changes the row height, so the offset that used to be row N now
|
||||
// points at a different row — realign the window with what the viewport shows.
|
||||
useEffect(() => {
|
||||
const el = listRef.current
|
||||
if (!el) return
|
||||
setFirstVisible(windowStart(el.scrollTop, rowHeight))
|
||||
}, [rowHeight])
|
||||
|
||||
const onListScroll = useCallback(() => {
|
||||
const el = listRef.current
|
||||
if (!el) return
|
||||
const start = windowStart(el.scrollTop)
|
||||
const start = windowStart(el.scrollTop, rowHeight)
|
||||
setFirstVisible((prev) => (prev === start ? prev : start))
|
||||
}, [])
|
||||
}, [rowHeight])
|
||||
|
||||
const slice = useMemo((): WindowSlice => {
|
||||
const visible = Math.ceil(viewportH / ROW_HEIGHT) + OVERSCAN * 2
|
||||
return { start: firstVisible, end: Math.min(entries.length, firstVisible + visible) }
|
||||
}, [viewportH, firstVisible, entries.length])
|
||||
const visible = Math.ceil(viewportH / rowHeight) + OVERSCAN * 2
|
||||
return { start: firstVisible, end: Math.min(entriesSorted.length, firstVisible + visible) }
|
||||
}, [viewportH, firstVisible, rowHeight, entriesSorted.length])
|
||||
|
||||
/** the entry the context menu is operating on (selected when right-clicked) */
|
||||
const menuTarget = menuEntry ?? selected
|
||||
|
||||
/**
|
||||
* The rows carry their own context-menu Dropdown, so a right click on one
|
||||
* bubbles up to the container's trigger too and both menus would open. antd
|
||||
* installs its trigger in the bubble phase, so the flag is recorded in the
|
||||
* capture phase (which React dispatches first) and read by `onOpenChange`.
|
||||
*/
|
||||
const contextMenuInRowRef = useRef(false)
|
||||
const [blankMenuOpen, setBlankMenuOpen] = useState(false)
|
||||
|
||||
/** Monotonic token: only the most recently issued listing may paint state. */
|
||||
const refreshSeqRef = useRef(0)
|
||||
/** Mirrors `dir` for callbacks created before a navigation (upload finish). */
|
||||
const dirRef = useRef(dir)
|
||||
/** Unsubscribe for the transfer listener the in-flight upload registered. */
|
||||
const uploadOffRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// An upload can still be running — or never deliver a terminal event —
|
||||
// when the panel unmounts; without this its IPC listener outlives it.
|
||||
uploadOffRef.current?.()
|
||||
uploadOffRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(
|
||||
async (target: string): Promise<void> => {
|
||||
@@ -471,6 +613,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
|
||||
}
|
||||
if (e.transferId === targetId) finish()
|
||||
})
|
||||
uploadOffRef.current = off
|
||||
window.api.uploadRemote(sessionId, files, dir).then(
|
||||
(id: string) => {
|
||||
targetId = id
|
||||
@@ -693,7 +836,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [menuTarget, dir, entries, refresh, language])
|
||||
|
||||
/** One directory entry: icon, name, permissions, owner, size, mtime + menu. */
|
||||
/** One directory entry: icon+name, size, mtime, permissions, owner + menu. */
|
||||
const renderRow = (e: SftpEntry): React.JSX.Element => (
|
||||
<Dropdown
|
||||
key={e.path}
|
||||
@@ -717,22 +860,33 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
|
||||
<span className="sftp-name" title={e.name}>
|
||||
{e.name}
|
||||
</span>
|
||||
<span className="sftp-mode" title={e.mode ?? t('ssh.file.modeUnavailable')}>
|
||||
{e.mode ?? '----------'}
|
||||
</span>
|
||||
<span className="sftp-owner" title={`UID ${e.uid ?? '—'} / GID ${e.gid ?? '—'}`}>
|
||||
{e.uid != null && e.gid != null ? `${e.uid}/${e.gid}` : '—'}
|
||||
</span>
|
||||
<span className="sftp-size" title={e.isDir ? '' : `${e.size} bytes`}>
|
||||
{e.isDir ? '' : fmtSize(e.size)}
|
||||
</span>
|
||||
<span className="sftp-time">{fmtTime(e.mtime)}</span>
|
||||
<span className="sftp-mode" title={e.mode ?? t('ssh.file.modeUnavailable')}>
|
||||
{e.mode ?? '----------'}
|
||||
</span>
|
||||
<span className="sftp-owner" title={`UID ${e.uid ?? '—'} / GID ${e.gid ?? '—'}`}>
|
||||
{ownerLabel(e)}
|
||||
</span>
|
||||
</div>
|
||||
</Dropdown>
|
||||
)
|
||||
|
||||
/** Sortable header cell; the arrow only shows on the active column. */
|
||||
const headCell = (key: SortKey, colClass: string, label: string): React.JSX.Element => (
|
||||
<span
|
||||
className={`sftp-head-cell ${colClass}${sortKey === key ? ' sftp-head-on' : ''}`}
|
||||
onClick={() => toggleSort(key)}
|
||||
>
|
||||
{label}
|
||||
<span className="sftp-head-arrow">{sortKey === key ? (sortDir === 'asc' ? '▲' : '▼') : ''}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="sftp-panel">
|
||||
<div className="sftp-panel" style={scaleVars}>
|
||||
<div className="sftp-toolbar">
|
||||
<Tooltip title={t('ssh.file.up')}>
|
||||
<Button
|
||||
@@ -798,6 +952,25 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
|
||||
>
|
||||
<Button type="text" size="small" icon={<DeleteOutlined />} disabled={!selected} danger />
|
||||
</Popconfirm>
|
||||
<span className="sftp-toolbar-gap" />
|
||||
<Tooltip title={t('ssh.file.zoomOut')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ZoomOutOutlined />}
|
||||
disabled={scale <= MIN_FONT_SCALE}
|
||||
onClick={() => adjustScale(-FONT_SCALE_STEP)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('ssh.file.zoomIn')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ZoomInOutlined />}
|
||||
disabled={scale >= MAX_FONT_SCALE}
|
||||
onClick={() => adjustScale(FONT_SCALE_STEP)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="sftp-breadcrumb">
|
||||
@@ -814,22 +987,67 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
|
||||
{error ? (
|
||||
<div className="sftp-error">{error}</div>
|
||||
) : (
|
||||
<div className="sftp-list" ref={listRef} onScroll={onListScroll}>
|
||||
{loading && entries.length === 0 && <div className="sftp-empty">{t('ssh.file.loading')}</div>}
|
||||
{!loading && entries.length === 0 && <div className="sftp-empty">{t('ssh.file.empty')}</div>}
|
||||
<Dropdown
|
||||
menu={menu}
|
||||
trigger={['contextMenu']}
|
||||
getPopupContainer={() => document.body}
|
||||
open={blankMenuOpen}
|
||||
onOpenChange={(open) => {
|
||||
// The rows carry their own Dropdown; a right click on one bubbles up
|
||||
// here too and would open both menus. The row's handler flags the
|
||||
// click (in the capture phase, which runs first), so this trigger
|
||||
// refuses to open for it.
|
||||
if (open && contextMenuInRowRef.current) return
|
||||
setBlankMenuOpen(open)
|
||||
if (!open) setMenuEntry(null)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="sftp-list"
|
||||
ref={listRef}
|
||||
onScroll={onListScroll}
|
||||
onContextMenuCapture={(e) => {
|
||||
const inRow = (e.target as HTMLElement).closest('.sftp-row') != null
|
||||
contextMenuInRowRef.current = inRow
|
||||
if (inRow) {
|
||||
// A row click must not leave a blank-area menu behind; the row
|
||||
// opens its own. (Re-opening is refused above, so this only ever
|
||||
// closes.)
|
||||
setBlankMenuOpen(false)
|
||||
return
|
||||
}
|
||||
// Blank space (or the empty/loading hint): the menu keeps only the
|
||||
// target-less entries, every row action disables itself.
|
||||
setSelected(null)
|
||||
setMenuEntry(null)
|
||||
}}
|
||||
>
|
||||
<div className="sftp-head">
|
||||
<span className="sftp-head-cell" />
|
||||
{headCell('name', 'sftp-col-name', t('ssh.file.colName'))}
|
||||
{headCell('size', 'sftp-col-size', t('ssh.file.colSize'))}
|
||||
{headCell('mtime', 'sftp-col-time', t('ssh.file.colModified'))}
|
||||
<span className="sftp-head-cell sftp-col-mode">{t('ssh.file.colPerm')}</span>
|
||||
<span className="sftp-head-cell sftp-col-owner">{t('ssh.file.colOwner')}</span>
|
||||
</div>
|
||||
{loading && entriesSorted.length === 0 && (
|
||||
<div className="sftp-empty">{t('ssh.file.loading')}</div>
|
||||
)}
|
||||
{!loading && entriesSorted.length === 0 && <div className="sftp-empty">{t('ssh.file.empty')}</div>}
|
||||
{virtualized ? (
|
||||
<div className="sftp-list-virtual" style={{ height: entries.length * ROW_HEIGHT }}>
|
||||
<div className="sftp-list-virtual" style={{ height: entriesSorted.length * rowHeight }}>
|
||||
<div
|
||||
className="sftp-list-window"
|
||||
style={{ transform: `translateY(${slice.start * ROW_HEIGHT}px)` }}
|
||||
style={{ transform: `translateY(${slice.start * rowHeight}px)` }}
|
||||
>
|
||||
{entries.slice(slice.start, slice.end).map(renderRow)}
|
||||
{entriesSorted.slice(slice.start, slice.end).map(renderRow)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
entries.map(renderRow)
|
||||
entriesSorted.map(renderRow)
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
<PermissionModal
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
|
||||
/* Pushes the zoom controls to the far end of the toolbar. */
|
||||
.sftp-toolbar-gap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sftp-breadcrumb {
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
@@ -42,6 +47,79 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ---- header + rows ----------------------------------------------------------
|
||||
|
||||
The header and every row share one grid template, so the columns always line
|
||||
up. Column widths and the header height scale with --sftp-scale; the row
|
||||
height and font sizes come from FilePanel.tsx (--sftp-row-h / --sftp-fs /
|
||||
--sftp-fs-meta). Every px below is therefore a scale-1 baseline only. */
|
||||
|
||||
.sftp-head,
|
||||
.sftp-row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
calc(22px * var(--sftp-scale, 1)) minmax(0, 1fr) calc(78px * var(--sftp-scale, 1))
|
||||
calc(104px * var(--sftp-scale, 1)) calc(88px * var(--sftp-scale, 1))
|
||||
calc(84px * var(--sftp-scale, 1));
|
||||
align-items: center;
|
||||
column-gap: calc(8px * var(--sftp-scale, 1));
|
||||
padding-inline: calc(8px * var(--sftp-scale, 1));
|
||||
}
|
||||
|
||||
.sftp-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
/* Above the rows, including the absolutely positioned windowed slice. */
|
||||
z-index: 2;
|
||||
height: var(--sftp-row-h, 26px);
|
||||
box-sizing: border-box;
|
||||
background: var(--chrome-bg);
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
font-size: var(--sftp-fs-meta, 12px);
|
||||
color: color-mix(in srgb, var(--chrome-fg, #cccccc) 55%, transparent);
|
||||
/* Header labels must never end up in a text selection with the listing. */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sftp-head-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
font-size: var(--sftp-fs-meta, 12px);
|
||||
}
|
||||
|
||||
.sftp-head-cell.sftp-col-mode {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sftp-head-cell.sftp-col-size,
|
||||
.sftp-head-cell.sftp-col-time,
|
||||
.sftp-head-cell.sftp-col-owner {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sftp-head-cell.sftp-col-name,
|
||||
.sftp-head-cell.sftp-col-size,
|
||||
.sftp-head-cell.sftp-col-time {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sftp-head-cell.sftp-col-name:hover,
|
||||
.sftp-head-cell.sftp-col-size:hover,
|
||||
.sftp-head-cell.sftp-col-time:hover,
|
||||
.sftp-head-cell.sftp-head-on {
|
||||
color: var(--chrome-fg);
|
||||
}
|
||||
|
||||
.sftp-head-arrow {
|
||||
flex: 0 0 auto;
|
||||
font-size: calc(9px * var(--sftp-scale, 1));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Windowed list (node_modules-sized directories). Only the visible slice of
|
||||
rows plus the overscan is mounted; the spacer carries the full scroll height. */
|
||||
.sftp-list-virtual {
|
||||
@@ -57,25 +135,14 @@
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Right-click menu wrapper: antd wraps each row in a span by default;
|
||||
force block so rows keep full-width hover/selection and normal flow. */
|
||||
.sftp-list .ant-dropdown-trigger {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sftp-row {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr) 96px 74px 66px 96px;
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
padding: 3px 8px;
|
||||
/* Fixed outer height so the windowed list can offset rows arithmetically.
|
||||
border-box is required — there is no global reset, so content-box would
|
||||
add the 6px of vertical padding on top. Keep in sync with ROW_HEIGHT in
|
||||
FilePanel.tsx. */
|
||||
add any padding on top of the height. The height itself is driven by
|
||||
--sftp-row-h so row geometry has one source of truth (FilePanel.tsx). */
|
||||
box-sizing: border-box;
|
||||
height: 24px;
|
||||
font-size: 13px;
|
||||
height: var(--sftp-row-h, 26px);
|
||||
font-size: var(--sftp-fs, 13px);
|
||||
color: var(--chrome-fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -91,7 +158,7 @@
|
||||
.sftp-icon {
|
||||
display: inline-flex;
|
||||
color: #8a8a8a;
|
||||
font-size: 14px;
|
||||
font-size: calc(14px * var(--sftp-scale, 1));
|
||||
}
|
||||
|
||||
.sftp-icon-dir {
|
||||
@@ -103,14 +170,14 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13.5px;
|
||||
font-size: var(--sftp-fs, 13px);
|
||||
}
|
||||
|
||||
.sftp-mode {
|
||||
text-align: center;
|
||||
color: #9a9a9a;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
font-size: var(--sftp-fs-meta, 12px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -118,7 +185,7 @@
|
||||
.sftp-owner {
|
||||
text-align: right;
|
||||
color: #8a8a8a;
|
||||
font-size: 11px;
|
||||
font-size: var(--sftp-fs-meta, 12px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -126,7 +193,7 @@
|
||||
.sftp-size {
|
||||
text-align: right;
|
||||
color: #8a8a8a;
|
||||
font-size: 11px;
|
||||
font-size: var(--sftp-fs-meta, 12px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -134,7 +201,7 @@
|
||||
.sftp-time {
|
||||
text-align: right;
|
||||
color: #8a8a8a;
|
||||
font-size: 11px;
|
||||
font-size: var(--sftp-fs-meta, 12px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1042,6 +1042,13 @@ export function TerminalView({
|
||||
// Live data is queued until the replay lands so output keeps its order.
|
||||
let replayDone = false
|
||||
const pending: string[] = []
|
||||
// Death state, shared by the PTY_EXIT subscription and the SESSION_STATE
|
||||
// query below: the two paths must land identically.
|
||||
const markDead = (code: number): void => {
|
||||
deadRef.current = true
|
||||
setExitCode(code)
|
||||
setDead(true)
|
||||
}
|
||||
const unsubscribes: (() => void)[] = [
|
||||
// Routed via the shared dispatcher (one IPC listener for all panes)
|
||||
// instead of a per-pane global listener.
|
||||
@@ -1053,11 +1060,7 @@ export function TerminalView({
|
||||
}
|
||||
writeHighlighted(data)
|
||||
}),
|
||||
subscribePtyExit(sessionId, (code: number) => {
|
||||
deadRef.current = true
|
||||
setExitCode(code)
|
||||
setDead(true)
|
||||
})
|
||||
subscribePtyExit(sessionId, markDead)
|
||||
]
|
||||
// Late-subscriber catch-up: output emitted before this subscription
|
||||
// (shell banner, template-apply rebind) replays from the main buffer.
|
||||
@@ -1073,6 +1076,21 @@ export function TerminalView({
|
||||
pending.length = 0
|
||||
})
|
||||
|
||||
// PTY_EXIT is broadcast once and never replayed, so a shell that died in
|
||||
// the gap between openSession and this subscription (or before the pane was
|
||||
// rebound to it) would leave the pane blank forever. Ask once, now that the
|
||||
// subscription can no longer miss it. `disposed` rather than deadRef: the
|
||||
// cleanup below also runs on a rebind, where the pane is alive again under
|
||||
// another session and this answer is stale.
|
||||
let disposed = false
|
||||
void window.api
|
||||
.getSessionLiveState(sessionId)
|
||||
.then((state) => {
|
||||
if (disposed || !state.exited) return
|
||||
markDead(state.exitCode ?? 0)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
let observer: ResizeObserver | undefined
|
||||
if (hostRef.current) {
|
||||
observer = new ResizeObserver(() => scheduleFit())
|
||||
@@ -1114,6 +1132,9 @@ export function TerminalView({
|
||||
for (const disposable of disposables) disposable.dispose()
|
||||
observer?.disconnect()
|
||||
observerRef.current = null
|
||||
// The pane is unmounted or rebinding to another session: an in-flight
|
||||
// SESSION_STATE answer must not mark the next session dead.
|
||||
disposed = true
|
||||
deadRef.current = true
|
||||
webglRef.current?.dispose()
|
||||
webglRef.current = null
|
||||
|
||||
@@ -99,6 +99,22 @@ const DOCKVIEW_TAB_COMPONENTS = {
|
||||
)
|
||||
}
|
||||
|
||||
/** A queued host-key prompt plus the time it was queued — see the TTL sweep. */
|
||||
interface QueuedHostKeyPrompt extends HostKeyPromptEvent {
|
||||
enqueuedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a queued host-key prompt is worth answering. Mirrors the main
|
||||
* process' `DEFAULT_TIMEOUTS.prompt` (src/main/ssh.ts): once that elapses the
|
||||
* prompt has already been resolved as a reject and dropped there, so a
|
||||
* decision of ours can no longer reach it.
|
||||
*/
|
||||
const HOST_KEY_PROMPT_TTL_MS = 120_000
|
||||
|
||||
/** Expired-prompt sweep interval (rendering draws the queue head only). */
|
||||
const HOST_KEY_SWEEP_MS = 5_000
|
||||
|
||||
/**
|
||||
* Electron re-throws a main-process rejection as
|
||||
* `Error: Error invoking remote method 'x': Error: <real message>`, so the real
|
||||
@@ -333,8 +349,12 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
/** Re-entrancy guard for `connect` — a second attempt while one is in flight
|
||||
* is dropped (openSession cannot be aborted and would orphan a session). */
|
||||
const connectInFlightRef = useRef(0)
|
||||
/** Re-entrancy guard for `handleApplyTemplate` — an overlapping second apply
|
||||
* would collect the sessions the first one just restored as its own
|
||||
* "previous" set and kill them. */
|
||||
const applyTemplateInFlightRef = useRef(false)
|
||||
/** FIFO of pending host-key confirmations — render the head only. */
|
||||
const [hostKeyQueue, setHostKeyQueue] = useState<HostKeyPromptEvent[]>([])
|
||||
const [hostKeyQueue, setHostKeyQueue] = useState<QueuedHostKeyPrompt[]>([])
|
||||
|
||||
/** B's sidebar handle; refresh() after a successful SSH connect */
|
||||
const sidebarRef = useRef<ConnectionSidebarHandle>(null)
|
||||
@@ -647,21 +667,39 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
*/
|
||||
useEffect(() => {
|
||||
return window.api.onHostKeyPrompt((event: HostKeyPromptEvent) => {
|
||||
setHostKeyQueue((prev) => [...prev, event])
|
||||
setHostKeyQueue((prev) => [...prev, { ...event, enqueuedAt: Date.now() }])
|
||||
})
|
||||
}, [])
|
||||
|
||||
// A prompt the main process gave up on never gets a decision from the user:
|
||||
// its own timeout resolves the attempt as a reject and stops tracking the
|
||||
// prompt, but nothing tells the renderer. Since only the head is rendered,
|
||||
// such an entry would hold the single visible slot forever and block every
|
||||
// later host's prompt behind it, so sweep the expired ones out.
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const now = Date.now()
|
||||
setHostKeyQueue((prev) => {
|
||||
const next = prev.filter((p) => now - p.enqueuedAt < HOST_KEY_PROMPT_TTL_MS)
|
||||
return next.length === prev.length ? prev : next
|
||||
})
|
||||
}, HOST_KEY_SWEEP_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const hostKeyHead = hostKeyQueue.length > 0 ? hostKeyQueue[0] : null
|
||||
|
||||
const handleHostKeyDecision = useCallback((action: 'accept' | 'reject'): void => {
|
||||
// The queue head's decision is final (accept/reject both consume the prompt).
|
||||
setHostKeyQueue((prev) => {
|
||||
const head = prev[0]
|
||||
if (!head) return prev
|
||||
window.api.respondHostKey(head.promptId, action)
|
||||
return prev.slice(1)
|
||||
})
|
||||
}, [])
|
||||
const handleHostKeyDecision = useCallback(
|
||||
(promptId: string, action: 'accept' | 'reject'): void => {
|
||||
// The head's decision is final (accept/reject both consume the prompt).
|
||||
// The IPC call belongs here, not inside the updater: React may re-run an
|
||||
// updater (it does so deliberately to surface impure ones), which would
|
||||
// answer the prompt — and log it as unanswered — more than once.
|
||||
window.api.respondHostKey(promptId, action)
|
||||
setHostKeyQueue((prev) => prev.filter((p) => p.promptId !== promptId))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* M6: Ctrl+PgUp / Ctrl+PgDn cycle tabs. Registered on `window` with
|
||||
@@ -1032,6 +1070,12 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
|
||||
const handleApplyTemplate = useCallback(
|
||||
async (meta: LayoutMetaLike): Promise<void> => {
|
||||
// Overlapping applies corrupt each other: the second call would collect
|
||||
// the sessions the first one just restored into `previousSessions` and
|
||||
// kill them on the way out. Drop the second call instead.
|
||||
if (applyTemplateInFlightRef.current) return
|
||||
applyTemplateInFlightRef.current = true
|
||||
try {
|
||||
const raw = await window.api.getLayout(meta.id)
|
||||
if (raw == null) return
|
||||
|
||||
@@ -1122,21 +1166,38 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
return
|
||||
}
|
||||
|
||||
// Fresh sessions for every restored terminal panel in both workspaces.
|
||||
// Fresh sessions for every restored terminal panel. Only a dockview the
|
||||
// payload actually reached may be rebound: `rebindRestoredPanels`
|
||||
// rewrites each panel's params to a fresh *local* session, so running it
|
||||
// over a legacy template's untouched ssh workspace would demote its live
|
||||
// SSH panes to local terminals.
|
||||
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
|
||||
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
|
||||
if (sshTouched && sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
|
||||
|
||||
// A template carries the tab titles it was saved with, which may be in
|
||||
// another language (or written by a build whose language the user has
|
||||
// since switched away from).
|
||||
retitleAutoPanels()
|
||||
|
||||
// Old sessions are unreachable after the layout swap — kill them.
|
||||
for (const sid of previousSessions) killSession(sid)
|
||||
// Old sessions are unreachable after the layout swap — kill them, but
|
||||
// only the ones no panel shows any more: a legacy template never reached
|
||||
// the ssh dockview, whose panes keep their live SSH sessions (the same
|
||||
// `live` test the failure path uses).
|
||||
const live = new Set<string>()
|
||||
for (const api of [terminalApiRef.current, sshApiRef.current]) {
|
||||
for (const panel of api?.panels ?? []) {
|
||||
const sid = sessionIdOf(panel)
|
||||
if (sid) live.add(sid)
|
||||
}
|
||||
}
|
||||
for (const sid of previousSessions) if (!live.has(sid)) killSession(sid)
|
||||
recountAll()
|
||||
recomputeLocalPanels()
|
||||
const mode = useWorkspaceModeStore.getState().mode
|
||||
activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel
|
||||
} finally {
|
||||
applyTemplateInFlightRef.current = false
|
||||
}
|
||||
},
|
||||
[killSession, message, rebindRestoredPanels, recountAll, recomputeLocalPanels, retitleAutoPanels]
|
||||
)
|
||||
@@ -1284,7 +1345,10 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
|
||||
{/* Host-key verification — head of the FIFO queue only. */}
|
||||
{hostKeyHead != null && (
|
||||
<HostKeyModal event={hostKeyHead} onDecision={handleHostKeyDecision} />
|
||||
<HostKeyModal
|
||||
event={hostKeyHead}
|
||||
onDecision={(action) => handleHostKeyDecision(hostKeyHead.promptId, action)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SaveTemplateModal
|
||||
|
||||
@@ -6,7 +6,8 @@ import type {
|
||||
PtyDataEvent,
|
||||
PtyExitEvent,
|
||||
SessionOpenResult,
|
||||
SessionSnapshot
|
||||
SessionSnapshot,
|
||||
SessionStateResult
|
||||
} from './ipc'
|
||||
import type { AppSettings } from './settings'
|
||||
import type { ReleaseNote, UpdateState } from './ipc'
|
||||
@@ -36,6 +37,13 @@ export interface AppApi {
|
||||
openSession(opts: SessionOpenOptions): Promise<SessionOpenResult>
|
||||
/** output a session produced before this renderer subscribed (capped ring buffer) */
|
||||
getSessionReplay(id: string): Promise<string>
|
||||
/**
|
||||
* Compensating query for a missed PTY_EXIT: that broadcast fires exactly once
|
||||
* and is never replayed, so a pane subscribing after its shell died would
|
||||
* wait forever. `exited` (with `exitCode`) is the death state PTY_EXIT would
|
||||
* have set.
|
||||
*/
|
||||
getSessionLiveState(id: string): Promise<SessionStateResult>
|
||||
writePty(id: string, data: string): void
|
||||
resizePty(id: string, cols: number, rows: number): void
|
||||
killPty(id: string): void
|
||||
|
||||
@@ -5,10 +5,11 @@ import { isHighlightCategory, type HighlightRule } from './settings'
|
||||
* a copy before experimenting.
|
||||
*
|
||||
* The envelope carries a kind + version so pasting the wrong JSON (a layout, a
|
||||
* connection list) fails loudly instead of importing nonsense. Validation here
|
||||
* stays deliberately light: it only rejects what cannot be a rule at all
|
||||
* (unparseable JSON, no `rules` array, an unusable regex). Everything else is
|
||||
* repaired by the settings store on save, which already owns that job.
|
||||
* connection list, a file from a future format) fails loudly instead of
|
||||
* importing nonsense. Validation here stays deliberately light: past that
|
||||
* envelope it only rejects what cannot be a rule at all (no `rules` array, an
|
||||
* unusable regex). Everything else is repaired by the settings store on save,
|
||||
* which already owns that job.
|
||||
*/
|
||||
|
||||
export const HIGHLIGHT_FILE_KIND = 'openterminal.highlight-rules'
|
||||
@@ -27,7 +28,7 @@ export interface HighlightFile {
|
||||
}
|
||||
|
||||
/** Reasons a paste can be refused, as codes the UI turns into a message. */
|
||||
export type ImportError = 'not-json' | 'no-rules' | 'wrong-kind'
|
||||
export type ImportError = 'not-json' | 'no-rules' | 'wrong-kind' | 'wrong-version'
|
||||
|
||||
export interface ParsedHighlightFile {
|
||||
rules: HighlightRule[]
|
||||
@@ -68,10 +69,16 @@ export function parseHighlightRules(text: string): ParsedHighlightFile {
|
||||
if (Array.isArray(raw)) {
|
||||
list = raw
|
||||
} else if (raw !== null && typeof raw === 'object' && Array.isArray((raw as { rules?: unknown }).rules)) {
|
||||
const kind = (raw as { kind?: unknown }).kind
|
||||
if (typeof kind === 'string' && kind !== HIGHLIGHT_FILE_KIND) {
|
||||
// An envelope is only trusted when both stamps match: a missing `kind`
|
||||
// means the JSON is not ours, and a foreign `version` means it was written
|
||||
// by a format this build does not know how to read.
|
||||
const { kind, version } = raw as { kind?: unknown; version?: unknown }
|
||||
if (kind !== HIGHLIGHT_FILE_KIND) {
|
||||
return { rules: [], warnings: [], error: 'wrong-kind' }
|
||||
}
|
||||
if (version !== HIGHLIGHT_FILE_VERSION) {
|
||||
return { rules: [], warnings: [], error: 'wrong-version' }
|
||||
}
|
||||
list = (raw as { rules: unknown[] }).rules
|
||||
} else {
|
||||
return { rules: [], warnings: [], error: 'no-rules' }
|
||||
|
||||
@@ -205,6 +205,8 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.importEmpty': 'No usable rules found',
|
||||
'settings.highlight.importBadJson': 'Not valid JSON',
|
||||
'settings.highlight.importWrongKind': 'This JSON is not a highlight-rule file',
|
||||
'settings.highlight.importWrongVersion': 'This JSON has an unsupported version',
|
||||
'settings.highlight.importReadFailed': 'Could not read the file',
|
||||
'settings.highlight.importSkipped': 'Skipped {n}: {list}',
|
||||
'settings.highlight.imported': 'Imported {n} rule(s)',
|
||||
'settings.highlight.testText': 'Test text',
|
||||
@@ -268,6 +270,7 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.builtin.loglevel': 'Log levels',
|
||||
'settings.highlight.builtin.rootat': 'Root prompt (root@)',
|
||||
'settings.highlight.builtin.exitcode': 'Exit codes',
|
||||
'settings.highlight.builtin.percent': 'Percentages & progress',
|
||||
'settings.highlight.builtin.http': 'HTTP status codes',
|
||||
'settings.highlight.builtin.latency': 'Durations (milliseconds)',
|
||||
'settings.highlight.builtin.delop': 'Delete / move / overwrite operations',
|
||||
|
||||
@@ -135,6 +135,13 @@ const ssh: Record<string, string> = {
|
||||
'ssh.file.permColRead': 'Read',
|
||||
'ssh.file.permColWrite': 'Write',
|
||||
'ssh.file.permColExec': 'Execute',
|
||||
'ssh.file.colName': 'Name',
|
||||
'ssh.file.colSize': 'Size',
|
||||
'ssh.file.colModified': 'Modified',
|
||||
'ssh.file.colPerm': 'Permissions',
|
||||
'ssh.file.colOwner': 'Owner',
|
||||
'ssh.file.zoomIn': 'Zoom in',
|
||||
'ssh.file.zoomOut': 'Zoom out',
|
||||
|
||||
// System monitor
|
||||
'ssh.monitor.collecting': 'Collecting…',
|
||||
|
||||
@@ -201,6 +201,8 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.importEmpty': '有効なルールが見つかりません',
|
||||
'settings.highlight.importBadJson': '有効な JSON ではありません',
|
||||
'settings.highlight.importWrongKind': 'この JSON はハイライトルールのファイルではありません',
|
||||
'settings.highlight.importWrongVersion': 'この JSON のバージョンは対応していません',
|
||||
'settings.highlight.importReadFailed': 'ファイルを読み込めませんでした',
|
||||
'settings.highlight.importSkipped': '{n} 件をスキップ:{list}',
|
||||
'settings.highlight.imported': '{n} 件のルールを取り込みました',
|
||||
'settings.highlight.testText': 'テスト文字列',
|
||||
@@ -263,6 +265,7 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.builtin.loglevel': 'ログレベル',
|
||||
'settings.highlight.builtin.rootat': 'root プロンプト(root@)',
|
||||
'settings.highlight.builtin.exitcode': '終了コード',
|
||||
'settings.highlight.builtin.percent': 'パーセントと進捗',
|
||||
'settings.highlight.builtin.http': 'HTTP ステータスコード',
|
||||
'settings.highlight.builtin.latency': '所要時間(ミリ秒)',
|
||||
'settings.highlight.builtin.delop': '削除・移動・上書きの操作',
|
||||
|
||||
@@ -137,6 +137,13 @@ const ssh: Record<string, string> = {
|
||||
'ssh.file.permColRead': '読み取り',
|
||||
'ssh.file.permColWrite': '書き込み',
|
||||
'ssh.file.permColExec': '実行',
|
||||
'ssh.file.colName': '名前',
|
||||
'ssh.file.colSize': 'サイズ',
|
||||
'ssh.file.colModified': '更新日時',
|
||||
'ssh.file.colPerm': 'パーミッション',
|
||||
'ssh.file.colOwner': '所有者',
|
||||
'ssh.file.zoomIn': 'フォント拡大',
|
||||
'ssh.file.zoomOut': 'フォント縮小',
|
||||
|
||||
// システム監視
|
||||
'ssh.monitor.collecting': '取得中…',
|
||||
|
||||
@@ -194,6 +194,8 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.importEmpty': '没有解析到可用规则',
|
||||
'settings.highlight.importBadJson': '不是有效的 JSON',
|
||||
'settings.highlight.importWrongKind': '这份 JSON 不是高亮规则文件',
|
||||
'settings.highlight.importWrongVersion': '这份 JSON 的版本不受支持',
|
||||
'settings.highlight.importReadFailed': '读取文件失败',
|
||||
'settings.highlight.importSkipped': '已跳过 {n} 条:{list}',
|
||||
'settings.highlight.imported': '已导入 {n} 条规则',
|
||||
'settings.highlight.testText': '测试文本',
|
||||
@@ -252,6 +254,7 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.builtin.loglevel': '日志级别',
|
||||
'settings.highlight.builtin.rootat': '管理员提示符 root@',
|
||||
'settings.highlight.builtin.exitcode': '退出码',
|
||||
'settings.highlight.builtin.percent': '百分比与进度',
|
||||
'settings.highlight.builtin.http': 'HTTP 状态码',
|
||||
'settings.highlight.builtin.latency': '耗时(毫秒)',
|
||||
'settings.highlight.builtin.delop': '删除/移动/覆盖操作',
|
||||
|
||||
@@ -129,6 +129,13 @@ const ssh: Record<string, string> = {
|
||||
'ssh.file.permColRead': '读',
|
||||
'ssh.file.permColWrite': '写',
|
||||
'ssh.file.permColExec': '执行',
|
||||
'ssh.file.colName': '名称',
|
||||
'ssh.file.colSize': '大小',
|
||||
'ssh.file.colModified': '修改时间',
|
||||
'ssh.file.colPerm': '权限',
|
||||
'ssh.file.colOwner': '用户/组',
|
||||
'ssh.file.zoomIn': '放大字体',
|
||||
'ssh.file.zoomOut': '缩小字体',
|
||||
|
||||
// 系统监控
|
||||
'ssh.monitor.collecting': '采集中…',
|
||||
|
||||
@@ -194,6 +194,8 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.importEmpty': '沒有解析到可用規則',
|
||||
'settings.highlight.importBadJson': '不是有效的 JSON',
|
||||
'settings.highlight.importWrongKind': '這份 JSON 不是高亮規則檔案',
|
||||
'settings.highlight.importWrongVersion': '這份 JSON 的版本不受支援',
|
||||
'settings.highlight.importReadFailed': '讀取檔案失敗',
|
||||
'settings.highlight.importSkipped': '已略過 {n} 條:{list}',
|
||||
'settings.highlight.imported': '已匯入 {n} 條規則',
|
||||
'settings.highlight.testText': '測試文字',
|
||||
@@ -252,6 +254,7 @@ const settings: Record<string, string> = {
|
||||
'settings.highlight.builtin.loglevel': '日誌等級',
|
||||
'settings.highlight.builtin.rootat': '管理員提示字元 root@',
|
||||
'settings.highlight.builtin.exitcode': '結束碼',
|
||||
'settings.highlight.builtin.percent': '百分比與進度',
|
||||
'settings.highlight.builtin.http': 'HTTP 狀態碼',
|
||||
'settings.highlight.builtin.latency': '耗時(毫秒)',
|
||||
'settings.highlight.builtin.delop': '刪除/移動/覆蓋操作',
|
||||
|
||||
@@ -129,6 +129,13 @@ const ssh: Record<string, string> = {
|
||||
'ssh.file.permColRead': '讀取',
|
||||
'ssh.file.permColWrite': '寫入',
|
||||
'ssh.file.permColExec': '執行',
|
||||
'ssh.file.colName': '名稱',
|
||||
'ssh.file.colSize': '大小',
|
||||
'ssh.file.colModified': '修改時間',
|
||||
'ssh.file.colPerm': '權限',
|
||||
'ssh.file.colOwner': '使用者/群組',
|
||||
'ssh.file.zoomIn': '放大字體',
|
||||
'ssh.file.zoomOut': '縮小字體',
|
||||
|
||||
// 系統監控
|
||||
'ssh.monitor.collecting': '採集中…',
|
||||
|
||||
@@ -35,6 +35,13 @@ export const Ipc = {
|
||||
SESSION_OPEN: 'session:open',
|
||||
/** main keeps a short replay buffer per session so late subscribers catch up */
|
||||
SESSION_REPLAY: 'session:replay',
|
||||
/**
|
||||
* Compensating query for a missed PTY_EXIT: that broadcast fires exactly once
|
||||
* and is never replayed, so a pane bound to a shell that died before it
|
||||
* subscribed would wait forever. Distinct from SESSION_STATE_GET/SET, which
|
||||
* carry the layout snapshot.
|
||||
*/
|
||||
SESSION_STATE: 'session:state',
|
||||
|
||||
// ---- ssh connections (bookmarks) ----
|
||||
CONNECTIONS_LIST: 'connections:list',
|
||||
@@ -216,6 +223,16 @@ export interface PtyExitEvent {
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
/** Answer to SESSION_STATE: what the main process still knows about a session. */
|
||||
export interface SessionStateResult {
|
||||
/** the session is alive and still accepts data */
|
||||
exists: boolean
|
||||
/** the session exited; `exitCode` then carries the status it died with */
|
||||
exited: boolean
|
||||
/** null unless `exited` */
|
||||
exitCode: number | null
|
||||
}
|
||||
|
||||
export interface AppInfo {
|
||||
platform: NodeJS.Platform | string
|
||||
appVersion: string
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface SftpEntry {
|
||||
/** numeric uid/gid (best effort) */
|
||||
uid?: number
|
||||
gid?: number
|
||||
/** owner/group names parsed from readdir longname (best effort) */
|
||||
owner?: string
|
||||
group?: string
|
||||
}
|
||||
|
||||
export type TransferKind = 'upload' | 'download' | 'zmodem-upload' | 'zmodem-download'
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Broadcast-store self-test (broadcast-store.mjs).
|
||||
*
|
||||
* `src/renderer/src/workspace/broadcastStore.ts` decides who receives a keypress
|
||||
* typed into one pane, and it holds the two rules that are easy to get wrong:
|
||||
*
|
||||
* 1. fan-out only happens when broadcast is on AND the originating session is
|
||||
* itself a target — otherwise the keypress stays in the pane it came from;
|
||||
* 2. losing a target session wipes the whole selection and turns broadcast off
|
||||
* (`pruneOnTargetLoss`), while *unchecking* one by hand deliberately keeps
|
||||
* the remaining targets and only drops `enabled`. The two paths look alike
|
||||
* and are not.
|
||||
*
|
||||
* `pruneOnTargetLoss` is module-private, so its behaviour is pinned through the
|
||||
* only caller that can reach it (`unregisterSession`) instead of by exporting it.
|
||||
* The panel registry is module-level and append-only, so each scenario uses its
|
||||
* own ids and resets `enabled`/`targets` through the store's public `setState`.
|
||||
* The `window.api.writePty` surface is stubbed for the write-path scenarios.
|
||||
*
|
||||
* The import pulls in zustand (a devDependency); no React renderer is involved —
|
||||
* the store's `getState`/`setState` work on their own, which is why this test can
|
||||
* import the module directly instead of going through an esbuild bundle.
|
||||
* Run: node tests/broadcast-store.mjs (must exit 0)
|
||||
*/
|
||||
import { broadcastFanOut, useBroadcastStore, writeBroadcast } from '../src/renderer/src/workspace/broadcastStore.ts'
|
||||
|
||||
let failed = 0
|
||||
const ok = (cond, msg) => {
|
||||
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
||||
if (!cond) failed += 1
|
||||
}
|
||||
|
||||
const store = useBroadcastStore
|
||||
const state = () => store.getState()
|
||||
/** Reset only the fields these scenarios drive; the panel registry is shared. */
|
||||
const reset = () => store.setState({ enabled: false, targets: new Set() })
|
||||
const open = (panelId, id) => state().registerSession({ id, panelId, title: id, isSsh: false })
|
||||
const targets = () => [...state().targets]
|
||||
const openIds = () => state().sessions.map((s) => s.id)
|
||||
|
||||
console.log('broadcastFanOut: who receives the keypress')
|
||||
const fanOutCases = [
|
||||
['a', [], false, ['a']],
|
||||
['a', ['a'], false, ['a']],
|
||||
['a', ['a', 'b'], false, ['a']],
|
||||
['a', [], true, ['a']],
|
||||
['a', ['a'], true, ['a']],
|
||||
['a', ['b', 'c'], true, ['a']],
|
||||
['a', ['a', 'b'], true, ['a', 'b']],
|
||||
['b', ['b', 'a'], true, ['b', 'a']],
|
||||
['c', ['a', 'b', 'c'], true, ['a', 'b', 'c']]
|
||||
]
|
||||
for (const [origin, list, enabled, expected] of fanOutCases) {
|
||||
const got = broadcastFanOut(origin, new Set(list), enabled)
|
||||
const same = JSON.stringify(got) === JSON.stringify(expected)
|
||||
ok(
|
||||
same,
|
||||
`origin ${origin}, targets [${list}], enabled=${enabled} -> [${got}]${same ? '' : ` (want [${expected}])`}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('broadcastFanOut returns a fresh array')
|
||||
const live = new Set(['a', 'b'])
|
||||
const out = broadcastFanOut('a', live, true)
|
||||
ok(Array.isArray(out) && out !== live, 'the result is not the live Set')
|
||||
out.push('c')
|
||||
ok(live.size === 2 && !live.has('c'), 'mutating the result does not touch the live target set')
|
||||
|
||||
console.log('losing a target session wipes the selection and turns broadcast off')
|
||||
reset()
|
||||
open('a-p1', 'a1')
|
||||
open('a-p2', 'a2')
|
||||
state().toggleTarget('a1')
|
||||
state().toggleTarget('a2')
|
||||
ok(state().enabled === true && targets().join() === 'a1,a2', 'two targets turn broadcast on')
|
||||
state().unregisterSession('a-p2')
|
||||
ok(state().enabled === false, 'closing the pane that showed a target turns broadcast off')
|
||||
ok(targets().length === 0, 'the whole selection is dropped, not just the lost id')
|
||||
ok(!openIds().includes('a2'), 'the closed session left the registry')
|
||||
ok(openIds().includes('a1'), 'the session that is still open stays')
|
||||
|
||||
console.log('unchecking by hand keeps the rest of the selection')
|
||||
reset()
|
||||
open('b-p1', 'b1')
|
||||
open('b-p2', 'b2')
|
||||
open('b-p3', 'b3')
|
||||
for (const id of ['b1', 'b2', 'b3']) state().toggleTarget(id)
|
||||
ok(state().enabled === true && targets().length === 3, 'three targets turn broadcast on')
|
||||
state().toggleTarget('b3')
|
||||
ok(state().enabled === true && targets().join() === 'b1,b2', 'dropping to two targets keeps it on')
|
||||
state().toggleTarget('b2')
|
||||
ok(
|
||||
state().enabled === false && targets().join() === 'b1',
|
||||
'dropping below the minimum turns it off but keeps the remaining target'
|
||||
)
|
||||
|
||||
console.log('a session mirrored in two panes survives one of them closing')
|
||||
reset()
|
||||
open('c-pA', 'c1')
|
||||
open('c-pB', 'c1')
|
||||
open('c-pC', 'c2')
|
||||
ok(openIds().filter((id) => id === 'c1').length === 1, 'two panels showing one session register it once')
|
||||
state().toggleTarget('c1')
|
||||
state().toggleTarget('c2')
|
||||
ok(state().enabled === true && targets().join() === 'c1,c2', 'c1 is mirrored, c2 is not')
|
||||
state().unregisterSession('c-pA')
|
||||
ok(openIds().includes('c1'), 'the mirrored session is still open through the other pane')
|
||||
ok(targets().join() === 'c1,c2' && state().enabled === true, 'and keeps its target slot')
|
||||
state().unregisterSession('c-pB')
|
||||
ok(!openIds().includes('c1'), 'the last pane showing it takes the session out of the registry')
|
||||
ok(state().enabled === false && targets().length === 0, 'losing it prunes the target set')
|
||||
|
||||
console.log('broadcast needs two targets to turn on')
|
||||
reset()
|
||||
open('d-p1', 'd1')
|
||||
state().setEnabled(true)
|
||||
ok(state().enabled === false, 'enabling with no targets is refused')
|
||||
state().toggleTarget('d1')
|
||||
ok(state().enabled === false && targets().join() === 'd1', 'a single target stays off')
|
||||
state().setEnabled(true)
|
||||
ok(state().enabled === false, 'still refused with one target')
|
||||
open('d-p2', 'd2')
|
||||
state().toggleTarget('d2')
|
||||
ok(state().enabled === true, 'the second target turns it on')
|
||||
state().setEnabled(false)
|
||||
ok(state().enabled === false, 'it can always be turned off')
|
||||
state().setEnabled(true)
|
||||
ok(state().enabled === true, 'and back on while two targets remain')
|
||||
|
||||
console.log('unregistering an unknown or already-dropped panel is a no-op')
|
||||
reset()
|
||||
open('e-p1', 'e1')
|
||||
open('e-p2', 'e2')
|
||||
state().toggleTarget('e1')
|
||||
state().toggleTarget('e2')
|
||||
state().unregisterSession('e-never-registered')
|
||||
ok(state().enabled === true && targets().join() === 'e1,e2', 'an unknown panel id changes nothing')
|
||||
ok(openIds().includes('e1') && openIds().includes('e2'), 'and drops no session')
|
||||
state().unregisterSession('e-p1')
|
||||
const snapshot = JSON.stringify({ enabled: state().enabled, targets: targets(), sessions: openIds() })
|
||||
state().unregisterSession('e-p1')
|
||||
ok(
|
||||
JSON.stringify({ enabled: state().enabled, targets: targets(), sessions: openIds() }) === snapshot,
|
||||
'a second unregister of the same panel changes nothing'
|
||||
)
|
||||
|
||||
console.log('writeBroadcast fans out through the live registry')
|
||||
reset()
|
||||
open('f-p1', 'f1')
|
||||
open('f-p2', 'f2')
|
||||
state().toggleTarget('f1')
|
||||
state().toggleTarget('f2')
|
||||
const writes = []
|
||||
globalThis.window = { api: { writePty: (id, data) => writes.push(`${id}:${data}`) } }
|
||||
try {
|
||||
writeBroadcast('f1', 'ls')
|
||||
ok(writes.join(' ') === 'f1:ls f2:ls', 'an enabled origin that is a target reaches every target')
|
||||
writes.length = 0
|
||||
store.setState({ targets: new Set(['f1', 'f2', 'f-ghost']) })
|
||||
writeBroadcast('f1', 'x')
|
||||
ok(writes.join(' ') === 'f1:x f2:x', 'a target with no open session is skipped')
|
||||
writes.length = 0
|
||||
store.setState({ enabled: false })
|
||||
writeBroadcast('f2', 'y')
|
||||
ok(writes.join(' ') === 'f2:y', 'with broadcast off only the origin is written')
|
||||
writes.length = 0
|
||||
store.setState({ enabled: true, targets: new Set(['f1', 'f2']) })
|
||||
open('f-p3', 'f3')
|
||||
writeBroadcast('f3', 'q')
|
||||
ok(writes.join(' ') === 'f3:q', 'an open origin that is not a target writes only to itself')
|
||||
writes.length = 0
|
||||
store.setState({ enabled: false })
|
||||
writeBroadcast('f-gone', 'w')
|
||||
ok(writes.length === 0, 'a closed origin writes nowhere')
|
||||
} finally {
|
||||
delete globalThis.window
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n[broadcast-store] ${failed} check(s) FAILED`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\n[broadcast-store] ALL CHECKS PASSED')
|
||||
@@ -236,6 +236,21 @@ const expected =
|
||||
'partial-tail'
|
||||
ok(readFileSync(startB.file, 'utf8') === expected, 'burst writes + stop tail land in order')
|
||||
|
||||
// The stop tail is flushed *synchronously* when no async append is in flight:
|
||||
// the quit path (killAllPtys → safeStopLog → logStop) has no later sync point,
|
||||
// so a fresh appendFile chain may never run and the tail would be lost. The
|
||||
// drain for the committed line below has long settled, so nothing can race it.
|
||||
const sid3 = 'cccccccc-dddd-eeee-ffff-000000000000'
|
||||
const startC = store.logStart(sid3)
|
||||
store.logWrite(sid3, 'settled line\n')
|
||||
await wait(100)
|
||||
store.logWrite(sid3, 'no trailing newline')
|
||||
store.logStop(sid3)
|
||||
ok(
|
||||
readFileSync(startC.file, 'utf8') === 'settled line\nno trailing newline',
|
||||
'the stop tail is on disk when logStop returns (sync flush when the drain is idle)'
|
||||
)
|
||||
|
||||
// ---- 7. index.json path containment (hydrateIndex) -----------------------------
|
||||
// index.json is data, not trust: a tampered `file` value must never turn
|
||||
// logWrite into an arbitrary-path append. Only entries that resolve inside
|
||||
@@ -366,6 +381,36 @@ ok(!existsSync(join(freshDir, 'commands.json.bak')), 'a missing file (ENOENT) is
|
||||
ok(existsSync(join(freshDir, 'commands.json')), 'and the first write lands normally')
|
||||
rmSync(freshDir, { recursive: true, force: true })
|
||||
|
||||
// ---- 10. Valid JSON of the wrong shape is backed up too -------------------------
|
||||
// `[1,2,3]` parses fine, so the parse-catch never saw it: the file used to be
|
||||
// treated exactly like a missing one and the next recordCommand replaced it with
|
||||
// an empty history. A file that is not the store we wrote is unreadable, not
|
||||
// empty — same backup + empty-state exit as the corrupt case above.
|
||||
const shapeDir = mkdtempSync(join(tmpdir(), 'm5-cmd-shape-'))
|
||||
const shapeFile = join(shapeDir, 'commands.json')
|
||||
const shapeStore = new commandsMod.CommandsStore(shapeDir)
|
||||
const wrongShape = '[1,2,3]'
|
||||
writeFileSync(shapeFile, wrongShape, 'utf8')
|
||||
ok(shapeStore.listHistory().length === 0, 'a wrong-shaped commands.json loads as an empty history')
|
||||
const shapeBak = `${shapeFile}.bak`
|
||||
ok(existsSync(shapeBak), 'a wrong-shaped commands.json is backed up to .bak')
|
||||
ok(readFileSync(shapeBak, 'utf8') === wrongShape, '.bak holds the wrong-shaped original byte for byte')
|
||||
writeSettings({ historyLimit: 50, historyEnabled: true })
|
||||
shapeStore.recordCommand('after-shape-mismatch')
|
||||
const rewrittenShape = JSON.parse(readFileSync(shapeFile, 'utf8'))
|
||||
ok(
|
||||
Array.isArray(rewrittenShape.history) &&
|
||||
rewrittenShape.history.some((h) => h.command === 'after-shape-mismatch'),
|
||||
'the store recovers with a well-formed file after the backup'
|
||||
)
|
||||
|
||||
// An object without the expected keys is the same class of problem, and a second
|
||||
// episode must not overwrite the first backup.
|
||||
writeFileSync(shapeFile, '{"library":[]}', 'utf8')
|
||||
shapeStore.listHistory()
|
||||
ok(readFileSync(shapeBak, 'utf8') === wrongShape, 'an existing .bak is kept for a wrong shape too (earliest evidence wins)')
|
||||
rmSync(shapeDir, { recursive: true, force: true })
|
||||
|
||||
// All stores wrote into temp dirs; drop them so repeated runs do not litter.
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
|
||||
|
||||
@@ -205,6 +205,42 @@ ok(!existsSync(join(freshDir, 'connections.json.bak')), 'a missing file (ENOENT)
|
||||
ok(existsSync(join(freshDir, 'connections.json')), 'and the first write lands normally')
|
||||
rmSync(freshDir, { recursive: true, force: true })
|
||||
|
||||
// ---- 7b. Valid JSON of the wrong shape is backed up too --------------------------
|
||||
// `{}` parses fine, so the parse-catch never saw it: the file used to be treated
|
||||
// exactly like a missing one and the next write replaced every bookmark with an
|
||||
// empty list. A file that is not the array we wrote is unreadable, not empty —
|
||||
// same backup + empty-state exit as the corrupt case above.
|
||||
const shapeDir = mkdtempSync(join(tmpdir(), 'm-conn-shape-'))
|
||||
const shapeFile = join(shapeDir, 'connections.json')
|
||||
const shapeStore = new mod.ConnectionsStore(shapeFile)
|
||||
const wrongShape = '{"connections":[]}'
|
||||
writeFileSync(shapeFile, wrongShape, 'utf8')
|
||||
ok(shapeStore.listConnections().length === 0, 'a wrong-shaped connections.json loads as an empty list')
|
||||
const shapeBak = `${shapeFile}.bak`
|
||||
ok(existsSync(shapeBak), 'a wrong-shaped connections.json is backed up to .bak')
|
||||
ok(readFileSync(shapeBak, 'utf8') === wrongShape, '.bak holds the wrong-shaped original byte for byte')
|
||||
const shapeSaved = shapeStore.saveConnection({
|
||||
name: 'after-shape-mismatch',
|
||||
host: 'h',
|
||||
port: 22,
|
||||
username: 'u',
|
||||
auth: 'password',
|
||||
askPasswordAtConnect: false,
|
||||
askPassphraseAtConnect: false,
|
||||
keepaliveIntervalSec: 0
|
||||
})
|
||||
const shapeList = JSON.parse(readFileSync(shapeFile, 'utf8'))
|
||||
ok(
|
||||
Array.isArray(shapeList) && shapeList.length === 1 && shapeList[0].id === shapeSaved.id,
|
||||
'the new bookmark is written normally after the backup'
|
||||
)
|
||||
|
||||
// A second wrong-shape episode must not overwrite the first backup.
|
||||
writeFileSync(shapeFile, '{"nope":true}', 'utf8')
|
||||
shapeStore.listConnections()
|
||||
ok(readFileSync(shapeBak, 'utf8') === wrongShape, 'an existing .bak is kept (earliest evidence wins)')
|
||||
rmSync(shapeDir, { recursive: true, force: true })
|
||||
|
||||
// An unreadable path (here: a directory) must still load as empty and never
|
||||
// throw — the backup is best effort and may itself fail.
|
||||
const dirCase = mkdtempSync(join(tmpdir(), 'm-conn-dir-'))
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
import { compileRules, applyHighlights, __testHooks as hooks } from '../src/renderer/src/terminal/highlightEngine.ts'
|
||||
import { previewSpans } from '../src/renderer/src/terminal/highlightEngine.ts'
|
||||
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
|
||||
import { exportHighlightRules, mergeRules, parseHighlightRules } from '../src/shared/highlightIO.ts'
|
||||
import {
|
||||
exportHighlightRules,
|
||||
HIGHLIGHT_FILE_KIND,
|
||||
HIGHLIGHT_FILE_VERSION,
|
||||
mergeRules,
|
||||
parseHighlightRules
|
||||
} from '../src/shared/highlightIO.ts'
|
||||
import {
|
||||
excludedByProfile,
|
||||
rulesForProfile,
|
||||
@@ -549,11 +555,26 @@ console.log('[import / export]')
|
||||
parseHighlightRules('{"kind":"openterminal.layout","rules":[]}').error === 'wrong-kind',
|
||||
'a foreign envelope is refused'
|
||||
)
|
||||
ok(
|
||||
parseHighlightRules('{"rules":[]}').error === 'wrong-kind',
|
||||
'an envelope with no kind at all is refused'
|
||||
)
|
||||
ok(
|
||||
parseHighlightRules(JSON.stringify({ kind: HIGHLIGHT_FILE_KIND, version: HIGHLIGHT_FILE_VERSION + 1, rules: [] }))
|
||||
.error === 'wrong-version',
|
||||
'an envelope from another version is refused'
|
||||
)
|
||||
ok(
|
||||
parseHighlightRules(JSON.stringify({ kind: HIGHLIGHT_FILE_KIND, rules: [] })).error === 'wrong-version',
|
||||
'an envelope with no version at all is refused'
|
||||
)
|
||||
ok(parseHighlightRules('{"nope":1}').error === 'no-rules', 'JSON without a rules array is refused')
|
||||
ok(parseHighlightRules('[]').error === undefined, 'a bare empty array is accepted')
|
||||
|
||||
const partial = parseHighlightRules(
|
||||
JSON.stringify({
|
||||
kind: HIGHLIGHT_FILE_KIND,
|
||||
version: HIGHLIGHT_FILE_VERSION,
|
||||
rules: [
|
||||
{ pattern: '\\bOK\\b', priority: 500, color: { fg: 'red' } },
|
||||
{ pattern: '[' },
|
||||
|
||||
@@ -94,6 +94,27 @@ console.log('[highlight rules]')
|
||||
const out = (await roundTrip({ highlightRules: [] })).highlightRules
|
||||
ok(out.length === 0, 'an explicitly empty rule list stays empty')
|
||||
}
|
||||
{
|
||||
// A non-array highlightRules is a shape error, not "no rules": the built-in
|
||||
// rules come back as copies (mutating a loaded list must not poison the
|
||||
// module-level preset table) and the fallback is recorded in the warnings log.
|
||||
writeFileSync(
|
||||
join(userData, 'settings.json'),
|
||||
JSON.stringify({ highlightRules: { nope: true } }),
|
||||
'utf8'
|
||||
)
|
||||
const first = store.loadSettings().highlightRules
|
||||
ok(first.length > 0, `a non-array highlightRules loads the built-in rules (got ${first.length})`)
|
||||
const second = store.loadSettings().highlightRules
|
||||
ok(first !== second, 'each load hands out its own array, not the shared preset list')
|
||||
first.length = 0
|
||||
const afterMutation = store.loadSettings().highlightRules
|
||||
ok(afterMutation.length > 0, 'emptying a loaded rule list does not affect the next load')
|
||||
const log = existsSync(join(userData, 'settings-warnings.log'))
|
||||
? readFileSync(join(userData, 'settings-warnings.log'), 'utf8')
|
||||
: ''
|
||||
ok(log.includes('highlightRules: not an array'), 'the fallback is recorded in settings-warnings.log')
|
||||
}
|
||||
|
||||
// ---- 3. value bands ---------------------------------------------------------
|
||||
console.log('[value bands]')
|
||||
|
||||
@@ -51,8 +51,9 @@ sftp.setLocalPathPolicy({
|
||||
/**
|
||||
* A fake ssh2 SFTPWrapper whose per-call behavior is scripted.
|
||||
* `behaviour(op, args)` returns `'silent'` (never calls back — the dead-channel
|
||||
* case), `'error'` (calls back with an error), or `'ok'` (calls back with
|
||||
* `result`). Every call is recorded so the test can assert on what was opened.
|
||||
* case), `'enoent'` (calls back with the server's "no such file"), `'error'`
|
||||
* (calls back with a generic error), or `'ok'` (calls back with `result`). Every
|
||||
* call is recorded so the test can assert on what was opened.
|
||||
*/
|
||||
const defaultStat = () => ({
|
||||
isDirectory: () => false,
|
||||
@@ -63,6 +64,9 @@ const defaultStat = () => ({
|
||||
gid: 1000
|
||||
})
|
||||
|
||||
/** SSH_FX_NO_SUCH_FILE as ssh2 surfaces it: a numeric status on an Error. */
|
||||
const noSuchFile = () => Object.assign(new Error('No such file'), { code: 2 })
|
||||
|
||||
let calls
|
||||
let behaviour
|
||||
let openedChannels
|
||||
@@ -101,6 +105,7 @@ const makeWrapper = () => {
|
||||
calls.push({ op, args })
|
||||
const verdict = behaviour(op, args)
|
||||
if (verdict === 'silent') return
|
||||
if (verdict === 'enoent') return cb(noSuchFile())
|
||||
if (verdict === 'error') return cb(new Error(`${op} failed`))
|
||||
cb(null, voidResult ? undefined : result)
|
||||
}
|
||||
@@ -433,6 +438,77 @@ console.log('default budgets are sane relative to each other')
|
||||
ok(elapsed < 3000, `the check itself was quick (${elapsed}ms)`)
|
||||
}
|
||||
|
||||
// ---- 7. a delete is idempotent: "already gone" is success ------------------
|
||||
console.log('deleting an entry that is already gone succeeds instead of reporting a failure')
|
||||
{
|
||||
// The file may have been removed by someone else between the listing and the
|
||||
// click, or the same delete may be run again after an earlier pass already
|
||||
// removed it. "No such file" means the end state the user asked for already
|
||||
// holds, so it must not surface as "delete failed".
|
||||
reset((op) => (op === 'lstat' || op === 'unlink' ? 'enoent' : 'ok'))
|
||||
sftp.closeSftp('del-gone')
|
||||
const missing = await expectReject(sftp.deleteRemote('del-gone', ['/remote/gone.txt']), 'deleteRemote')
|
||||
ok(!missing.rejected, `a missing file is not a delete failure (${missing.message})`)
|
||||
|
||||
// The tolerance is for that one status only: a real unlink failure still has
|
||||
// to reach the user.
|
||||
reset((op) => (op === 'unlink' ? 'error' : 'ok'))
|
||||
sftp.closeSftp('del-denied')
|
||||
const denied = await expectReject(sftp.deleteRemote('del-denied', ['/remote/x.txt']), 'deleteRemote')
|
||||
ok(denied.rejected, 'a genuine unlink failure is still reported')
|
||||
|
||||
// The other shape of the same status: no numeric code, only the library's
|
||||
// English message.
|
||||
reset(() => 'ok')
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
const w = makeWrapper()
|
||||
w.unlink = (p, cb) => {
|
||||
calls.push({ op: 'unlink', args: [p] })
|
||||
cb(new Error('No such file'))
|
||||
}
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
sftp.closeSftp('del-gone-msg')
|
||||
const byMessage = await expectReject(sftp.deleteRemote('del-gone-msg', ['/remote/gone.txt']), 'deleteRemote')
|
||||
ok(!byMessage.rejected, `the message-only form is tolerated too (${byMessage.message})`)
|
||||
|
||||
// The recursive shapes: the directory itself is gone, and a tree whose entries
|
||||
// were already removed reports the same status from unlink and rmdir.
|
||||
reset((op, args) => (op === 'readdir' && args[0] === '/remote/dir' ? 'enoent' : 'ok'))
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
const w = makeWrapper()
|
||||
// Only the top-level path is a directory, or the children would recurse.
|
||||
w.lstat = (p, cb) => {
|
||||
calls.push({ op: 'lstat', args: [p] })
|
||||
cb(null, { ...defaultStat(), isDirectory: () => p === '/remote/dir' })
|
||||
}
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
sftp.closeSftp('del-dir-gone')
|
||||
const dirGone = await expectReject(sftp.deleteRemote('del-dir-gone', ['/remote/dir']), 'deleteRemote')
|
||||
ok(!dirGone.rejected, `a directory that no longer exists is not a failure (${dirGone.message})`)
|
||||
|
||||
reset((op) => (op === 'unlink' || op === 'rmdir' ? 'enoent' : 'ok'))
|
||||
sftp.closeSftp('del-dir-partial')
|
||||
const dirPartial = await expectReject(sftp.deleteRemote('del-dir-partial', ['/remote/dir']), 'deleteRemote')
|
||||
ok(!dirPartial.rejected, `a tree whose entries were already removed is not a failure (${dirPartial.message})`)
|
||||
|
||||
// Restore the standard provider for any later section.
|
||||
sftp.registerSftpClientProvider(() => ({
|
||||
sftp: (cb) => {
|
||||
const w = makeWrapper()
|
||||
openedChannels.push(w)
|
||||
cb(null, w)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// ---- helpers ----------------------------------------------------------------
|
||||
function waitFor(pred, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Working-directory tracking self-test (terminal-cwd.mjs).
|
||||
*
|
||||
* `src/renderer/src/terminal/cwdTracker.ts` is the renderer half of cwd memory:
|
||||
* it turns a submitted command line into a cd argument (or null) and unwraps
|
||||
* ConEmu's OSC 9 payload. Everything platform-specific — path resolution,
|
||||
* existence checks, quote stripping, `cd -` rejection — lives in the main
|
||||
* process (`src/main/cwd.ts`), so the contract pinned here is deliberately
|
||||
* narrow, and the table says which side owns what:
|
||||
*
|
||||
* - the cd family per shell (cd / chdir / sl / Set-Location / pushd /
|
||||
* Push-Location) and the bare `d:` drive switch;
|
||||
* - an EMPTY STRING means "bare cd" (home) and is NOT the same answer as
|
||||
* null ("not a cd line") — the caller only resolves on non-null;
|
||||
* - quotes and trailing arguments are handed to main verbatim: this module is
|
||||
* not a shell parser, so `cd "D:\Program Files"` keeps its quotes and
|
||||
* `cd /tmp && ls` keeps its separator;
|
||||
* - lookalikes (`cdrom`, `cdx`, `sleep 1`, `VAR=x cd y`) never match, since a
|
||||
* false positive would move the remembered directory on an unrelated line.
|
||||
*
|
||||
* Unlike the esbuild-bundled tests, this one imports the .ts module directly:
|
||||
* it has no imports and no runtime-only TS syntax, and Node >= 22.18 strips the
|
||||
* type annotations itself. Run: node tests/terminal-cwd.mjs (must exit 0)
|
||||
*/
|
||||
import { cdArgument, conemuCwd } from '../src/renderer/src/terminal/cwdTracker.ts'
|
||||
|
||||
let failed = 0
|
||||
const ok = (cond, msg) => {
|
||||
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
||||
if (!cond) failed += 1
|
||||
}
|
||||
|
||||
/** Table-driven: [line, expected] — `null` = "not a cd line", `''` = bare cd. */
|
||||
const table = (label, cases, fn) => {
|
||||
console.log(label)
|
||||
for (const [input, expected] of cases) {
|
||||
const got = fn(input)
|
||||
ok(
|
||||
got === expected,
|
||||
`${JSON.stringify(input)} -> ${JSON.stringify(got)}${got === expected ? '' : ` (want ${JSON.stringify(expected)})`}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
table(
|
||||
'the cd family returns the raw argument (main does the path work)',
|
||||
[
|
||||
['cd /path', '/path'],
|
||||
['cd\t/tmp', '/tmp'],
|
||||
['cd\n/tmp', '/tmp'],
|
||||
['cd ~/x', '~/x'],
|
||||
['cd ../x', '../x'],
|
||||
['cd -', '-'],
|
||||
['cd .', '.'],
|
||||
['cd D:\\projects', 'D:\\projects'],
|
||||
['cd "D:\\Program Files"', '"D:\\Program Files"'],
|
||||
["cd '~/src'", "'~/src'"],
|
||||
['cd ""', '""']
|
||||
],
|
||||
cdArgument
|
||||
)
|
||||
|
||||
table(
|
||||
'every shell spelling is recognised, case-insensitively',
|
||||
[
|
||||
['CD /tmp', '/tmp'],
|
||||
['Cd /tmp', '/tmp'],
|
||||
[' cD /tmp ', '/tmp'],
|
||||
['chdir C:\\x', 'C:\\x'],
|
||||
['CHDIR ..', '..'],
|
||||
['sl .', '.'],
|
||||
['sl', ''],
|
||||
['Set-Location /a', '/a'],
|
||||
['set-location /a', '/a'],
|
||||
['pushd ../b', '../b'],
|
||||
['Push-Location ~/c', '~/c']
|
||||
],
|
||||
cdArgument
|
||||
)
|
||||
|
||||
table(
|
||||
'bare cd is the empty-string sentinel, not null',
|
||||
[
|
||||
['cd', ''],
|
||||
['cd ', ''],
|
||||
[' cd ', ''],
|
||||
['cd\t', '']
|
||||
],
|
||||
cdArgument
|
||||
)
|
||||
|
||||
table(
|
||||
'trailing arguments and separators stay in the argument',
|
||||
[
|
||||
['cd /tmp ', '/tmp'],
|
||||
['cd /a b', '/a b'],
|
||||
['cd /tmp extra', '/tmp extra'],
|
||||
['cd /tmp && ls', '/tmp && ls'],
|
||||
['cd /tmp; ls', '/tmp; ls'],
|
||||
['cd /tmp | more', '/tmp | more']
|
||||
],
|
||||
cdArgument
|
||||
)
|
||||
|
||||
table(
|
||||
'the bare drive switch resolves to the drive root',
|
||||
[
|
||||
['d:', 'D:\\'],
|
||||
['D:', 'D:\\'],
|
||||
[' d: ', 'D:\\'],
|
||||
['a:', 'A:\\'],
|
||||
['z:', 'Z:\\']
|
||||
],
|
||||
cdArgument
|
||||
)
|
||||
|
||||
table(
|
||||
'lines that are not a cd are null (no false positives)',
|
||||
[
|
||||
['', null],
|
||||
[' ', null],
|
||||
['cdx /tmp', null],
|
||||
['cdrom', null],
|
||||
['cdemo /x', null],
|
||||
['slack', null],
|
||||
['sleep 1', null],
|
||||
['VAR=x cd y', null],
|
||||
['sudo cd /x', null],
|
||||
['echo cd /x', null],
|
||||
['ls -la', null],
|
||||
['d:\\', null],
|
||||
['..', null],
|
||||
['~', null]
|
||||
],
|
||||
cdArgument
|
||||
)
|
||||
|
||||
console.log('the empty-string answer is distinct from "not a cd line"')
|
||||
ok(cdArgument('cd') === '', 'a bare cd answers with the empty string')
|
||||
ok(cdArgument('cd') !== null, 'and is not null, so the caller still resolves it')
|
||||
ok(cdArgument('ls') === null, 'a non-cd line answers null')
|
||||
|
||||
table(
|
||||
'ConEmu OSC 9;9 carries a bare path',
|
||||
[
|
||||
['9;/home/a', '/home/a'],
|
||||
['9;C:\\Users\\a', 'C:\\Users\\a'],
|
||||
['9;C:\\Program Files', 'C:\\Program Files'],
|
||||
['9;/a\n', '/a'],
|
||||
['9; /a ', '/a'],
|
||||
['9; ', null],
|
||||
['9;', null],
|
||||
['9', null],
|
||||
['90;x', null],
|
||||
['9;;x', ';x'],
|
||||
['', null],
|
||||
['7;file://host/p', null],
|
||||
['8;/a', null]
|
||||
],
|
||||
conemuCwd
|
||||
)
|
||||
|
||||
console.log('OSC 9 payloads that are not ConEmu cwd reports are rejected')
|
||||
ok(conemuCwd('9;1;2') === '1;2', 'the remainder after the convention marker is the path')
|
||||
ok(conemuCwd('9;file://host/p') === 'file://host/p', 'a file:// body is passed through for main to normalize')
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n[terminal-cwd] ${failed} check(s) FAILED`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\n[terminal-cwd] ALL CHECKS PASSED')
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Clickable-URL self-test (terminal-links.mjs).
|
||||
*
|
||||
* `src/renderer/src/terminal/urlLinks.ts` feeds xterm's link provider: it scans
|
||||
* one *logical* line (the provider rebuilds it across wrapped rows first) and
|
||||
* returns half-open `[start, end)` ranges plus a scheme-carrying target. The
|
||||
* ranges are what the terminal underlines and what a click opens, so the table
|
||||
* pins the real contract rather than an idealised one:
|
||||
*
|
||||
* - `http(s)://` and `ftp://` are handed over as written (the match is
|
||||
* case-insensitive, the target keeps the original case); `www.…`,
|
||||
* `localhost:port`, `127.0.0.1:port` and `0.0.0.0:port` get an `http://`
|
||||
* target added;
|
||||
* - a bare host WITHOUT a port is not a link, and a bare domain without `www.`
|
||||
* never is — the two shapes that would otherwise make every `foo.com` in
|
||||
* build output clickable;
|
||||
* - trailing sentence punctuation (`. , ; : ! ? ) ] } > ' "` plus the CJK
|
||||
* forms) is stripped from the URL *and* from `end`, so the closing bracket
|
||||
* of a sentence is not underlined. Brackets, quotes and whitespace cannot
|
||||
* appear inside the body at all;
|
||||
* - ranges never overlap: `http://localhost:5000` also matches the bare-host
|
||||
* pattern, and the longer, scheme-carrying match wins;
|
||||
* - the body is greedy up to the next whitespace, so `http://a/http://b` is
|
||||
* one link — a documented limitation, not an accident to be "fixed" here.
|
||||
*
|
||||
* Direct .ts import (no esbuild bundle needed): the module has no imports and no
|
||||
* runtime-only TS syntax, and Node >= 22.18 strips types itself.
|
||||
* Run: node tests/terminal-links.mjs (must exit 0)
|
||||
*/
|
||||
import { findUrls } from '../src/renderer/src/terminal/urlLinks.ts'
|
||||
|
||||
let failed = 0
|
||||
const ok = (cond, msg) => {
|
||||
console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`)
|
||||
if (!cond) failed += 1
|
||||
}
|
||||
|
||||
/** [start, end, url] triples — compact enough to keep the tables readable. */
|
||||
const triples = (text) => findUrls(text).map((l) => [l.start, l.end, l.url])
|
||||
|
||||
const table = (label, cases) => {
|
||||
console.log(label)
|
||||
for (const [text, expected] of cases) {
|
||||
const got = triples(text)
|
||||
ok(
|
||||
JSON.stringify(got) === JSON.stringify(expected),
|
||||
`${JSON.stringify(text)} -> ${JSON.stringify(got)}${JSON.stringify(got) === JSON.stringify(expected) ? '' : ` (want ${JSON.stringify(expected)})`}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
table('fully qualified http/https URLs are handed over as written', [
|
||||
['https://example.com', [[0, 19, 'https://example.com']]],
|
||||
['http://example.com/a/b?c=1#frag', [[0, 31, 'http://example.com/a/b?c=1#frag']]],
|
||||
['see https://example.com/a.', [[4, 25, 'https://example.com/a']]],
|
||||
['https://user:pass@host.com:8443/x', [[0, 33, 'https://user:pass@host.com:8443/x']]],
|
||||
['\t\thttps://example.com/x', [[2, 23, 'https://example.com/x']]]
|
||||
])
|
||||
|
||||
table('matching is case-insensitive, the target keeps the original case', [
|
||||
['HTTP://EXAMPLE.COM', [[0, 18, 'HTTP://EXAMPLE.COM']]],
|
||||
['Visit HTTPS://Example.COM/Path', [[6, 30, 'HTTPS://Example.COM/Path']]]
|
||||
])
|
||||
|
||||
table('trailing sentence punctuation is not part of the link', [
|
||||
['go to https://example.com.', [[6, 25, 'https://example.com']]],
|
||||
['https://example.com, then', [[0, 19, 'https://example.com']]],
|
||||
['https://example.com/a; ls', [[0, 21, 'https://example.com/a']]],
|
||||
['https://example.com/a,;:!?', [[0, 21, 'https://example.com/a']]],
|
||||
['(https://example.com/a)', [[1, 22, 'https://example.com/a']]],
|
||||
['<https://example.com/a>', [[1, 22, 'https://example.com/a']]],
|
||||
['"https://example.com/a"', [[1, 22, 'https://example.com/a']]],
|
||||
["'https://example.com/a'", [[1, 22, 'https://example.com/a']]],
|
||||
['[https://example.com/a]', [[1, 22, 'https://example.com/a']]],
|
||||
['https://example.com/a).', [[0, 21, 'https://example.com/a']]]
|
||||
])
|
||||
|
||||
table('CJK punctuation ends the link too', [
|
||||
['中文 https://example.com/路径。然后', [[3, 25, 'https://example.com/路径']]],
|
||||
['见 https://example.com/路径,还有', [[2, 24, 'https://example.com/路径']]],
|
||||
['https://example.com/a、b', [[0, 21, 'https://example.com/a']]]
|
||||
])
|
||||
|
||||
table('several URLs on one line keep their own offsets', [
|
||||
[
|
||||
'go to http://a.com and https://b.org/x?y=1!',
|
||||
[
|
||||
[6, 18, 'http://a.com'],
|
||||
[23, 42, 'https://b.org/x?y=1']
|
||||
]
|
||||
],
|
||||
[
|
||||
'x https://a.com https://a.com y',
|
||||
[
|
||||
[2, 15, 'https://a.com'],
|
||||
[16, 29, 'https://a.com']
|
||||
]
|
||||
]
|
||||
])
|
||||
|
||||
table('overlapping matches collapse to the longest one', [
|
||||
['http://localhost:5000', [[0, 21, 'http://localhost:5000']]],
|
||||
['http://localhost:5000/x', [[0, 23, 'http://localhost:5000/x']]],
|
||||
['localhost:3000 http://localhost:3000', [
|
||||
[0, 14, 'http://localhost:3000'],
|
||||
[15, 36, 'http://localhost:3000']
|
||||
]],
|
||||
['http://127.0.0.1:8080/x', [[0, 23, 'http://127.0.0.1:8080/x']]]
|
||||
])
|
||||
|
||||
table('ftp is recognised as a scheme of its own', [
|
||||
['ftp://files.example.com/pub', [[0, 27, 'ftp://files.example.com/pub']]],
|
||||
['ftp://files.example.com/pub.', [[0, 27, 'ftp://files.example.com/pub']]]
|
||||
])
|
||||
|
||||
table('scheme-less dev-server forms get an http:// target', [
|
||||
['localhost:5000', [[0, 14, 'http://localhost:5000']]],
|
||||
['localhost:5000/x', [[0, 16, 'http://localhost:5000/x']]],
|
||||
[' Listening on http://0.0.0.0:3000', [[15, 34, 'http://0.0.0.0:3000']]],
|
||||
['127.0.0.1:8080/x', [[0, 16, 'http://127.0.0.1:8080/x']]],
|
||||
['www.example.com/path.', [[0, 20, 'http://www.example.com/path']]],
|
||||
['WWW.EXAMPLE.COM', [[0, 15, 'http://WWW.EXAMPLE.COM']]],
|
||||
['see www.example.com:8080', [[4, 24, 'http://www.example.com:8080']]]
|
||||
])
|
||||
|
||||
table('shapes that must NOT become links', [
|
||||
['no links here', []],
|
||||
['', []],
|
||||
['a.com', []],
|
||||
['localhost', []],
|
||||
['0.0.0.0', []],
|
||||
['127.0.0.1', []],
|
||||
['localhost:', []],
|
||||
['xhttps://a.com', []],
|
||||
['https://', []],
|
||||
['mailto:user@example.com', []],
|
||||
['file:///c:/x', []],
|
||||
['ssh://git@host/repo', []],
|
||||
['C:\\Users\\me', []],
|
||||
['www.', []],
|
||||
['www.example', []]
|
||||
])
|
||||
|
||||
table('documented greedy behaviour: the body runs to the next whitespace', [
|
||||
['http://a.com/http://b.com', [[0, 25, 'http://a.com/http://b.com']]],
|
||||
['https://a.com/x\tand', [[0, 15, 'https://a.com/x']]],
|
||||
['https://a.com/x and', [[0, 15, 'https://a.com/x']]]
|
||||
])
|
||||
|
||||
console.log('the optional path group of a bare host needs a body character')
|
||||
ok(
|
||||
JSON.stringify(triples('localhost:5000/')) === JSON.stringify([[0, 14, 'http://localhost:5000']]),
|
||||
'a trailing slash with nothing behind it is left out of the range'
|
||||
)
|
||||
|
||||
console.log('every range is consistent with its own text')
|
||||
const corpus = [
|
||||
'go to http://a.com and https://b.org/x?y=1!',
|
||||
'(https://a.com/a)',
|
||||
'http://a.com/http://b.com',
|
||||
'plain output, no links',
|
||||
'http://localhost:5000/x and www.example.com/y, then https://c.jp/z。',
|
||||
'ftp://f.io/a https://g.io/b',
|
||||
'https://example.com/path).',
|
||||
'localhost:3000/',
|
||||
'https://'
|
||||
]
|
||||
for (const text of corpus) {
|
||||
const found = findUrls(text)
|
||||
const ranges = found.map((l) => `${l.start}-${l.end}`)
|
||||
ok(
|
||||
found.every((l, i) => i === 0 || found[i - 1].end <= l.start),
|
||||
`${JSON.stringify(text)}: ranges do not overlap (${ranges.join(' ')})`
|
||||
)
|
||||
ok(
|
||||
found.every((l) => l.start >= 0 && l.end <= text.length && l.end > l.start),
|
||||
`${JSON.stringify(text)}: every range is inside the line and non-empty`
|
||||
)
|
||||
ok(
|
||||
found.every((l) => l.url.length === l.end - l.start || l.url.endsWith(text.slice(l.start, l.end))),
|
||||
`${JSON.stringify(text)}: the target matches the range it covers`
|
||||
)
|
||||
ok(
|
||||
found.every((l) => /^[a-z][a-z0-9+.-]*:\/\//i.test(l.url)),
|
||||
`${JSON.stringify(text)}: every target carries a scheme`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('the target of a bare host is the matched text behind http://')
|
||||
ok(findUrls('localhost:5000')[0].url === 'http://localhost:5000', 'the matched text is kept verbatim behind the scheme')
|
||||
ok(
|
||||
findUrls('go to https://example.com.')[0].url === 'https://example.com',
|
||||
'a scheme URL is not prefixed a second time'
|
||||
)
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n[terminal-links] ${failed} check(s) FAILED`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\n[terminal-links] ALL CHECKS PASSED')
|
||||
@@ -14,20 +14,24 @@ website/
|
||||
├── index.html # 单页站点:功能介绍 / 下载 / 更新日志
|
||||
├── .nojekyll # 让 GitHub Pages 原样托管(跳过 Jekyll 处理)
|
||||
└── assets/
|
||||
├── style.css # 全部样式(企业风浅色主题,单一强调色)
|
||||
├── shot-terminal.png # 主界面截图(四路分屏)
|
||||
├── shot-themes.png # 主题设置页截图
|
||||
├── style.css # 全部样式(深色默认 / 浅色可切换,主题令牌在 :root 与 html[data-theme='light'])
|
||||
├── fonts/ # 自托管 woff2:Space Grotesk 400/500/600/700、JetBrains Mono 400/500/600
|
||||
├── shot-terminal.jpg # 主界面截图(三路分屏并行 AI 编程 Agent,含背景图个性化)
|
||||
├── shot-agents.png # 多路 AI Agent 分屏截图(无背景图版)
|
||||
├── shot-sftp.png # SFTP 文件面板:列头、排序、用户/组、字体缩放
|
||||
├── shot-sftp-menu.png # 文件列表空白处右键菜单
|
||||
├── shot-themes.jpg # 主题设置页截图(背景图/不透明度/压暗层)
|
||||
├── shot-lock.png # 锁屏界面截图
|
||||
└── icon.png # 应用图标(来自仓库 build/icon.png)
|
||||
```
|
||||
|
||||
## 发新版本时需要更新的地方
|
||||
|
||||
`index.html` 中搜索 `1.0.15`,共 3 处下载链接(hero 主按钮、下载表格 exe、msi)。
|
||||
下载链接使用 GitHub Releases 原生地址:
|
||||
`index.html` 中搜索当前版本号,共 2 处下载链接(hero 主按钮、下载表格 exe)。
|
||||
自 v1.0.22 起不再提供 MSI,只有 NSIS exe 一条直链:
|
||||
|
||||
```
|
||||
https://github.com/billowliu2/OpenTerminal/releases/download/v<版本>/OpenTerminal-<版本>-setup.exe
|
||||
https://github.com/billowliu2/OpenTerminal/releases/download/v<版本>/OpenTerminal-<版本>-setup.msi
|
||||
```
|
||||
|
||||
注意:发布新版本时需同时把安装包资产上传到 GitHub release
|
||||
@@ -61,7 +65,10 @@ npx serve website
|
||||
|
||||
## 设计约定
|
||||
|
||||
- 浅色企业风,避免营销站常见的霓虹/渐变装饰
|
||||
- 强调色仅一个:`#1a7f37`(应用品牌绿 #3fb950 的深色化,保证浅底对比度 ≥ 4.5:1)
|
||||
- 截图来自真实运行界面,与应用内设置一致
|
||||
- 中文为主要语言;字体使用系统栈(Segoe UI / 微软雅黑),不加载外部字体
|
||||
- **深色为主**:默认深色(`#0b0e12` 底 + 终端绿强调色),右上角按钮可切浅色;两套主题共用同一份令牌,新增样式一律走 CSS 变量,不要写死颜色
|
||||
- 主题选择存 `localStorage['openterminal.theme']`,首帧前由 `index.html` 头部的内联脚本定好,避免闪白/闪黑
|
||||
- 全站只用**一个强调色**:深底 `#3fb950`、浅底 `#1a7f37`(对比度 ≥ 4.5:1)
|
||||
- 字体自托管(`assets/fonts/`),不依赖 Google Fonts:展示字体 Space Grotesk、等宽 JetBrains Mono;中文回落到系统栈
|
||||
- 「核心能力」是横向滚动卡片轨道(箭头翻页 + 拖拽 + 键盘方向键),不再用三栏等高卡片
|
||||
- 截图来自真实运行界面,与应用内设置一致;涉及 IP / 主机名等敏感信息的截图必须打码后再放入 `assets/`
|
||||
- 中文为主要语言;文案保持具体、平实,避免营销套话
|
||||
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 662 KiB |
|
After Width: | Height: | Size: 629 KiB |
|
After Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 101 KiB |
@@ -1,17 +1,140 @@
|
||||
/* OpenTerminal 官网 - 企业风浅色主题
|
||||
单一强调色: 品牌绿 #1a7f37 (深化的 #3fb950, 保证浅底对比度)
|
||||
无渐变 / 无发光 / 无装饰性动画, 仅保留 hover 与 focus 反馈 */
|
||||
/* OpenTerminal 官网 —— 双主题 (深色默认 / 浅色)
|
||||
排版: Space Grotesk (展示) + JetBrains Mono (等宽), 自托管 woff2
|
||||
单一强调色: 终端绿 (深底 #3fb950, 浅底 #1a7f37) */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Space Grotesk';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('assets/fonts/space-grotesk-400.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Space Grotesk';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('assets/fonts/space-grotesk-500.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Space Grotesk';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('assets/fonts/space-grotesk-600.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Space Grotesk';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('assets/fonts/space-grotesk-700.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('assets/fonts/jetbrains-mono-400.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('assets/fonts/jetbrains-mono-500.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('assets/fonts/jetbrains-mono-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* ---- 主题令牌: 深色为默认 ---- */
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-soft: #f7f8f8;
|
||||
--text: #1c2128;
|
||||
--text-2: #57606a;
|
||||
--border: #d9dee3;
|
||||
color-scheme: dark;
|
||||
|
||||
--bg: #0b0e12;
|
||||
--bg-alt: #0d1219;
|
||||
--surface: #11161e;
|
||||
--surface-2: #161c26;
|
||||
|
||||
--text: #e6ebe6;
|
||||
--text-2: #99a49c;
|
||||
--text-3: #6f7a72;
|
||||
|
||||
--border: rgba(255, 255, 255, 0.09);
|
||||
--border-strong: rgba(255, 255, 255, 0.16);
|
||||
|
||||
--accent: #3fb950;
|
||||
--accent-hover: #34a24a;
|
||||
--accent-text: #4fd067;
|
||||
--accent-soft: rgba(63, 185, 80, 0.12);
|
||||
--btn-fg: #06210d;
|
||||
|
||||
--overlay-1: rgba(255, 255, 255, 0.03);
|
||||
--overlay-2: rgba(255, 255, 255, 0.06);
|
||||
--overlay-3: rgba(255, 255, 255, 0.08);
|
||||
--overlay-row: rgba(255, 255, 255, 0.02);
|
||||
|
||||
--grid-line: rgba(255, 255, 255, 0.03);
|
||||
--grid-line-soft: rgba(255, 255, 255, 0.022);
|
||||
--nav-bg: rgba(11, 14, 18, 0.82);
|
||||
|
||||
--glow-1: rgba(63, 185, 80, 0.15);
|
||||
--glow-2: rgba(63, 185, 80, 0.07);
|
||||
--grain: 0.045;
|
||||
|
||||
--shadow-card: 0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 18px 48px -24px rgba(0, 0, 0, 0.9);
|
||||
--shadow-lift: 0 1px 0 rgba(255, 255, 255, 0.06) inset, 0 28px 64px -28px rgba(0, 0, 0, 1);
|
||||
--shadow-shot: 0 2px 10px rgba(0, 0, 0, 0.5), 0 36px 88px -32px rgba(0, 0, 0, 0.95);
|
||||
|
||||
--display: 'Space Grotesk', 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
--mono: 'JetBrains Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace;
|
||||
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
}
|
||||
|
||||
html[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
--bg: #fafbf9;
|
||||
--bg-alt: #f1f4f0;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #fbfdfa;
|
||||
|
||||
--text: #151a16;
|
||||
--text-2: #59615a;
|
||||
--text-3: #8b948c;
|
||||
|
||||
--border: #e1e6df;
|
||||
--border-strong: #cbd3ca;
|
||||
|
||||
--accent: #1a7f37;
|
||||
--accent-hover: #116329;
|
||||
--radius: 8px;
|
||||
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
|
||||
--accent-hover: #146227;
|
||||
--accent-text: #1a7f37;
|
||||
--accent-soft: rgba(26, 127, 55, 0.09);
|
||||
--btn-fg: #ffffff;
|
||||
|
||||
--overlay-1: rgba(21, 26, 22, 0.03);
|
||||
--overlay-2: rgba(21, 26, 22, 0.05);
|
||||
--overlay-3: rgba(21, 26, 22, 0.06);
|
||||
--overlay-row: rgba(21, 26, 22, 0.02);
|
||||
|
||||
--grid-line: rgba(21, 26, 22, 0.05);
|
||||
--grid-line-soft: rgba(21, 26, 22, 0.035);
|
||||
--nav-bg: rgba(250, 251, 249, 0.86);
|
||||
|
||||
--glow-1: rgba(26, 127, 55, 0.1);
|
||||
--glow-2: rgba(26, 127, 55, 0.05);
|
||||
--grain: 0.02;
|
||||
|
||||
--shadow-card: 0 1px 2px rgba(23, 32, 24, 0.06), 0 18px 40px -24px rgba(23, 32, 24, 0.22);
|
||||
--shadow-lift: 0 2px 4px rgba(23, 32, 24, 0.08), 0 28px 56px -26px rgba(23, 32, 24, 0.28);
|
||||
--shadow-shot: 0 2px 8px rgba(23, 32, 24, 0.12), 0 32px 72px -32px rgba(23, 32, 24, 0.35);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
@@ -19,28 +142,63 @@
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", "Microsoft YaHei", -apple-system, "PingFang SC", "Noto Sans SC", sans-serif;
|
||||
font-family: 'Segoe UI', 'Microsoft YaHei', -apple-system, 'PingFang SC', 'Noto Sans SC', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
line-height: 1.65;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1120px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
padding: 0 28px;
|
||||
}
|
||||
|
||||
.mono { font-family: var(--mono); font-size: 0.92em; }
|
||||
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: var(--display);
|
||||
font-weight: 700;
|
||||
text-wrap: balance;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
p { text-wrap: pretty; }
|
||||
|
||||
img { max-width: 100%; }
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--accent-text);
|
||||
outline-offset: 3px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--accent);
|
||||
color: var(--btn-fg);
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.skip-link:focus { left: 12px; top: 12px; }
|
||||
|
||||
section[id], .site-nav { scroll-margin-top: 76px; }
|
||||
|
||||
/* ---- nav ---- */
|
||||
.site-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
z-index: 30;
|
||||
background: var(--nav-bg);
|
||||
backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@@ -48,58 +206,128 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 64px;
|
||||
gap: 16px;
|
||||
height: 62px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--display);
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-links { display: flex; gap: 28px; }
|
||||
.brand img { border-radius: 6px; }
|
||||
|
||||
.nav-right { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.nav-links { display: flex; gap: 2px; }
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
font-size: 14.5px;
|
||||
padding: 7px 13px;
|
||||
border-radius: 7px;
|
||||
transition: color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-links a:hover { color: var(--text); }
|
||||
.nav-links a:hover { color: var(--text); background: var(--overlay-2); }
|
||||
.nav-links a.is-current { color: var(--text); background: var(--accent-soft); }
|
||||
|
||||
.theme-toggle {
|
||||
position: relative;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease, transform 0.15s ease;
|
||||
}
|
||||
.theme-toggle:hover { color: var(--accent-text); border-color: var(--accent); transform: translateY(-1px); }
|
||||
.theme-toggle:active { transform: translateY(0) scale(0.97); }
|
||||
|
||||
.theme-toggle svg { position: absolute; transition: opacity 0.25s ease, transform 0.35s ease; }
|
||||
.theme-toggle .icon-sun { opacity: 0; transform: rotate(-60deg) scale(0.7); }
|
||||
.theme-toggle .icon-moon { opacity: 1; transform: none; }
|
||||
|
||||
html[data-theme='light'] .theme-toggle .icon-sun { opacity: 1; transform: none; }
|
||||
html[data-theme='light'] .theme-toggle .icon-moon { opacity: 0; transform: rotate(60deg) scale(0.7); }
|
||||
|
||||
/* ---- hero ---- */
|
||||
.hero {
|
||||
padding: 72px 0 0;
|
||||
background: var(--bg-soft);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 92px 0 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background:
|
||||
radial-gradient(1100px 500px at 80% -15%, var(--glow-1), transparent 62%),
|
||||
radial-gradient(760px 420px at -10% 115%, var(--glow-2), transparent 58%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 72px 72px;
|
||||
mask-image: radial-gradient(900px 620px at 50% 0%, #000 32%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
|
||||
opacity: var(--grain);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr;
|
||||
gap: 48px;
|
||||
grid-template-columns: 1fr 1.12fr;
|
||||
gap: 56px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
font-size: 14px;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 16px;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-text);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 36px;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 650;
|
||||
font-size: clamp(34px, 4.6vw, 54px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.hero h1 .accent { color: var(--accent-text); }
|
||||
|
||||
.hero-sub {
|
||||
margin-top: 20px;
|
||||
margin-top: 22px;
|
||||
max-width: 46ch;
|
||||
color: var(--text-2);
|
||||
font-size: 17px;
|
||||
@@ -108,118 +336,251 @@ body {
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
margin-top: 32px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 10px 22px;
|
||||
padding: 11px 24px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:active { transform: translateY(1px) scale(0.99); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
color: var(--btn-fg);
|
||||
box-shadow: 0 10px 28px -10px rgba(63, 185, 80, 0.5);
|
||||
}
|
||||
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
|
||||
.btn-ghost {
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
background: var(--bg);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--overlay-1);
|
||||
}
|
||||
|
||||
.btn-ghost:hover { border-color: var(--text-2); }
|
||||
.btn-ghost:hover { border-color: var(--accent); background: var(--overlay-2); }
|
||||
|
||||
.hero-note {
|
||||
margin-top: 14px;
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.hero-note .mono { color: var(--text); }
|
||||
|
||||
.hero-shot {
|
||||
min-width: 0;
|
||||
}
|
||||
.hero-shot { min-width: 0; }
|
||||
|
||||
.hero-shot img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(28, 33, 40, 0.10);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-shot);
|
||||
}
|
||||
|
||||
.hero-shot figcaption {
|
||||
margin-top: 10px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spec-strip {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
gap: 40px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
padding: 40px 0 48px;
|
||||
margin-top: 48px;
|
||||
font-size: 14px;
|
||||
color: var(--text-2);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.spec-strip strong { color: var(--text); font-weight: 600; }
|
||||
.spec-strip .mono { font-size: 13px; }
|
||||
|
||||
/* ---- features ---- */
|
||||
.features { padding: 72px 0; }
|
||||
/* ---- shared section chrome ---- */
|
||||
section { position: relative; }
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.section-eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-text);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.features h2, .download h2, .changelog h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
font-size: clamp(26px, 3vw, 34px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.section-sub {
|
||||
margin-top: 10px;
|
||||
margin-top: 14px;
|
||||
color: var(--text-2);
|
||||
max-width: 60ch;
|
||||
max-width: 62ch;
|
||||
font-size: 16.5px;
|
||||
}
|
||||
|
||||
.grid-bg::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--grid-line-soft) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line-soft) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.features > .container { position: relative; z-index: 1; }
|
||||
|
||||
/* ---- capability track (horizontal scroll) ---- */
|
||||
.features { padding: 92px 0 100px; background: var(--bg); }
|
||||
|
||||
.cap-controls { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
.track-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.15s ease;
|
||||
}
|
||||
.track-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent-text); transform: translateY(-1px); }
|
||||
.track-btn:active:not(:disabled) { transform: translateY(0) scale(0.97); }
|
||||
.track-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
|
||||
.cap-track {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 44px;
|
||||
padding: 4px 28px 22px;
|
||||
margin-left: -28px;
|
||||
margin-right: -28px;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-width: none;
|
||||
cursor: grab;
|
||||
mask-image: linear-gradient(90deg, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
|
||||
}
|
||||
.cap-track::-webkit-scrollbar { display: none; }
|
||||
.cap-track.is-dragging { cursor: grabbing; scroll-snap-type: none; }
|
||||
|
||||
.cap-card {
|
||||
flex: 0 0 clamp(292px, 30vw, 366px);
|
||||
scroll-snap-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 26px 24px 28px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: border-color 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
|
||||
}
|
||||
|
||||
.cap-card:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-2);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lift);
|
||||
}
|
||||
|
||||
.cap-num {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-text);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.cap-card h3 { font-size: 19px; letter-spacing: -0.01em; }
|
||||
|
||||
.cap-card ul { list-style: none; display: grid; gap: 9px; margin-top: 2px; }
|
||||
|
||||
.cap-card li {
|
||||
position: relative;
|
||||
padding-left: 16px;
|
||||
font-size: 14.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.cap-card li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.66em;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 1px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.cap-hint {
|
||||
margin-top: 18px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ---- media rows (screenshot + copy) ---- */
|
||||
.feature-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 64px;
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.media-row {
|
||||
grid-template-columns: 7fr 5fr;
|
||||
gap: 60px;
|
||||
align-items: center;
|
||||
gap: 56px;
|
||||
margin-top: 84px;
|
||||
}
|
||||
.feature-row:first-of-type { margin-top: 76px; }
|
||||
|
||||
.feature-copy { min-width: 0; }
|
||||
|
||||
.feature-copy h3 { font-size: 21px; letter-spacing: -0.015em; margin-bottom: 14px; }
|
||||
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-text);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feature-copy h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.feature-copy ul {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.feature-copy ul { list-style: none; display: grid; gap: 10px; }
|
||||
|
||||
.feature-copy li {
|
||||
padding-left: 18px;
|
||||
position: relative;
|
||||
padding-left: 18px;
|
||||
color: var(--text-2);
|
||||
font-size: 15px;
|
||||
}
|
||||
@@ -229,24 +590,38 @@ body {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.62em;
|
||||
width: 6px;
|
||||
height: 2px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 1px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.feature-shot { min-width: 0; }
|
||||
|
||||
.feature-shot img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 6px 18px rgba(28, 33, 40, 0.08);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-shot);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.feature-shot img + img { margin-top: 16px; }
|
||||
.feature-shot img.narrow { width: 78%; }
|
||||
|
||||
.feature-shot figcaption {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- download ---- */
|
||||
.download {
|
||||
padding: 72px 0;
|
||||
background: var(--bg-soft);
|
||||
padding: 92px 0 100px;
|
||||
background: var(--bg-alt);
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@@ -254,84 +629,94 @@ body {
|
||||
.dl-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 40px;
|
||||
background: var(--bg);
|
||||
margin-top: 44px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
.dl-table th, .dl-table td {
|
||||
text-align: left;
|
||||
padding: 14px 18px;
|
||||
padding: 15px 20px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.dl-table thead th {
|
||||
background: var(--bg-soft);
|
||||
background: var(--accent-soft);
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
font-weight: 500;
|
||||
font-size: 12.5px;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.dl-table tbody td { color: var(--text-2); }
|
||||
.dl-table tbody tr + tr td { border-top: 1px solid var(--border); }
|
||||
.dl-table tbody tr:hover td { background: var(--overlay-row); }
|
||||
|
||||
.dl-table a { color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||
.dl-table a:hover { text-decoration: underline; }
|
||||
.dl-table a {
|
||||
color: var(--accent-text);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
.dl-table a:hover { border-bottom-color: currentColor; }
|
||||
|
||||
.dl-table .sep { color: var(--border); margin: 0 8px; }
|
||||
.dl-table .sep { color: var(--border-strong); margin: 0 8px; }
|
||||
.dl-table .mono { color: var(--text); }
|
||||
|
||||
.update-note { margin-top: 40px; max-width: 72ch; }
|
||||
.update-note { margin-top: 44px; max-width: 74ch; }
|
||||
|
||||
.update-note h3 { font-size: 16px; margin-bottom: 8px; }
|
||||
|
||||
.update-note p { color: var(--text-2); font-size: 14.5px; }
|
||||
|
||||
/* ---- changelog ---- */
|
||||
.changelog { padding: 72px 0; }
|
||||
.changelog { padding: 92px 0 100px; background: var(--bg); }
|
||||
|
||||
.changelog-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 48px;
|
||||
margin-top: 40px;
|
||||
gap: 40px;
|
||||
margin-top: 44px;
|
||||
}
|
||||
|
||||
.changelog-list article {
|
||||
border-top: 2px solid var(--border);
|
||||
padding-top: 18px;
|
||||
transition: border-color 0.25s ease;
|
||||
}
|
||||
.changelog-list article:hover { border-top-color: var(--accent); }
|
||||
|
||||
.changelog-list article h3 {
|
||||
font-size: 15px;
|
||||
font-family: var(--mono);
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.changelog-list article h3 .mono {
|
||||
color: var(--text);
|
||||
margin-right: 8px;
|
||||
}
|
||||
.changelog-list .rel-date { color: var(--text-3); font-weight: 400; font-size: 12.5px; }
|
||||
|
||||
.changelog-list ul {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.changelog-list ul { list-style: none; display: grid; gap: 8px; }
|
||||
|
||||
.changelog-list li {
|
||||
font-size: 14px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.changelog-list li { font-size: 14px; color: var(--text-2); }
|
||||
|
||||
.changelog-more {
|
||||
margin-top: 40px;
|
||||
font-size: 14px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.changelog-more { margin-top: 44px; font-size: 14px; color: var(--text-2); }
|
||||
|
||||
.changelog-more a { color: var(--accent); }
|
||||
.changelog-more a { color: var(--accent-text); font-weight: 500; text-decoration: none; }
|
||||
.changelog-more a:hover { border-bottom: 1px solid currentColor; }
|
||||
|
||||
/* ---- footer ---- */
|
||||
.site-footer {
|
||||
background: var(--bg-alt);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 28px 0;
|
||||
padding: 34px 0;
|
||||
font-size: 13.5px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
@@ -341,24 +726,47 @@ body {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-inner .mono { font-size: 12.5px; color: var(--text-3); }
|
||||
|
||||
/* ---- scroll reveal ---- */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.reveal.is-in { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* ---- responsive ---- */
|
||||
@media (max-width: 960px) {
|
||||
@media (max-width: 1024px) {
|
||||
.feature-row { grid-template-columns: 1fr; gap: 36px; }
|
||||
.feature-row .feature-shot { order: -1; }
|
||||
.feature-shot img.narrow { width: 100%; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-grid { grid-template-columns: 1fr; gap: 40px; }
|
||||
.hero { padding-top: 48px; }
|
||||
.feature-row, .media-row { grid-template-columns: 1fr; gap: 32px; }
|
||||
.media-row .feature-shot { order: -1; }
|
||||
.changelog-list { grid-template-columns: 1fr; gap: 32px; }
|
||||
.hero { padding-top: 60px; }
|
||||
.section-head { flex-direction: column; align-items: flex-start; gap: 20px; }
|
||||
.changelog-list { grid-template-columns: 1fr; gap: 28px; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.nav-links { gap: 16px; }
|
||||
.nav-links a { font-size: 14px; }
|
||||
.container { padding: 0 20px; }
|
||||
.nav-inner { height: 56px; }
|
||||
.nav-links { gap: 0; }
|
||||
.nav-links a { font-size: 13.5px; padding: 6px 8px; }
|
||||
.hero h1 { font-size: 30px; }
|
||||
.spec-strip { gap: 16px 24px; }
|
||||
.cap-track { padding: 4px 20px 20px; margin-left: -20px; margin-right: -20px; }
|
||||
.dl-table { display: block; overflow-x: auto; white-space: nowrap; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after { transition: none !important; }
|
||||
}
|
||||
@@ -6,111 +6,277 @@
|
||||
<title>OpenTerminal - 本地终端 / SSH / SFTP 桌面终端,开源 Windows 终端工具</title>
|
||||
<meta name="description" content="OpenTerminal 是开源的 Windows 桌面终端:本地终端、SSH 远程会话、SFTP 文件管理、分屏与广播输入、主题定制。MIT 协议,支持自动更新。" />
|
||||
<link rel="icon" type="image/png" href="assets/icon.png" />
|
||||
<link rel="preload" href="assets/fonts/space-grotesk-600.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="assets/fonts/jetbrains-mono-500.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<meta property="og:title" content="OpenTerminal - 本地终端 / SSH / SFTP 桌面终端" />
|
||||
<meta property="og:description" content="开源的 Windows 桌面终端:本地终端、SSH 远程会话、SFTP 文件管理、分屏与广播输入。MIT 协议,支持自动更新。" />
|
||||
<meta property="og:image" content="assets/shot-terminal.jpg" />
|
||||
<meta name="theme-color" content="#0b0e12" />
|
||||
<link rel="stylesheet" href="assets/style.css" />
|
||||
<script>
|
||||
// 主题必须在首帧前定好,避免闪白/闪黑;默认深色,只有用户显式切过才用保存值
|
||||
(function () {
|
||||
var theme = 'dark';
|
||||
try {
|
||||
if (localStorage.getItem('openterminal.theme') === 'light') theme = 'light';
|
||||
} catch (e) {}
|
||||
document.documentElement.dataset.theme = theme;
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||
|
||||
<header class="site-nav">
|
||||
<div class="container nav-inner">
|
||||
<a class="brand" href="#top">
|
||||
<img src="assets/icon.png" alt="OpenTerminal 图标" width="24" height="24" />
|
||||
<span>OpenTerminal</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<div class="nav-right">
|
||||
<nav class="nav-links" aria-label="站内导航">
|
||||
<a href="#features">功能</a>
|
||||
<a href="#download">下载</a>
|
||||
<a href="#changelog">更新日志</a>
|
||||
<a href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener">GitHub</a>
|
||||
</nav>
|
||||
<button class="theme-toggle" id="theme-toggle" type="button" aria-label="切换明暗主题" title="切换明暗主题">
|
||||
<svg class="icon-moon" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
||||
</svg>
|
||||
<svg class="icon-sun" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<main id="main">
|
||||
|
||||
<section class="hero">
|
||||
<section class="hero" id="top">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-copy">
|
||||
<p class="hero-kicker">开源桌面终端 · MIT 协议</p>
|
||||
<h1>本地终端、SSH 与 SFTP<br />装进同一个桌面应用</h1>
|
||||
<h1>本地终端、SSH 与 SFTP<br />装进<span class="accent">同一个</span>桌面应用</h1>
|
||||
<p class="hero-sub">
|
||||
OpenTerminal 面向开发和运维场景:WebGL 加速的终端渲染、可分屏的 SSH 会话、
|
||||
内置 SFTP 文件管理与广播输入。安装即用,支持自动更新。
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-primary" id="dl-exe" href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.15/OpenTerminal-1.0.15-setup.exe">
|
||||
<a class="btn btn-primary" id="dl-exe" href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.23/OpenTerminal-1.0.23-setup.exe">
|
||||
下载 Windows 安装包
|
||||
</a>
|
||||
<a class="btn btn-ghost" href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener">
|
||||
查看源码
|
||||
</a>
|
||||
</div>
|
||||
<p class="hero-note">当前版本 v1.0.15 · 提供 exe 与 msi 安装包。</p>
|
||||
<p class="hero-note">当前版本 <span class="mono">v1.0.23</span> · NSIS exe 安装包,支持应用内自动更新。</p>
|
||||
</div>
|
||||
<figure class="hero-shot">
|
||||
<img src="assets/shot-terminal.png" alt="OpenTerminal 主界面:四个分屏终端会话" width="1282" height="801" />
|
||||
<figcaption>四路分屏会话,每路独立连接本地或远程主机</figcaption>
|
||||
<img src="assets/shot-terminal.jpg" alt="OpenTerminal 主界面:三路分屏并行运行 Kimi Code、Claude Code 与 Codex" width="1739" height="870" />
|
||||
<figcaption>三路分屏并行运行 AI 编程 Agent,背景图与压暗层可自定义</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="container spec-strip">
|
||||
<span>当前版本 <strong class="mono">1.0.15</strong></span>
|
||||
<span>当前版本 <strong class="mono">v1.0.23</strong></span>
|
||||
<span>Windows 10 / 11 · x64</span>
|
||||
<span>MIT 开源协议</span>
|
||||
<span>exe 支持应用内自动更新</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features" id="features">
|
||||
<section class="features grid-bg" id="features">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="section-eyebrow">Capabilities</p>
|
||||
<h2>核心能力</h2>
|
||||
<p class="section-sub">按使用场景划分的三组功能,全部能力见仓库 README。</p>
|
||||
<p class="section-sub">按使用场景划分的能力组,左右拖动或用箭头浏览;完整清单见仓库 README。</p>
|
||||
</div>
|
||||
<div class="cap-controls">
|
||||
<button class="track-btn" type="button" data-dir="-1" aria-label="向左浏览能力卡片">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6" /></svg>
|
||||
</button>
|
||||
<button class="track-btn" type="button" data-dir="1" aria-label="向右浏览能力卡片">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-row">
|
||||
<div class="feature-copy">
|
||||
<div class="cap-track" id="cap-track" tabindex="0" aria-label="核心能力,可横向滚动">
|
||||
<article class="cap-card">
|
||||
<p class="cap-num">01</p>
|
||||
<h3>终端核心</h3>
|
||||
<ul>
|
||||
<li>node-pty 本地会话,自动探测 PowerShell / zsh / bash</li>
|
||||
<li>xterm.js WebGL 渲染,声明 256 色与真彩能力,异常时自动降级</li>
|
||||
<li>xterm.js WebGL 渲染,声明 256 色与真彩,异常自动降级</li>
|
||||
<li>内嵌搜索(Ctrl+F)、命令历史、粘贴风险确认</li>
|
||||
<li>关键词高亮:内置 11 条规则(错误状态、路径、IP、URL 等),支持自定义正则</li>
|
||||
<li>关键词高亮 22 条内置规则,支持自定义正则与三档强度</li>
|
||||
<li>字体、字重、字距、行高、光标样式实时生效</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="feature-copy">
|
||||
<h3>SSH 与 SFTP</h3>
|
||||
</article>
|
||||
|
||||
<article class="cap-card">
|
||||
<p class="cap-num">02</p>
|
||||
<h3>SSH 会话</h3>
|
||||
<ul>
|
||||
<li>ssh2 实现:密码、私钥(文件或粘贴内容)、SSH Agent 三种认证</li>
|
||||
<li>ssh2 实现:密码、私钥(文件或粘贴内容)、SSH Agent</li>
|
||||
<li>多路分屏与广播输入,一次操作下发到多台主机</li>
|
||||
<li>SFTP 文件面板:上传下载、同名覆盖确认、chmod / chown 权限编辑</li>
|
||||
<li>ZMODEM 传输支持;主机指纹校验与首次连接确认</li>
|
||||
<li>目录记忆:OSC 7 上报工作目录,新会话按上次位置恢复</li>
|
||||
<li>主机指纹校验、首次连接确认、变更告警</li>
|
||||
<li>ZMODEM 传输支持(sz / rz)</li>
|
||||
<li>OSC 7 上报工作目录,新会话按上次位置恢复</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="cap-card">
|
||||
<p class="cap-num">03</p>
|
||||
<h3>SFTP 文件面板</h3>
|
||||
<ul>
|
||||
<li>列表头列分明:名称、大小、修改时间、权限、用户/组</li>
|
||||
<li>点击列头排序;文件名解析为真实用户与组名</li>
|
||||
<li>字体缩放 80%–180%,Ctrl+滚轮或工具栏按钮</li>
|
||||
<li>上传下载、同名覆盖确认、chmod / chown 编辑</li>
|
||||
<li>大目录虚拟滚动,上万条目不卡窗口</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="cap-card">
|
||||
<p class="cap-num">04</p>
|
||||
<h3>工作区与布局</h3>
|
||||
<ul>
|
||||
<li>dockview 分屏:任意拆分、拖拽、合并与浮动</li>
|
||||
<li>布局模板保存与一键套用</li>
|
||||
<li>启动恢复上次会话布局</li>
|
||||
<li>界面缩放 50% 至 150%</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="cap-card">
|
||||
<p class="cap-num">05</p>
|
||||
<h3>主题与外观</h3>
|
||||
<ul>
|
||||
<li>主题编辑器:22 个颜色位逐项调整,实时预览</li>
|
||||
<li>内置 11 套主题,跟随系统明暗自动切换</li>
|
||||
<li>自定义背景图片、不透明度与压暗层</li>
|
||||
<li>标签强调色、高亮配色跟随主题</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="cap-card">
|
||||
<p class="cap-num">06</p>
|
||||
<h3>效率工具</h3>
|
||||
<ul>
|
||||
<li>命令库:保存常用命令片段,跨会话发送到指定终端</li>
|
||||
<li>高亮规则集按主机绑定,不同服务器不同配色</li>
|
||||
<li>规则导入导出、实时预览与命中统计</li>
|
||||
<li>会话快照与恢复、终端标签自动命名</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="cap-card">
|
||||
<p class="cap-num">07</p>
|
||||
<h3>系统监控</h3>
|
||||
<ul>
|
||||
<li>CPU、负载、内存、磁盘、网络实时面板</li>
|
||||
<li>每核占用与磁盘分区明细</li>
|
||||
<li>运行时长与网络速率曲线</li>
|
||||
<li>随会话打开,不额外占用通道</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="cap-card">
|
||||
<p class="cap-num">08</p>
|
||||
<h3>锁屏与安全</h3>
|
||||
<ul>
|
||||
<li>Ctrl+L 一键锁屏,支持闲置自动锁定与启动即锁</li>
|
||||
<li>密码经 scrypt 加盐哈希存储,不落盘明文</li>
|
||||
<li>输错触发递增冷却;锁定期间禁用菜单与刷新快捷键</li>
|
||||
<li>锁屏只是遮罩:背后会话与传输不中断</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
<p class="cap-hint">← 拖动浏览 · 共 8 组能力</p>
|
||||
|
||||
<div class="feature-row">
|
||||
<figure class="feature-shot reveal">
|
||||
<img src="assets/shot-agents.png" alt="OpenTerminal 多路分屏运行 Kimi Code、Claude Code、Codex 等 AI 编程 Agent" width="1296" height="870" />
|
||||
</figure>
|
||||
<div class="feature-copy reveal">
|
||||
<p class="eyebrow">Workbench</p>
|
||||
<h3>AI Agent 工作台</h3>
|
||||
<ul>
|
||||
<li>多路分屏并行运行 Kimi Code、Claude Code、Codex 等 AI 编程工具</li>
|
||||
<li>为高频刷新的 TUI 界面优化:终端尺寸去抖同步,避免反复重绘</li>
|
||||
<li>中文输入法候选窗紧贴光标,TUI 内输入中文不再漂移</li>
|
||||
<li>关键词高亮自动标出错误、警告、HTTP 状态码与百分比</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-row media-row">
|
||||
<figure class="feature-shot">
|
||||
<img src="assets/shot-themes.png" alt="OpenTerminal 主题设置:主题画廊与背景图片配置" width="1282" height="801" />
|
||||
<div class="feature-row">
|
||||
<figure class="feature-shot reveal">
|
||||
<img src="assets/shot-sftp.png" alt="OpenTerminal SFTP 文件面板:列表头、可排序列、完整修改时间与用户/组" width="1282" height="801" />
|
||||
<img class="narrow" src="assets/shot-sftp-menu.png" alt="OpenTerminal 在文件列表空白处右键弹出同款文件菜单" width="1282" height="801" />
|
||||
<figcaption>列表空白处右键同样是完整文件菜单,行内右键则作用于所选条目</figcaption>
|
||||
</figure>
|
||||
<div class="feature-copy">
|
||||
<div class="feature-copy reveal">
|
||||
<p class="eyebrow">SFTP</p>
|
||||
<h3>远程文件面板</h3>
|
||||
<ul>
|
||||
<li>列表头列分明:名称、大小、修改时间、权限、用户/组,点击列头排序</li>
|
||||
<li>文件名解析为真实用户与组名(root/root、eveuser/eveuser),不再只有数字</li>
|
||||
<li>字体缩放 80%–180%:Ctrl+滚轮或工具栏按钮,行高与字号联动并记忆</li>
|
||||
<li>上传 / 下载、同名覆盖确认、chmod 权限与 chown 属主编辑</li>
|
||||
<li>大目录虚拟滚动,上万条目不卡窗口</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-row">
|
||||
<figure class="feature-shot reveal">
|
||||
<img src="assets/shot-themes.jpg" alt="OpenTerminal 主题设置:背景图片、不透明度、压暗层与主题画廊" width="1739" height="870" />
|
||||
</figure>
|
||||
<div class="feature-copy reveal">
|
||||
<p class="eyebrow">Customize</p>
|
||||
<h3>定制与效率</h3>
|
||||
<ul>
|
||||
<li>主题编辑器:22 个颜色位逐项调整,实时预览;支持自定义背景图片与不透明度</li>
|
||||
<li>内置 13 套主题,跟随系统明暗自动切换</li>
|
||||
<li>主题编辑器:22 个颜色位逐项调整,实时预览;支持自定义背景图片、不透明度与压暗层</li>
|
||||
<li>内置 11 套主题,跟随系统明暗自动切换</li>
|
||||
<li>命令库:保存常用命令片段,跨会话发送到指定终端</li>
|
||||
<li>启动恢复上次会话布局;界面缩放 50% 至 150%</li>
|
||||
<li>四语言界面:简体中文、繁体中文、English、日本語</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="feature-row">
|
||||
<figure class="feature-shot reveal">
|
||||
<img src="assets/shot-lock.png" alt="OpenTerminal 锁屏界面:不透明遮罩与密码解锁" width="1739" height="870" />
|
||||
</figure>
|
||||
<div class="feature-copy reveal">
|
||||
<p class="eyebrow">Security</p>
|
||||
<h3>锁屏与安全</h3>
|
||||
<ul>
|
||||
<li>Ctrl+L 一键锁屏,支持闲置自动锁定与启动即锁</li>
|
||||
<li>锁定期间禁用应用菜单、页面刷新与开发者工具快捷键</li>
|
||||
<li>密码经 scrypt 加盐哈希存储,不落盘明文;输错触发递增冷却</li>
|
||||
<li>锁屏只是遮罩:背后的终端会话与文件传输保持运行不中断</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="download" id="download">
|
||||
<div class="container">
|
||||
<p class="section-eyebrow">Download</p>
|
||||
<h2>下载</h2>
|
||||
<p class="section-sub">
|
||||
从 GitHub Releases 下载;全部历史版本在同一页面。
|
||||
<!-- 发新版本时更新下方两条直链与 hero 的 id=dl-exe 链接 -->
|
||||
<!-- 发新版本时更新下方 exe 直链与 hero 的 id=dl-exe 链接 -->
|
||||
</p>
|
||||
<table class="dl-table">
|
||||
<thead>
|
||||
@@ -118,19 +284,11 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="mono">OpenTerminal-1.0.15-setup.exe</td>
|
||||
<td>常规安装,支持应用内自动更新(推荐)</td>
|
||||
<td class="mono">122 MB</td>
|
||||
<td class="mono">OpenTerminal-1.0.23-setup.exe</td>
|
||||
<td>NSIS 安装包,支持应用内自动更新(推荐)</td>
|
||||
<td class="mono">109 MB</td>
|
||||
<td>
|
||||
<a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.15/OpenTerminal-1.0.15-setup.exe">下载</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="mono">OpenTerminal-1.0.15-setup.msi</td>
|
||||
<td>企业批量部署(MSI 无自动更新,需手动升级)</td>
|
||||
<td class="mono">135 MB</td>
|
||||
<td>
|
||||
<a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.15/OpenTerminal-1.0.15-setup.msi">下载</a>
|
||||
<a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.23/OpenTerminal-1.0.23-setup.exe">下载</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -147,8 +305,9 @@
|
||||
<div class="update-note">
|
||||
<h3>自动更新说明</h3>
|
||||
<p>
|
||||
应用内检查更新优先访问 git.codingplan.site 更新通道,失败时回退 GitHub Releases。
|
||||
应用内检查更新优先访问 GitHub Releases(跟随系统代理),失败时自动回退国内 git.codingplan.site 更新通道(直连)。
|
||||
更新包经 SHA-512 校验后安装。更新说明文字从发布通道的 release-notes.md 读取,与每次发布同步更新。
|
||||
自 v1.0.22 起不再提供 MSI 安装包,统一使用 NSIS exe。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,29 +315,39 @@
|
||||
|
||||
<section class="changelog" id="changelog">
|
||||
<div class="container">
|
||||
<p class="section-eyebrow">Changelog</p>
|
||||
<h2>近期更新</h2>
|
||||
<div class="changelog-list">
|
||||
<article>
|
||||
<h3><span class="mono">1.0.15</span> 2026-09-20</h3>
|
||||
<h3><span class="mono">1.0.23</span><span class="rel-date">2026-10-09</span></h3>
|
||||
<ul>
|
||||
<li>新增自定义终端背景图片与图片不透明度设置</li>
|
||||
<li>主题编辑器重名提醒与颜色值校验</li>
|
||||
<li>设置导航选中项文字对比度修复</li>
|
||||
<li>SFTP 文件面板改为真正的表格:六列对齐、表头吸顶、点击列头排序</li>
|
||||
<li>字体可缩放 0.8–1.8 倍(A- / A+ 或 Ctrl+滚轮),行高联动并记忆设置</li>
|
||||
<li>用户 / 组列显示真实名称,不再只有数字 UID / GID</li>
|
||||
<li>列表空白处右键即可唤出文件菜单;修复六列此前未真正按网格对齐的问题</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h3><span class="mono">1.0.14</span> 2026-09-20</h3>
|
||||
<h3><span class="mono">1.0.22</span><span class="rel-date">2026-10-08</span></h3>
|
||||
<ul>
|
||||
<li>修复大文件(约 250KB 以上)上传损坏与 SFTP 句柄泄漏</li>
|
||||
<li>SSH 中文乱码、命令历史错乱等终端修复</li>
|
||||
<li>权限编辑、连接错误提示等 20 余项修复</li>
|
||||
<li>修复中文输入法候选窗不跟随光标(v1.0.21 回归)</li>
|
||||
<li>不再提供 MSI 安装包,统一使用 NSIS exe</li>
|
||||
<li>打包流程新增依赖一致性校验,防止旧依赖混入安装包</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h3><span class="mono">1.0.13</span> 2026-09-20</h3>
|
||||
<h3><span class="mono">1.0.21</span><span class="rel-date">2026-10-07</span></h3>
|
||||
<ul>
|
||||
<li>命令片段库与广播输入改进</li>
|
||||
<li>监控面板历史曲线实时重绘</li>
|
||||
<li>背景图体验:任意主题下调透明度均为压暗、文字自动提亮到 WCAG AA、新增背景压暗滑杆</li>
|
||||
<li>性能:设置热更不再触发全窗格重排,SFTP 大目录虚拟滚动,面板独立错误边界</li>
|
||||
<li>安装包精简约 90MB;SFTP/ZMODEM 本地路径准入与多处安全加固</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h3><span class="mono">1.0.20</span><span class="rel-date">2026-10-01</span></h3>
|
||||
<ul>
|
||||
<li>修复中文输入法候选窗不跟随光标</li>
|
||||
<li>锁屏加固:锁定期间移除应用菜单,Ctrl+L 按键判定重构并补测试</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
@@ -197,5 +366,116 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// 滚动进场
|
||||
var revealables = document.querySelectorAll('.reveal');
|
||||
if (reduce || !('IntersectionObserver' in window)) {
|
||||
revealables.forEach(function (el) { el.classList.add('is-in'); });
|
||||
} else {
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) {
|
||||
if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
|
||||
});
|
||||
}, { rootMargin: '0px 0px -8% 0px', threshold: 0.05 });
|
||||
revealables.forEach(function (el) { io.observe(el); });
|
||||
}
|
||||
|
||||
// 导航当前位置
|
||||
var links = Array.prototype.slice.call(document.querySelectorAll('.nav-links a[href^="#"]'));
|
||||
var sections = links
|
||||
.map(function (a) { return document.querySelector(a.getAttribute('href')); })
|
||||
.filter(Boolean);
|
||||
function syncNav() {
|
||||
var pos = scrollY + 130;
|
||||
var current = null;
|
||||
sections.forEach(function (s) { if (s.offsetTop <= pos) current = s; });
|
||||
links.forEach(function (a) {
|
||||
a.classList.toggle('is-current', !!current && a.getAttribute('href') === '#' + current.id);
|
||||
});
|
||||
}
|
||||
addEventListener('scroll', syncNav, { passive: true });
|
||||
syncNav();
|
||||
|
||||
// 能力卡片横向轨道:箭头翻页 + 按住拖动
|
||||
var track = document.getElementById('cap-track');
|
||||
if (!track) return;
|
||||
var buttons = document.querySelectorAll('.track-btn');
|
||||
|
||||
function step() {
|
||||
var card = track.querySelector('.cap-card');
|
||||
return card ? card.getBoundingClientRect().width + 20 : track.clientWidth * 0.8;
|
||||
}
|
||||
function syncButtons() {
|
||||
var max = track.scrollWidth - track.clientWidth - 1;
|
||||
buttons.forEach(function (b) {
|
||||
var dir = Number(b.getAttribute('data-dir'));
|
||||
b.disabled = dir < 0 ? track.scrollLeft <= 0 : track.scrollLeft >= max;
|
||||
});
|
||||
}
|
||||
buttons.forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
track.scrollBy({ left: Number(b.getAttribute('data-dir')) * step(), behavior: reduce ? 'auto' : 'smooth' });
|
||||
});
|
||||
});
|
||||
track.addEventListener('scroll', syncButtons, { passive: true });
|
||||
addEventListener('resize', syncButtons);
|
||||
syncButtons();
|
||||
|
||||
var dragging = false;
|
||||
var startX = 0;
|
||||
var startScroll = 0;
|
||||
track.addEventListener('pointerdown', function (e) {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
dragging = true;
|
||||
startX = e.clientX;
|
||||
startScroll = track.scrollLeft;
|
||||
track.classList.add('is-dragging');
|
||||
track.setPointerCapture(e.pointerId);
|
||||
});
|
||||
track.addEventListener('pointermove', function (e) {
|
||||
if (!dragging) return;
|
||||
var delta = e.clientX - startX;
|
||||
if (Math.abs(delta) > 3) e.preventDefault();
|
||||
track.scrollLeft = startScroll - delta;
|
||||
});
|
||||
function endDrag(e) {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
track.classList.remove('is-dragging');
|
||||
if (e && e.pointerId !== undefined && track.hasPointerCapture(e.pointerId)) {
|
||||
track.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
syncButtons();
|
||||
}
|
||||
track.addEventListener('pointerup', endDrag);
|
||||
track.addEventListener('pointercancel', endDrag);
|
||||
track.addEventListener('dragstart', function (e) { e.preventDefault(); });
|
||||
track.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'ArrowRight') { track.scrollBy({ left: step(), behavior: 'smooth' }); e.preventDefault(); }
|
||||
if (e.key === 'ArrowLeft') { track.scrollBy({ left: -step(), behavior: 'smooth' }); e.preventDefault(); }
|
||||
});
|
||||
|
||||
// 明暗主题切换
|
||||
var toggle = document.getElementById('theme-toggle');
|
||||
var meta = document.querySelector('meta[name="theme-color"]');
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
if (meta) meta.setAttribute('content', theme === 'light' ? '#fafbf9' : '#0b0e12');
|
||||
if (toggle) toggle.setAttribute('aria-label', theme === 'light' ? '切换到深色主题' : '切换到浅色主题');
|
||||
}
|
||||
if (toggle) {
|
||||
toggle.addEventListener('click', function () {
|
||||
var next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
|
||||
applyTheme(next);
|
||||
try { localStorage.setItem('openterminal.theme', next); } catch (e) {}
|
||||
});
|
||||
}
|
||||
applyTheme(document.documentElement.dataset.theme === 'light' ? 'light' : 'dark');
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||