Compare commits

...
9 Commits
Author SHA1 Message Date
Bill 06cfc61392 chore: 对齐 package-lock 根版本号到 1.0.23
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-09 08:47:07 +08:00
Bill e9e0f7592f website: v1.0.23 — SFTP 列表改造、字体缩放与空白处右键菜单
Deploy website to GitHub Pages / deploy (push) Waiting to run
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-09 08:38:32 +08:00
Bill 3ab4b50afb chore(release): v1.0.23
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-09 08:35:12 +08:00
Bill 6e7c9d5376 feat(sftp): 文件面板列头/排序、字体缩放与空白处右键菜单
- 列表头改为 sticky 并支持点击排序(名称/大小/修改时间/权限/用户/组,目录恒在前)
- 时间显示改为 YYYY/M/D HH:MM,大小显示改为 30.7 KB 风格
- 新增字体缩放 0.8–1.8(A-/A+ 与 Ctrl+滚轮),行高字号联动并记忆到 localStorage
- 行高/字号统一由 FilePanel.tsx 计算,经 CSS 变量下发,sftp.css 只消费变量
- 主进程新增 readdirDetailed + parseLongnameOwner,从 OpenSSH longname 解析真实的用户/组
- 列表空白处右键弹出菜单(行内右键仍走原菜单,两者不叠加)
- 修复:antd 把 ant-dropdown-trigger 克隆到行元素上,旧规则以更高特异性顶掉了 .sftp-row 的 display:grid,导致列此前并未按网格对齐
- 4 语言词典补充列名与缩放键
2026-10-09 08:33:06 +08:00
Bill 5602949a4f website: dark-first redesign + SFTP 面板截图
CI / typecheck + test + build (windows) (push) Canceled after 0s
Deploy website to GitHub Pages / deploy (push) Canceled after 0s
- 全站深色(#0b0e12 + 终端绿),右上角可切浅色;两套主题共用 CSS 令牌
- 自托管字体:Space Grotesk(展示)+ JetBrains Mono(等宽),不再依赖系统栈
- 「核心能力」改为横向滚动卡片轨道(箭头翻页 / 拖拽 / 键盘方向键)
- 新增 SFTP 文件面板截图(含空白处右键菜单),IP 已打码
- 补 og:image / theme-color / skip-link / 焦点环 / 导航当前位置高亮
2026-10-09 00:46:35 +08:00
Bill 9e5e959f80 docs(readme): add screenshots & advantages section; fix stale facts (11 themes, 19 tests, MSI dropped)
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-08 14:19:22 +08:00
Bill 309de439e8 website: v1.0.22 — drop MSI, refresh screenshots (agents/themes/lock)
CI / typecheck + test + build (windows) (push) Canceled after 0s
Deploy website to GitHub Pages / deploy (push) Canceled after 0s
- download: NSIS exe only, v1.0.22 links, update-source order GitHub-first
- features: 22 highlight rules, 11 builtin themes, bg dim layer
- new rows: AI Agent workspace, lock screen & security
- changelog: 1.0.22 / 1.0.21 / 1.0.20
2026-10-08 14:10:15 +08:00
Bill a381d7be03 docs: warn about auto-created wrong-target tags in release flow
CI / typecheck + test + build (windows) (push) Canceled after 0s
2026-10-08 13:31:54 +08:00
Bill 14f5963706 build: drop MSI installer, NSIS exe only
CI / typecheck + test + build (windows) (push) Canceled after 0s
electron-updater never supported MSI auto-updates; the exe is the only
installer from v1.0.22 on. release.cjs no longer expects/uploads the msi.
2026-10-08 13:25:01 +08:00
36 changed files with 1381 additions and 284 deletions

No files matched your search

+5 -5
View File
@@ -10,7 +10,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 测试:`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,然后依次跑可离线运行的 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_* 凭据,不在此列)
- 依赖分类规则:**只有 `src/main/`/`src/preload/` 实际 import 的包才能进 `dependencies`**(node-pty/ssh2/zmodem.js/font-list/electron-updater);纯渲染层依赖一律 devDependencies(Vite 全量打包进 out/renderer,`externalizeDepsPlugin` 不作用渲染层)——这条让 asar 从 98MB 瘦到 8.1MB,加新依赖时别放错边 - 依赖分类规则:**只有 `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;更新通道无计数接口不计入) - 下载量统计:`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`,即类型检查 + 18 个离线测试全部通过后才 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`,只做验证,不打包安装器、不发布 - 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` - 国内网络需镜像:`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,打进去的是旧代码) - 依赖一致性闸门:`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,打进去的是旧代码)
@@ -34,20 +34,20 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
2. `node scripts/sync-changelog.cjs` 把发布说明并入 `CHANGELOG*.md` —— **必须在 `npm run dist` 之前**:更新日志以 `?raw` 打进安装包(「关于」页离线读),`release.cjs` 也校验 `CHANGELOG.md` 已含该版本号 2. `node scripts/sync-changelog.cjs` 把发布说明并入 `CHANGELOG*.md` —— **必须在 `npm run dist` 之前**:更新日志以 `?raw` 打进安装包(「关于」页离线读),`release.cjs` 也校验 `CHANGELOG.md` 已含该版本号
3. `npm run dist` 构建 3. `npm run dist` 构建
4. `node scripts/release.cjs <版本号>`,例如 `node scripts/release.cjs 1.0.14`(**不带 skip 参数**,Gitea 与 GitHub 一起发) 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 资产 - GitHub:release 资产再次随版本同步发布(exe + exe.blockmap + latest.yml),electron-updater 标准 GitHub provider 直接吃 release 资产
- 通道不再先删旧版:新版本文件全部传完、latest.yml 生效后才清掉上一版 exe/blockmap,中途失败不会把通道打空;同一版本可重复运行(release 复用、已传资产跳过) - 通道不再先删旧版:新版本文件全部传完、latest.yml 生效后才清掉上一版 exe/blockmap,中途失败不会把通道打空;同一版本可重复运行(release 复用、已传资产跳过)
- 上传前先比通道版本:`assertNoDowngrade()` 读通道 latest.yml,若线上版本**高于**待发布版本就直接拒绝——三条本地护栏只比本地产物,旧分支发旧版本号会一路通过,覆盖 latest.yml 之后 `pruneChannel` 会把线上新版本的 exe/blockmap 删掉 - 上传前先比通道版本:`assertNoDowngrade()` 读通道 latest.yml,若线上版本**高于**待发布版本就直接拒绝——三条本地护栏只比本地产物,旧分支发旧版本号会一路通过,覆盖 latest.yml 之后 `pruneChannel` 会把线上新版本的 exe/blockmap 删掉
- 只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub) - 只补通道:`node scripts/release.cjs <版本号> --channel-only`(不建 release、不发 GitHub)
- 国内通道全程直连,**不需要设代理**;GitHub 请求走 `HTTPS_PROXY=http://127.0.0.1:7897`(脚本只把它用于 GitHub 请求) - 国内通道全程直连,**不需要设代理**;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` 应返回新版本号 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` 里按源切换 - 检查更新:**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 - 更新日志: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——它同样被超时兜住,最终落到错误态,不会永远停在「检查中」 - 每次 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`):卡住的通道必须让位给下一个来源,不能把「关于」页吊住 - 更新日志 / releases API 的 fetch 都带 `AbortSignal.timeout(15s)`(`directFetch` 与 `fetchChangelog` 里的 `net.fetch`):卡住的通道必须让位给下一个来源,不能把「关于」页吊住
@@ -66,7 +66,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- `TerminalView` 用 **5 组 `useShallow` 字段级订阅**(fontOpts/themeOpts/bgOpts/highlightOpts/toolbar + rendererMode),**不要再回到整对象订阅**——主进程 `mutateSettings` 回推整个 settings 对象,任何写入都会变身份,整对象订阅会让所有 pane 重写 options + refit。`useResolvedTheme` 也是 shallow + memo 后的稳定引用 - `TerminalView` 用 **5 组 `useShallow` 字段级订阅**(fontOpts/themeOpts/bgOpts/highlightOpts/toolbar + rendererMode),**不要再回到整对象订阅**——主进程 `mutateSettings` 回推整个 settings 对象,任何写入都会变身份,整对象订阅会让所有 pane 重写 options + refit。`useResolvedTheme` 也是 shallow + memo 后的稳定引用
- `TerminalView` **已无 imperative handle**(`TerminalHandle`/forwardRef 全删,无人传 ref);要加「外部聚焦/清屏」需重新引入 - `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 崩溃只卸载自己,根部边界仍兜底 - 每个 dockview pane 内有 `PanelErrorBoundary`(`ErrorBoundary.tsx`):pane 崩溃只卸载自己,根部边界仍兜底
- **本地路径准入**(`src/main/localPathGrants.ts`):SFTP 上传/下载与 zmodem 收发的本地路径必须来自系统对话框授权(pickFiles/pickDirectory 是唯一授权源,进程级内存注册表、不落盘),realpath+stat 双重校验、Windows 大小写折叠、解析后的路径才是要打开的路径。新增「渲染层构造本地路径传给主进程」的调用点时**必须过这道门**,别绕 - **本地路径准入**(`src/main/localPathGrants.ts`):SFTP 上传/下载与 zmodem 收发的本地路径必须来自系统对话框授权(pickFiles/pickDirectory 是唯一授权源,进程级内存注册表、不落盘),realpath+stat 双重校验、Windows 大小写折叠、解析后的路径才是要打开的路径。新增「渲染层构造本地路径传给主进程」的调用点时**必须过这道门**,别绕
- `keyPath`(SSH 私钥):realpath → stat → 普通文件且 ≤1MB 才读(防设备文件永久阻塞 UI 线程/符号链接逃逸) - `keyPath`(SSH 私钥):realpath → stat → 普通文件且 ≤1MB 才读(防设备文件永久阻塞 UI 线程/符号链接逃逸)
+16
View File
@@ -1,10 +1,26 @@
# OpenTerminal Changelog # OpenTerminal Changelog
## 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 ## v1.0.22 - 2026-10-08
### IME ### 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. - **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 ### 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. - 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.
+16
View File
@@ -1,10 +1,26 @@
# OpenTerminal 更新履歴 # OpenTerminal 更新履歴
## 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 ## v1.0.22 - 2026-10-08
### IME ### IME
- **中国語 IME の候補ウィンドウがキャレットに追従しない問題を修正**(v1.0.21 でのリグレッション):v1.0.21 のビルド環境では端末コンポーネントの依存がロックされたバージョンと一致しておらず、インストーラーには上流の IME 修正を含まない古いビルドが同梱されていました。本バージョンでは v1.0.20 と同じ端末コンポーネントに戻し、候補ウィンドウが再びキャレットに追従します。 - **中国語 IME の候補ウィンドウがキャレットに追従しない問題を修正**(v1.0.21 でのリグレッション):v1.0.21 のビルド環境では端末コンポーネントの依存がロックされたバージョンと一致しておらず、インストーラーには上流の IME 修正を含まない古いビルドが同梱されていました。本バージョンでは v1.0.20 と同じ端末コンポーネントに戻し、候補ウィンドウが再びキャレットに追従します。
### インストーラー
- MSI インストーラーの提供を終了しました(electron-updater は MSI の自動更新に非対応のため)。今後は NSIS exe インストーラーに統一されます。
### 内部 ### 内部
- パッケージング前に依存関係の整合性チェックを追加:インストール済み依存とロックファイルを照合し、不一致があればビルドを中止します。同種の問題の再発を防ぎます。 - パッケージング前に依存関係の整合性チェックを追加:インストール済み依存とロックファイルを照合し、不一致があればビルドを中止します。同種の問題の再発を防ぎます。
+16
View File
@@ -1,10 +1,26 @@
# OpenTerminal 更新日志 # OpenTerminal 更新日志
## 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.22 - 2026-10-08
### 输入法 ### 输入法
- **修复中文输入法候选窗不跟随光标**(v1.0.21 回归):v1.0.21 的构建环境中终端组件依赖未按锁定版本安装,安装包实际打进的是不含上游输入法修复的旧版本;本版本恢复 v1.0.20 所用的终端组件版本,候选窗重新紧贴光标。 - **修复中文输入法候选窗不跟随光标**(v1.0.21 回归):v1.0.21 的构建环境中终端组件依赖未按锁定版本安装,安装包实际打进的是不含上游输入法修复的旧版本;本版本恢复 v1.0.20 所用的终端组件版本,候选窗重新紧贴光标。
### 安装包
- 不再提供 MSI 安装包(electron-updater 本就不支持 MSI 自动更新),统一使用 NSIS exe 安装包。
### 内部 ### 内部
- 打包流程新增依赖一致性校验:构建前自动比对实际安装的依赖与锁定清单,不一致即中止,防止同类问题再次发生。 - 打包流程新增依赖一致性校验:构建前自动比对实际安装的依赖与锁定清单,不一致即中止,防止同类问题再次发生。
+16
View File
@@ -1,10 +1,26 @@
# OpenTerminal 更新日誌 # OpenTerminal 更新日誌
## 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.22 - 2026-10-08
### 輸入法 ### 輸入法
- **修復中文輸入法候選視窗不跟隨游標**(v1.0.21 回歸):v1.0.21 的建置環境中終端機元件相依未按鎖定版本安裝,安裝包實際打包的是不含上游輸入法修復的舊版本;本版本恢復 v1.0.20 所用的終端機元件版本,候選視窗重新緊貼游標。 - **修復中文輸入法候選視窗不跟隨游標**(v1.0.21 回歸):v1.0.21 的建置環境中終端機元件相依未按鎖定版本安裝,安裝包實際打包的是不含上游輸入法修復的舊版本;本版本恢復 v1.0.20 所用的終端機元件版本,候選視窗重新緊貼游標。
### 安裝包
- 不再提供 MSI 安裝包(electron-updater 本就不支援 MSI 自動更新),統一使用 NSIS exe 安裝包。
### 內部 ### 內部
- 打包流程新增相依一致性校驗:建置前自動比對實際安裝的相依與鎖定清單,不一致即中止,防止同類問題再次發生。 - 打包流程新增相依一致性校驗:建置前自動比對實際安裝的相依與鎖定清單,不一致即中止,防止同類問題再次發生。
+30 -4
View File
@@ -4,6 +4,25 @@
> 产品官网(GitHub Pages):<https://billowliu2.github.io/OpenTerminal/>,源码在 [`website/`](website/)。 > 产品官网(GitHub Pages):<https://billowliu2.github.io/OpenTerminal/>,源码在 [`website/`](website/)。
## 截图
| 多路分屏并行 AI 编程 Agent | 无背景图的干净分屏 |
| --- | --- |
| ![主界面:三路分屏并行运行 AI 编程 Agent,自定义背景图](website/assets/shot-terminal.jpg) | ![多路分屏运行 Kimi Code / Claude Code / Codex](website/assets/shot-agents.png) |
| 主题与个性化(背景图 / 不透明度 / 压暗层) | 锁屏(Ctrl+L,遮罩下会话不断线) |
| --- | --- |
| ![主题设置:背景图片、不透明度、压暗层与主题画廊](website/assets/shot-themes.jpg) | ![锁屏界面:不透明遮罩与密码解锁](website/assets/shot-lock.png) |
## 优势
- **轻量**:渲染层依赖全量打包进 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 ```bash
@@ -11,7 +30,7 @@ npm install # 已安装可跳过
npm run dev # 开发模式(热更新) npm run dev # 开发模式(热更新)
npm run build # 生产构建到 out/ npm run build # 生产构建到 out/
npm run typecheck # 全量类型检查 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` > 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,并整块摘除应用菜单,防止菜单键绕过遮罩 - 锁定期间吞掉 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 调色板 + 前景/背景/光标/选区色,实时预览 - 自定义主题编辑器:16 色 ANSI 调色板 + 前景/背景/光标/选区色,实时预览
- 窗口标题栏与背景跟随终端主题 - 窗口标题栏与背景跟随终端主题
- 系统托盘:关闭按钮可设「每次询问 / 最小化到托盘 / 直接退出」,托盘菜单可切换 - 系统托盘:关闭按钮可设「每次询问 / 最小化到托盘 / 直接退出」,托盘菜单可切换
- 单实例运行:重复启动唤出已有窗口 - 单实例运行:重复启动唤出已有窗口
- 自动更新:GitHub 优先(走系统代理),失败回退国内 Gitea 更新通道(强制直连);单次检查 30s 整体超时,MSI 仅分发、自动更新走 NSIS 安装包 - 自动更新:GitHub 优先(走系统代理),失败回退国内 Gitea 更新通道(强制直连);单次检查 30s 整体超时;统一使用 NSIS exe 安装包(自 v1.0.22 起不再提供 MSI)
- 设置持久化(userData/settings.json,原子写入)+ 多窗口实时同步 - 设置持久化(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/ssh-loopback.mjs` — ssh2 客户端/服务端回环(认证、shell、数据、resize、指纹)
- `node tests/commands-store.mjs` — 命令库 / 历史 / 会话日志存储 - `node tests/commands-store.mjs` — 命令库 / 历史 / 会话日志存储
- `node tests/connections-store.mjs` — SSH 书签 CRUD、公开/密文字段切分、损坏文件备份 - `node tests/connections-store.mjs` — SSH 书签 CRUD、公开/密文字段切分、损坏文件备份
- `node tests/settings-store.mjs` — 设置清洗器(closeAction/高亮规则修复/旧预设升级/告警日志) - `node tests/settings-store.mjs` — 设置清洗器(closeAction/高亮规则修复/旧预设升级/告警日志)
- `node tests/local-path-grants.mjs` — 本地路径准入(对话框授权、realpath+stat 双重校验、大小写折叠)
- `node tests/lock-store.mjs` — 锁屏密码校验器(scrypt 往返、文件损坏处理) - `node tests/lock-store.mjs` — 锁屏密码校验器(scrypt 往返、文件损坏处理)
- `node tests/lock-controller.mjs` — 锁屏控制器(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动) - `node tests/lock-controller.mjs` — 锁屏控制器(冷却阶梯、并发串行化、落盘恢复、闲置触发、清除联动)
- `node tests/lock-shortcuts.mjs` — 锁屏快捷键分类器(表驱动:Ctrl+L 恐慌锁 / 锁定时禁用的组合键) - `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-split-smoke.cjs` — 关键词高亮流分块回归(bundle 由 `node tests/build-bundles.cjs` 生成)
- `node tests/.hl-rules.cjs` — 内置高亮预设(词边界、大小写、负向词、危险命令) - `node tests/.hl-rules.cjs` — 内置高亮预设(词边界、大小写、负向词、危险命令)
- `node tests/zmodem-e2e.mjs` — ZMODEM 双向传输(与第二个 zmodem.js Sentry 对接,内容一致性) - `node tests/zmodem-e2e.mjs` — ZMODEM 双向传输(与第二个 zmodem.js Sentry 对接,内容一致性)
+2 -13
View File
@@ -16,23 +16,12 @@ asarUnpack:
# @lydell/node-pty ships prebuilt binaries; do not trigger a rebuild. # @lydell/node-pty ships prebuilt binaries; do not trigger a rebuild.
npmRebuild: false npmRebuild: false
# Electron-updater only supports NSIS auto-updates (MSI has no update channel). # NSIS exe is the only installer: electron-updater only supports NSIS
# The NSIS .exe + latest.yml get uploaded together; MSI is manual/enterprise only. # auto-updates, so the MSI (no update channel) was dropped in v1.0.22.
win: win:
target: target:
- target: msi
- target: nsis - 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: nsis:
oneClick: false oneClick: false
allowToChangeInstallationDirectory: true allowToChangeInstallationDirectory: true
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "open-terminal", "name": "open-terminal",
"version": "1.0.22", "version": "1.0.23",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "open-terminal", "name": "open-terminal",
"version": "1.0.22", "version": "1.0.23",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@lydell/node-pty": "^1.2.0-beta.15", "@lydell/node-pty": "^1.2.0-beta.15",
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "open-terminal", "name": "open-terminal",
"productName": "OpenTerminal", "productName": "OpenTerminal",
"version": "1.0.22", "version": "1.0.23",
"description": "Open-source terminal with SSH, split panes, themes and fonts", "description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js", "main": "out/main/index.js",
"author": "CodingPlan.Site", "author": "CodingPlan.Site",
@@ -15,7 +15,7 @@
"pretest": "npm run typecheck", "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", "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": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only", "predist": "node scripts/verify-deps.cjs && npm test && npm install --package-lock-only",
"dist": "electron-vite build && electron-builder --win msi nsis", "dist": "electron-vite build && electron-builder --win nsis",
"dist:dir": "electron-vite build && electron-builder --win --dir" "dist:dir": "electron-vite build && electron-builder --win --dir"
}, },
"dependencies": { "dependencies": {
+5 -7
View File
@@ -42,14 +42,12 @@ const env = Object.fromEntries(
) )
const notes = fs.readFileSync(path.join(ROOT, 'RELEASE_NOTES.md'), 'utf8') const notes = fs.readFileSync(path.join(ROOT, 'RELEASE_NOTES.md'), 'utf8')
const R = path.join(ROOT, 'release') 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 exe = path.join(R, `OpenTerminal-${V}-setup.exe`)
const files = [msi, exe] const files = [exe]
// --channel-only republishes the update channel, which carries the NSIS exe and // Only the NSIS exe is built (the MSI was dropped in v1.0.22 — electron-updater
// its blockmap (electron-updater cannot consume an MSI) — the msi is a release // never supported it). The channel's own file list is validated in
// asset only, so demanding it here would block the repair path this mode exists // giteaChannel().
// for. The channel's own file list is validated in giteaChannel(). for (const f of files) {
for (const f of CHANNEL_ONLY ? [exe] : files) {
if (!fs.existsSync(f)) { console.error('missing build artifact:', f); process.exit(1) } if (!fs.existsSync(f)) { console.error('missing build artifact:', f); process.exit(1) }
} }
// Guard rails: the artifacts must belong to the version being published — // Guard rails: the artifacts must belong to the version being published —
+28 -7
View File
@@ -256,30 +256,51 @@ export function closeSftp(sessionId: string): void {
const FAKE_FS = new Set(['tmpfs', 'overlay', 'udev', 'devtmpfs', 'none', 'squashfs', 'shm']) const FAKE_FS = new Set(['tmpfs', 'overlay', 'udev', 'devtmpfs', 'none', 'squashfs', 'shm'])
/** readdir, both shapes ssh2 can yield (plain names or `{filename}` objects). */ /** readdir, both shapes ssh2 can yield (plain names or `{filename}` objects). */
function readdir(sftp: SFTPWrapper, dir: string): Promise<string[]> { function readdirDetailed(sftp: SFTPWrapper, dir: string): Promise<Array<{ name: string; longname?: string }>> {
return p<Array<string | { filename: string }>>(cb => sftp.readdir(dir, cb)).then(raw => return p<Array<string | { filename: string; longname?: string }>>(cb => sftp.readdir(dir, cb)).then(raw =>
raw.map(n => (typeof n === 'string' ? n : n.filename)) 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[]> { export function listRemote(sessionId: string, dir: string): Promise<SftpEntry[]> {
return withSftp(sessionId, async sftp => { return withSftp(sessionId, async sftp => {
const names = await readdir(sftp, dir) const names = await readdirDetailed(sftp, dir)
const entries: SftpEntry[] = [] const entries: SftpEntry[] = []
const queue = [...names] const queue = [...names]
const base = dir.replace(/\/+$/, '') || '/' const base = dir.replace(/\/+$/, '') || '/'
const workers = Array.from({ length: Math.min(8, Math.max(queue.length, 1)) }, async () => { const workers = Array.from({ length: Math.min(8, Math.max(queue.length, 1)) }, async () => {
for (;;) { for (;;) {
const name = queue.shift() const item = queue.shift()
if (name === undefined) return if (item === undefined) return
const { name, longname } = item
const path = `${base}/${name}` const path = `${base}/${name}`
try { try {
const st = await lstat(sftp, path) const st = await lstat(sftp, path)
entries.push({ entries.push({
name, path, isDir: st.isDirectory(), size: st.size, mtime: st.mtime * 1000, 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 { } catch {
// no longname data here — the namespace listing is all we have
entries.push({ name, path, isDir: false, size: 0, mtime: 0 }) entries.push({ name, path, isDir: false, size: 0, mtime: 0 })
} }
} }
+238 -32
View File
@@ -12,7 +12,9 @@ import {
FormOutlined, FormOutlined,
ReloadOutlined, ReloadOutlined,
SafetyOutlined, SafetyOutlined,
UploadOutlined UploadOutlined,
ZoomInOutlined,
ZoomOutOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import { App, Button, Checkbox, Dropdown, Input, Modal, Popconfirm, Tooltip } from 'antd' import { App, Button, Checkbox, Dropdown, Input, Modal, Popconfirm, Tooltip } from 'antd'
import type { MenuProps } from 'antd' import type { MenuProps } from 'antd'
@@ -93,16 +95,34 @@ function tOr(key: string, fallback: string): string {
return value === key ? fallback : value return value === key ? fallback : value
} }
/** FinalShell-style size: a unit at every magnitude, one decimal from KB up. */
function fmtSize(bytes: number): string { function fmtSize(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}G` if (bytes < 1024) return `${bytes} B`
if (bytes >= 1024) return `${Math.round(bytes / 1024)}K` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return String(bytes) 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 { function fmtTime(ms: number): string {
if (!ms) return '' if (!ms) return ''
const d = new Date(ms) 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. */ /** 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')] 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. */ /** Rows rendered above and below the viewport so a fast scroll never blanks. */
const OVERSCAN = 8 const OVERSCAN = 8
/** /**
@@ -126,15 +150,44 @@ const OVERSCAN = 8
*/ */
const VIRTUALIZE_THRESHOLD = 200 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. */ /** Slice of the entry list to mount for the current scroll position. */
interface WindowSlice { interface WindowSlice {
start: number start: number
end: number end: number
} }
/** First index to render for a given scroll offset and viewport height. */ /** First index to render for a given scroll offset, row height and viewport. */
function windowStart(scrollTop: number): number { function windowStart(scrollTop: number, rowHeight: number): number {
return Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN) return Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN)
} }
interface PermissionModalProps { interface PermissionModalProps {
@@ -357,14 +410,59 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
const [renameOpen, setRenameOpen] = useState(false) const [renameOpen, setRenameOpen] = useState(false)
const [permOpen, setPermOpen] = useState(false) const [permOpen, setPermOpen] = useState(false)
const [inputValue, setInputValue] = useState('') 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 // Windowed list state. `viewportH` is measured off the scroll container and
// `firstVisible` only advances when the scroll offset crosses a row boundary, // `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 listRef = useRef<HTMLDivElement | null>(null)
const [viewportH, setViewportH] = useState(0) const [viewportH, setViewportH] = useState(0)
const [firstVisible, setFirstVisible] = useState(0) const [firstVisible, setFirstVisible] = useState(0)
const virtualized = entries.length > VIRTUALIZE_THRESHOLD const virtualized = entriesSorted.length > VIRTUALIZE_THRESHOLD
useEffect(() => { useEffect(() => {
const el = listRef.current 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. // observer has to be re-attached whenever the container is replaced.
}, [error]) }, [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 // A new directory starts at the top; the stale scroll offset would otherwise
// leave the window slice pointing past the (now shorter) listing. // leave the window slice pointing past the (now shorter) listing.
useEffect(() => { useEffect(() => {
@@ -386,21 +500,38 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
setFirstVisible(0) setFirstVisible(0)
}, [dir]) }, [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 onListScroll = useCallback(() => {
const el = listRef.current const el = listRef.current
if (!el) return if (!el) return
const start = windowStart(el.scrollTop) const start = windowStart(el.scrollTop, rowHeight)
setFirstVisible((prev) => (prev === start ? prev : start)) setFirstVisible((prev) => (prev === start ? prev : start))
}, []) }, [rowHeight])
const slice = useMemo((): WindowSlice => { const slice = useMemo((): WindowSlice => {
const visible = Math.ceil(viewportH / ROW_HEIGHT) + OVERSCAN * 2 const visible = Math.ceil(viewportH / rowHeight) + OVERSCAN * 2
return { start: firstVisible, end: Math.min(entries.length, firstVisible + visible) } return { start: firstVisible, end: Math.min(entriesSorted.length, firstVisible + visible) }
}, [viewportH, firstVisible, entries.length]) }, [viewportH, firstVisible, rowHeight, entriesSorted.length])
/** the entry the context menu is operating on (selected when right-clicked) */ /** the entry the context menu is operating on (selected when right-clicked) */
const menuTarget = menuEntry ?? selected 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. */ /** Monotonic token: only the most recently issued listing may paint state. */
const refreshSeqRef = useRef(0) const refreshSeqRef = useRef(0)
/** Mirrors `dir` for callbacks created before a navigation (upload finish). */ /** Mirrors `dir` for callbacks created before a navigation (upload finish). */
@@ -693,7 +824,7 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [menuTarget, dir, entries, refresh, language]) }, [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 => ( const renderRow = (e: SftpEntry): React.JSX.Element => (
<Dropdown <Dropdown
key={e.path} key={e.path}
@@ -717,22 +848,33 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
<span className="sftp-name" title={e.name}> <span className="sftp-name" title={e.name}>
{e.name} {e.name}
</span> </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`}> <span className="sftp-size" title={e.isDir ? '' : `${e.size} bytes`}>
{e.isDir ? '' : fmtSize(e.size)} {e.isDir ? '' : fmtSize(e.size)}
</span> </span>
<span className="sftp-time">{fmtTime(e.mtime)}</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> </div>
</Dropdown> </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 ( return (
<div className="sftp-panel"> <div className="sftp-panel" style={scaleVars}>
<div className="sftp-toolbar"> <div className="sftp-toolbar">
<Tooltip title={t('ssh.file.up')}> <Tooltip title={t('ssh.file.up')}>
<Button <Button
@@ -798,6 +940,25 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
> >
<Button type="text" size="small" icon={<DeleteOutlined />} disabled={!selected} danger /> <Button type="text" size="small" icon={<DeleteOutlined />} disabled={!selected} danger />
</Popconfirm> </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>
<div className="sftp-breadcrumb"> <div className="sftp-breadcrumb">
@@ -814,22 +975,67 @@ export const FilePanel = memo(function FilePanel({ sessionId }: FilePanelProps):
{error ? ( {error ? (
<div className="sftp-error">{error}</div> <div className="sftp-error">{error}</div>
) : ( ) : (
<div className="sftp-list" ref={listRef} onScroll={onListScroll}> <Dropdown
{loading && entries.length === 0 && <div className="sftp-empty">{t('ssh.file.loading')}</div>} menu={menu}
{!loading && entries.length === 0 && <div className="sftp-empty">{t('ssh.file.empty')}</div>} 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 ? ( {virtualized ? (
<div className="sftp-list-virtual" style={{ height: entries.length * ROW_HEIGHT }}> <div className="sftp-list-virtual" style={{ height: entriesSorted.length * rowHeight }}>
<div <div
className="sftp-list-window" 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>
</div> </div>
) : ( ) : (
entries.map(renderRow) entriesSorted.map(renderRow)
)} )}
</div> </div>
</Dropdown>
)} )}
<PermissionModal <PermissionModal
+88 -21
View File
@@ -16,6 +16,11 @@
border-bottom: 1px solid var(--chrome-border); 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 { .sftp-breadcrumb {
padding: 3px 8px; padding: 3px 8px;
font-size: 12px; font-size: 12px;
@@ -42,6 +47,79 @@
position: relative; 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 /* Windowed list (node_modules-sized directories). Only the visible slice of
rows plus the overscan is mounted; the spacer carries the full scroll height. */ rows plus the overscan is mounted; the spacer carries the full scroll height. */
.sftp-list-virtual { .sftp-list-virtual {
@@ -57,25 +135,14 @@
will-change: transform; 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 { .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. /* Fixed outer height so the windowed list can offset rows arithmetically.
border-box is required — there is no global reset, so content-box would 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 add any padding on top of the height. The height itself is driven by
FilePanel.tsx. */ --sftp-row-h so row geometry has one source of truth (FilePanel.tsx). */
box-sizing: border-box; box-sizing: border-box;
height: 24px; height: var(--sftp-row-h, 26px);
font-size: 13px; font-size: var(--sftp-fs, 13px);
color: var(--chrome-fg); color: var(--chrome-fg);
cursor: pointer; cursor: pointer;
} }
@@ -91,7 +158,7 @@
.sftp-icon { .sftp-icon {
display: inline-flex; display: inline-flex;
color: #8a8a8a; color: #8a8a8a;
font-size: 14px; font-size: calc(14px * var(--sftp-scale, 1));
} }
.sftp-icon-dir { .sftp-icon-dir {
@@ -103,14 +170,14 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 13.5px; font-size: var(--sftp-fs, 13px);
} }
.sftp-mode { .sftp-mode {
text-align: center; text-align: center;
color: #9a9a9a; color: #9a9a9a;
font-family: Consolas, 'Courier New', monospace; font-family: Consolas, 'Courier New', monospace;
font-size: 11px; font-size: var(--sftp-fs-meta, 12px);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
} }
@@ -118,7 +185,7 @@
.sftp-owner { .sftp-owner {
text-align: right; text-align: right;
color: #8a8a8a; color: #8a8a8a;
font-size: 11px; font-size: var(--sftp-fs-meta, 12px);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
} }
@@ -126,7 +193,7 @@
.sftp-size { .sftp-size {
text-align: right; text-align: right;
color: #8a8a8a; color: #8a8a8a;
font-size: 11px; font-size: var(--sftp-fs-meta, 12px);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
} }
@@ -134,7 +201,7 @@
.sftp-time { .sftp-time {
text-align: right; text-align: right;
color: #8a8a8a; color: #8a8a8a;
font-size: 11px; font-size: var(--sftp-fs-meta, 12px);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
} }
+7
View File
@@ -135,6 +135,13 @@ const ssh: Record<string, string> = {
'ssh.file.permColRead': 'Read', 'ssh.file.permColRead': 'Read',
'ssh.file.permColWrite': 'Write', 'ssh.file.permColWrite': 'Write',
'ssh.file.permColExec': 'Execute', '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 // System monitor
'ssh.monitor.collecting': 'Collecting…', 'ssh.monitor.collecting': 'Collecting…',
+7
View File
@@ -137,6 +137,13 @@ const ssh: Record<string, string> = {
'ssh.file.permColRead': '読み取り', 'ssh.file.permColRead': '読み取り',
'ssh.file.permColWrite': '書き込み', 'ssh.file.permColWrite': '書き込み',
'ssh.file.permColExec': '実行', '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': '取得中…', 'ssh.monitor.collecting': '取得中…',
+7
View File
@@ -129,6 +129,13 @@ const ssh: Record<string, string> = {
'ssh.file.permColRead': '读', 'ssh.file.permColRead': '读',
'ssh.file.permColWrite': '写', 'ssh.file.permColWrite': '写',
'ssh.file.permColExec': '执行', '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': '采集中…', 'ssh.monitor.collecting': '采集中…',
+7
View File
@@ -129,6 +129,13 @@ const ssh: Record<string, string> = {
'ssh.file.permColRead': '讀取', 'ssh.file.permColRead': '讀取',
'ssh.file.permColWrite': '寫入', 'ssh.file.permColWrite': '寫入',
'ssh.file.permColExec': '執行', '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': '採集中…', 'ssh.monitor.collecting': '採集中…',
+3
View File
@@ -14,6 +14,9 @@ export interface SftpEntry {
/** numeric uid/gid (best effort) */ /** numeric uid/gid (best effort) */
uid?: number uid?: number
gid?: 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' export type TransferKind = 'upload' | 'download' | 'zmodem-upload' | 'zmodem-download'
+17 -10
View File
@@ -14,20 +14,24 @@ website/
├── index.html # 单页站点:功能介绍 / 下载 / 更新日志 ├── index.html # 单页站点:功能介绍 / 下载 / 更新日志
├── .nojekyll # 让 GitHub Pages 原样托管(跳过 Jekyll 处理) ├── .nojekyll # 让 GitHub Pages 原样托管(跳过 Jekyll 处理)
└── assets/ └── assets/
├── style.css # 全部样式(企业风浅色主题,单一强调色) ├── style.css # 全部样式(深色默认 / 浅色可切换,主题令牌在 :root 与 html[data-theme='light'])
├── shot-terminal.png # 主界面截图(四路分屏) ├── fonts/ # 自托管 woff2:Space Grotesk 400/500/600/700、JetBrains Mono 400/500/600
├── shot-themes.png # 主题设置页截图 ├── 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) └── icon.png # 应用图标(来自仓库 build/icon.png)
``` ```
## 发新版本时需要更新的地方 ## 发新版本时需要更新的地方
`index.html` 中搜索 `1.0.15`,共 3 处下载链接(hero 主按钮、下载表格 exe、msi)。 `index.html` 中搜索当前版本号,共 2 处下载链接(hero 主按钮、下载表格 exe)。
下载链接使用 GitHub Releases 原生地址: 自 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.exe
https://github.com/billowliu2/OpenTerminal/releases/download/v<版本>/OpenTerminal-<版本>-setup.msi
``` ```
注意:发布新版本时需同时把安装包资产上传到 GitHub release 注意:发布新版本时需同时把安装包资产上传到 GitHub release
@@ -61,7 +65,10 @@ npx serve website
## 设计约定 ## 设计约定
- 浅色企业风,避免营销站常见的霓虹/渐变装饰 - **深色为主**:默认深色(`#0b0e12` 底 + 终端绿强调色),右上角按钮可切浅色;两套主题共用同一份令牌,新增样式一律走 CSS 变量,不要写死颜色
- 强调色仅一个:`#1a7f37`(应用品牌绿 #3fb950 的深色化,保证浅底对比度 ≥ 4.5:1) - 主题选择存 `localStorage['openterminal.theme']`,首帧前由 `index.html` 头部的内联脚本定好,避免闪白/闪黑
- 截图来自真实运行界面,与应用内设置一致 - 全站只用**一个强调色**:深底 `#3fb950`、浅底 `#1a7f37`(对比度 ≥ 4.5:1)
- 中文为主要语言;字体使用系统栈(Segoe UI / 微软雅黑),不加载外部字体 - 字体自托管(`assets/fonts/`),不依赖 Google Fonts:展示字体 Space Grotesk、等宽 JetBrains Mono;中文回落到系统栈
- 「核心能力」是横向滚动卡片轨道(箭头翻页 + 拖拽 + 键盘方向键),不再用三栏等高卡片
- 截图来自真实运行界面,与应用内设置一致;涉及 IP / 主机名等敏感信息的截图必须打码后再放入 `assets/`
- 中文为主要语言;文案保持具体、平实,避免营销套话
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

+536 -128
View File
@@ -1,17 +1,140 @@
/* OpenTerminal 官网 - 企业风浅色主题 /* OpenTerminal 官网 —— 双主题 (深色默认 / 浅色)
单一强调色: 品牌绿 #1a7f37 (深化的 #3fb950, 保证浅底对比度) 排版: Space Grotesk (展示) + JetBrains Mono (等宽), 自托管 woff2
无渐变 / 无发光 / 无装饰性动画, 仅保留 hover 与 focus 反馈 */ 单一强调色: 终端绿 (深底 #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 { :root {
--bg: #ffffff; color-scheme: dark;
--bg-soft: #f7f8f8;
--text: #1c2128; --bg: #0b0e12;
--text-2: #57606a; --bg-alt: #0d1219;
--border: #d9dee3; --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: #1a7f37;
--accent-hover: #116329; --accent-hover: #146227;
--radius: 8px; --accent-text: #1a7f37;
--mono: ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace; --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; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -19,28 +142,63 @@
html { scroll-behavior: smooth; } html { scroll-behavior: smooth; }
body { 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; font-size: 16px;
line-height: 1.6; line-height: 1.65;
color: var(--text); color: var(--text);
background: var(--bg); background: var(--bg);
transition: background-color 0.3s ease, color 0.3s ease;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
.container { .container {
max-width: 1120px; max-width: 1180px;
margin: 0 auto; 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 ---- */ /* ---- nav ---- */
.site-nav { .site-nav {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 30;
background: rgba(255, 255, 255, 0.94); background: var(--nav-bg);
backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
@@ -48,58 +206,128 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 64px; gap: 16px;
height: 62px;
} }
.brand { .brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
font-family: var(--display);
font-weight: 600; font-weight: 600;
font-size: 17px; font-size: 17px;
letter-spacing: -0.01em;
color: var(--text); color: var(--text);
text-decoration: none; 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 { .nav-links a {
color: var(--text-2); color: var(--text-2);
text-decoration: none; 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 ---- */
.hero { .hero {
padding: 72px 0 0; position: relative;
background: var(--bg-soft); overflow: hidden;
padding: 92px 0 0;
border-bottom: 1px solid var(--border); 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 { .hero-grid {
position: relative;
z-index: 1;
display: grid; display: grid;
grid-template-columns: 1fr 1.2fr; grid-template-columns: 1fr 1.12fr;
gap: 48px; gap: 56px;
align-items: center; align-items: center;
} }
.hero-kicker { .hero-kicker {
font-size: 14px; font-family: var(--mono);
color: var(--text-2); font-size: 13px;
margin-bottom: 16px; font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-text);
margin-bottom: 20px;
} }
.hero h1 { .hero h1 {
font-size: 36px; font-size: clamp(34px, 4.6vw, 54px);
line-height: 1.25; line-height: 1.08;
letter-spacing: -0.02em; letter-spacing: -0.03em;
font-weight: 650;
} }
.hero h1 .accent { color: var(--accent-text); }
.hero-sub { .hero-sub {
margin-top: 20px; margin-top: 22px;
max-width: 46ch; max-width: 46ch;
color: var(--text-2); color: var(--text-2);
font-size: 17px; font-size: 17px;
@@ -108,118 +336,251 @@ body {
.hero-actions { .hero-actions {
display: flex; display: flex;
gap: 12px; gap: 12px;
margin-top: 28px; margin-top: 32px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.btn { .btn {
display: inline-block; display: inline-block;
padding: 10px 22px; padding: 11px 24px;
border-radius: var(--radius); border-radius: var(--radius);
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
border: 1px solid transparent; 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 { .btn-primary {
background: var(--accent); 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-primary:hover { background: var(--accent-hover); }
.btn-ghost { .btn-ghost {
color: var(--text); color: var(--text);
border-color: var(--border); border-color: var(--border-strong);
background: var(--bg); background: var(--overlay-1);
} }
.btn-ghost:hover { border-color: var(--accent); background: var(--overlay-2); }
.btn-ghost:hover { border-color: var(--text-2); }
.hero-note { .hero-note {
margin-top: 14px; margin-top: 16px;
font-size: 13px; font-size: 13px;
color: var(--text-2); color: var(--text-2);
} }
.hero-note .mono { color: var(--text); }
.hero-shot { .hero-shot { min-width: 0; }
min-width: 0;
}
.hero-shot img { .hero-shot img {
display: block; display: block;
width: 100%; width: 100%;
height: auto; height: auto;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: 12px;
box-shadow: 0 8px 24px rgba(28, 33, 40, 0.10); box-shadow: var(--shadow-shot);
} }
.hero-shot figcaption { .hero-shot figcaption {
margin-top: 10px; margin-top: 12px;
font-size: 13px; font-size: 13px;
color: var(--text-2); color: var(--text-2);
text-align: center; text-align: center;
} }
.spec-strip { .spec-strip {
position: relative;
z-index: 1;
display: flex; display: flex;
gap: 32px; gap: 40px;
flex-wrap: wrap; flex-wrap: wrap;
padding-top: 40px; padding: 40px 0 48px;
padding-bottom: 40px; margin-top: 48px;
font-size: 14px; font-size: 14px;
color: var(--text-2); color: var(--text-2);
border-top: 1px solid var(--border);
} }
.spec-strip strong { color: var(--text); font-weight: 600; } .spec-strip strong { color: var(--text); font-weight: 600; }
.spec-strip .mono { font-size: 13px; }
/* ---- features ---- */ /* ---- shared section chrome ---- */
.features { padding: 72px 0; } 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 { .features h2, .download h2, .changelog h2 {
font-size: 28px; font-size: clamp(26px, 3vw, 34px);
font-weight: 650; font-weight: 700;
letter-spacing: -0.01em; letter-spacing: -0.025em;
} }
.section-sub { .section-sub {
margin-top: 10px; margin-top: 14px;
color: var(--text-2); 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 { .feature-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr;
gap: 64px;
margin-top: 48px;
}
.media-row {
grid-template-columns: 7fr 5fr; grid-template-columns: 7fr 5fr;
gap: 60px;
align-items: center; 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 { .feature-copy ul { list-style: none; display: grid; gap: 10px; }
font-size: 20px;
font-weight: 650;
margin-bottom: 16px;
}
.feature-copy ul {
list-style: none;
display: grid;
gap: 10px;
}
.feature-copy li { .feature-copy li {
padding-left: 18px;
position: relative; position: relative;
padding-left: 18px;
color: var(--text-2); color: var(--text-2);
font-size: 15px; font-size: 15px;
} }
@@ -229,24 +590,38 @@ body {
position: absolute; position: absolute;
left: 0; left: 0;
top: 0.62em; top: 0.62em;
width: 6px; width: 5px;
height: 2px; height: 5px;
border-radius: 1px;
background: var(--accent); background: var(--accent);
} }
.feature-shot { min-width: 0; }
.feature-shot img { .feature-shot img {
display: block; display: block;
width: 100%; width: 100%;
height: auto; height: auto;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: 12px;
box-shadow: 0 6px 18px rgba(28, 33, 40, 0.08); 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 ---- */
.download { .download {
padding: 72px 0; padding: 92px 0 100px;
background: var(--bg-soft); background: var(--bg-alt);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
@@ -254,84 +629,94 @@ body {
.dl-table { .dl-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-top: 40px; margin-top: 44px;
background: var(--bg); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
font-size: 14.5px; font-size: 14.5px;
} }
.dl-table th, .dl-table td { .dl-table th, .dl-table td {
text-align: left; text-align: left;
padding: 14px 18px; padding: 15px 20px;
vertical-align: top; vertical-align: top;
} }
.dl-table thead th { .dl-table thead th {
background: var(--bg-soft); background: var(--accent-soft);
color: var(--text-2); color: var(--text-2);
font-weight: 600; font-family: var(--mono);
font-size: 13px; 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 + 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 {
.dl-table a:hover { text-decoration: underline; } 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 h3 { font-size: 16px; margin-bottom: 8px; }
.update-note p { color: var(--text-2); font-size: 14.5px; } .update-note p { color: var(--text-2); font-size: 14.5px; }
/* ---- changelog ---- */ /* ---- changelog ---- */
.changelog { padding: 72px 0; } .changelog { padding: 92px 0 100px; background: var(--bg); }
.changelog-list { .changelog-list {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 48px; gap: 40px;
margin-top: 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 { .changelog-list article h3 {
font-size: 15px; font-family: var(--mono);
font-size: 14.5px;
font-weight: 600; 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 { .changelog-list .rel-date { color: var(--text-3); font-weight: 400; font-size: 12.5px; }
color: var(--text);
margin-right: 8px;
}
.changelog-list ul { .changelog-list ul { list-style: none; display: grid; gap: 8px; }
list-style: none;
display: grid;
gap: 8px;
}
.changelog-list li { .changelog-list li { font-size: 14px; color: var(--text-2); }
font-size: 14px;
color: var(--text-2);
}
.changelog-more { .changelog-more { margin-top: 44px; font-size: 14px; color: var(--text-2); }
margin-top: 40px;
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 ---- */ /* ---- footer ---- */
.site-footer { .site-footer {
background: var(--bg-alt);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
padding: 28px 0; padding: 34px 0;
font-size: 13.5px; font-size: 13.5px;
color: var(--text-2); color: var(--text-2);
} }
@@ -341,24 +726,47 @@ body {
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
flex-wrap: wrap; 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 ---- */ /* ---- 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-grid { grid-template-columns: 1fr; gap: 40px; }
.hero { padding-top: 48px; } .hero { padding-top: 60px; }
.feature-row, .media-row { grid-template-columns: 1fr; gap: 32px; } .section-head { flex-direction: column; align-items: flex-start; gap: 20px; }
.media-row .feature-shot { order: -1; } .changelog-list { grid-template-columns: 1fr; gap: 28px; }
.changelog-list { grid-template-columns: 1fr; gap: 32px; }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.nav-links { gap: 16px; } .container { padding: 0 20px; }
.nav-links a { font-size: 14px; } .nav-inner { height: 56px; }
.nav-links { gap: 0; }
.nav-links a { font-size: 13.5px; padding: 6px 8px; }
.hero h1 { font-size: 30px; } .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; } .dl-table { display: block; overflow-x: auto; white-space: nowrap; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; } html { scroll-behavior: auto; }
*, *::before, *::after { transition: none !important; }
} }
+333 -53
View File
@@ -6,111 +6,277 @@
<title>OpenTerminal - 本地终端 / SSH / SFTP 桌面终端,开源 Windows 终端工具</title> <title>OpenTerminal - 本地终端 / SSH / SFTP 桌面终端,开源 Windows 终端工具</title>
<meta name="description" content="OpenTerminal 是开源的 Windows 桌面终端:本地终端、SSH 远程会话、SFTP 文件管理、分屏与广播输入、主题定制。MIT 协议,支持自动更新。" /> <meta name="description" content="OpenTerminal 是开源的 Windows 桌面终端:本地终端、SSH 远程会话、SFTP 文件管理、分屏与广播输入、主题定制。MIT 协议,支持自动更新。" />
<link rel="icon" type="image/png" href="assets/icon.png" /> <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" /> <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> </head>
<body> <body>
<a class="skip-link" href="#main">跳到主要内容</a>
<header class="site-nav"> <header class="site-nav">
<div class="container nav-inner"> <div class="container nav-inner">
<a class="brand" href="#top"> <a class="brand" href="#top">
<img src="assets/icon.png" alt="OpenTerminal 图标" width="24" height="24" /> <img src="assets/icon.png" alt="OpenTerminal 图标" width="24" height="24" />
<span>OpenTerminal</span> <span>OpenTerminal</span>
</a> </a>
<nav class="nav-links"> <div class="nav-right">
<nav class="nav-links" aria-label="站内导航">
<a href="#features">功能</a> <a href="#features">功能</a>
<a href="#download">下载</a> <a href="#download">下载</a>
<a href="#changelog">更新日志</a> <a href="#changelog">更新日志</a>
<a href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener">GitHub</a> <a href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener">GitHub</a>
</nav> </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> </div>
</header> </header>
<main id="top"> <main id="main">
<section class="hero"> <section class="hero" id="top">
<div class="container hero-grid"> <div class="container hero-grid">
<div class="hero-copy"> <div class="hero-copy">
<p class="hero-kicker">开源桌面终端 · MIT 协议</p> <p class="hero-kicker">开源桌面终端 · MIT 协议</p>
<h1>本地终端、SSH 与 SFTP<br />装进同一个桌面应用</h1> <h1>本地终端、SSH 与 SFTP<br />装进<span class="accent">同一个</span>桌面应用</h1>
<p class="hero-sub"> <p class="hero-sub">
OpenTerminal 面向开发和运维场景:WebGL 加速的终端渲染、可分屏的 SSH 会话、 OpenTerminal 面向开发和运维场景:WebGL 加速的终端渲染、可分屏的 SSH 会话、
内置 SFTP 文件管理与广播输入。安装即用,支持自动更新。 内置 SFTP 文件管理与广播输入。安装即用,支持自动更新。
</p> </p>
<div class="hero-actions"> <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 安装包 下载 Windows 安装包
</a> </a>
<a class="btn btn-ghost" href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener"> <a class="btn btn-ghost" href="https://github.com/billowliu2/OpenTerminal" target="_blank" rel="noopener">
查看源码 查看源码
</a> </a>
</div> </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> </div>
<figure class="hero-shot"> <figure class="hero-shot">
<img src="assets/shot-terminal.png" alt="OpenTerminal 主界面:四个分屏终端会话" width="1282" height="801" /> <img src="assets/shot-terminal.jpg" alt="OpenTerminal 主界面:三路分屏并行运行 Kimi Code、Claude Code 与 Codex" width="1739" height="870" />
<figcaption>四路分屏会话,每路独立连接本地或远程主机</figcaption> <figcaption>三路分屏并行运行 AI 编程 Agent,背景图与压暗层可自定义</figcaption>
</figure> </figure>
</div> </div>
<div class="container spec-strip"> <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>Windows 10 / 11 · x64</span>
<span>MIT 开源协议</span> <span>MIT 开源协议</span>
<span>exe 支持应用内自动更新</span> <span>exe 支持应用内自动更新</span>
</div> </div>
</section> </section>
<section class="features" id="features"> <section class="features grid-bg" id="features">
<div class="container"> <div class="container">
<div class="section-head">
<div>
<p class="section-eyebrow">Capabilities</p>
<h2>核心能力</h2> <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="cap-track" id="cap-track" tabindex="0" aria-label="核心能力,可横向滚动">
<div class="feature-copy"> <article class="cap-card">
<p class="cap-num">01</p>
<h3>终端核心</h3> <h3>终端核心</h3>
<ul> <ul>
<li>node-pty 本地会话,自动探测 PowerShell / zsh / bash</li> <li>node-pty 本地会话,自动探测 PowerShell / zsh / bash</li>
<li>xterm.js WebGL 渲染,声明 256 色与真彩能力,异常时自动降级</li> <li>xterm.js WebGL 渲染,声明 256 色与真彩,异常自动降级</li>
<li>内嵌搜索(Ctrl+F)、命令历史、粘贴风险确认</li> <li>内嵌搜索(Ctrl+F)、命令历史、粘贴风险确认</li>
<li>关键词高亮:内置 11 条规则(错误状态、路径、IP、URL 等),支持自定义正则</li> <li>关键词高亮 22 条内置规则,支持自定义正则与三档强度</li>
<li>字体、字重、字距、行高、光标样式实时生效</li> <li>字体、字重、字距、行高、光标样式实时生效</li>
</ul> </ul>
</div> </article>
<div class="feature-copy">
<h3>SSH 与 SFTP</h3> <article class="cap-card">
<p class="cap-num">02</p>
<h3>SSH 会话</h3>
<ul> <ul>
<li>ssh2 实现:密码、私钥(文件或粘贴内容)、SSH Agent 三种认证</li> <li>ssh2 实现:密码、私钥(文件或粘贴内容)、SSH Agent</li>
<li>多路分屏与广播输入,一次操作下发到多台主机</li> <li>多路分屏与广播输入,一次操作下发到多台主机</li>
<li>SFTP 文件面板:上传下载、同名覆盖确认、chmod / chown 权限编辑</li> <li>主机指纹校验、首次连接确认、变更告警</li>
<li>ZMODEM 传输支持;主机指纹校验与首次连接确认</li> <li>ZMODEM 传输支持(sz / rz)</li>
<li>目录记忆:OSC 7 上报工作目录,新会话按上次位置恢复</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> </ul>
</div> </div>
</div> </div>
<div class="feature-row media-row"> <div class="feature-row">
<figure class="feature-shot"> <figure class="feature-shot reveal">
<img src="assets/shot-themes.png" alt="OpenTerminal 主题设置:主题画廊与背景图片配置" width="1282" height="801" /> <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> </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> <h3>定制与效率</h3>
<ul> <ul>
<li>主题编辑器:22 个颜色位逐项调整,实时预览;支持自定义背景图片与不透明度</li> <li>主题编辑器:22 个颜色位逐项调整,实时预览;支持自定义背景图片、不透明度与压暗层</li>
<li>内置 13 套主题,跟随系统明暗自动切换</li> <li>内置 11 套主题,跟随系统明暗自动切换</li>
<li>命令库:保存常用命令片段,跨会话发送到指定终端</li> <li>命令库:保存常用命令片段,跨会话发送到指定终端</li>
<li>启动恢复上次会话布局;界面缩放 50% 至 150%</li> <li>启动恢复上次会话布局;界面缩放 50% 至 150%</li>
<li>四语言界面:简体中文、繁体中文、English、日本語</li> <li>四语言界面:简体中文、繁体中文、English、日本語</li>
</ul> </ul>
</div> </div>
</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> </div>
</section> </section>
<section class="download" id="download"> <section class="download" id="download">
<div class="container"> <div class="container">
<p class="section-eyebrow">Download</p>
<h2>下载</h2> <h2>下载</h2>
<p class="section-sub"> <p class="section-sub">
从 GitHub Releases 下载;全部历史版本在同一页面。 从 GitHub Releases 下载;全部历史版本在同一页面。
<!-- 发新版本时更新下方两条直链与 hero 的 id=dl-exe 链接 --> <!-- 发新版本时更新下方 exe 直链与 hero 的 id=dl-exe 链接 -->
</p> </p>
<table class="dl-table"> <table class="dl-table">
<thead> <thead>
@@ -118,19 +284,11 @@
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td class="mono">OpenTerminal-1.0.15-setup.exe</td> <td class="mono">OpenTerminal-1.0.23-setup.exe</td>
<td>常规安装,支持应用内自动更新(推荐)</td> <td>NSIS 安装包,支持应用内自动更新(推荐)</td>
<td class="mono">122 MB</td> <td class="mono">109 MB</td>
<td> <td>
<a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.15/OpenTerminal-1.0.15-setup.exe">下载</a> <a href="https://github.com/billowliu2/OpenTerminal/releases/download/v1.0.23/OpenTerminal-1.0.23-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>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -147,8 +305,9 @@
<div class="update-note"> <div class="update-note">
<h3>自动更新说明</h3> <h3>自动更新说明</h3>
<p> <p>
应用内检查更新优先访问 git.codingplan.site 更新通道,失败时回退 GitHub Releases。 应用内检查更新优先访问 GitHub Releases(跟随系统代理),失败时自动回退国内 git.codingplan.site 更新通道(直连)。
更新包经 SHA-512 校验后安装。更新说明文字从发布通道的 release-notes.md 读取,与每次发布同步更新。 更新包经 SHA-512 校验后安装。更新说明文字从发布通道的 release-notes.md 读取,与每次发布同步更新。
自 v1.0.22 起不再提供 MSI 安装包,统一使用 NSIS exe。
</p> </p>
</div> </div>
</div> </div>
@@ -156,29 +315,39 @@
<section class="changelog" id="changelog"> <section class="changelog" id="changelog">
<div class="container"> <div class="container">
<p class="section-eyebrow">Changelog</p>
<h2>近期更新</h2> <h2>近期更新</h2>
<div class="changelog-list"> <div class="changelog-list">
<article> <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> <ul>
<li>新增自定义终端背景图片与图片不透明度设置</li> <li>SFTP 文件面板改为真正的表格:六列对齐、表头吸顶、点击列头排序</li>
<li>主题编辑器重名提醒与颜色值校验</li> <li>字体可缩放 0.8–1.8 倍(A- / A+ 或 Ctrl+滚轮),行高联动并记忆设置</li>
<li>设置导航选中项文字对比度修复</li> <li>用户 / 组列显示真实名称,不再只有数字 UID / GID</li>
<li>列表空白处右键即可唤出文件菜单;修复六列此前未真正按网格对齐的问题</li>
</ul> </ul>
</article> </article>
<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> <ul>
<li>修复大文件(约 250KB 以上)上传损坏与 SFTP 句柄泄漏</li> <li>修复中文输入法候选窗不跟随光标(v1.0.21 回归)</li>
<li>SSH 中文乱码、命令历史错乱等终端修复</li> <li>不再提供 MSI 安装包,统一使用 NSIS exe</li>
<li>权限编辑、连接错误提示等 20 余项修复</li> <li>打包流程新增依赖一致性校验,防止旧依赖混入安装包</li>
</ul> </ul>
</article> </article>
<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> <ul>
<li>命令片段库与广播输入改进</li> <li>背景图体验:任意主题下调透明度均为压暗、文字自动提亮到 WCAG AA、新增背景压暗滑杆</li>
<li>监控面板历史曲线实时重绘</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> </ul>
</article> </article>
</div> </div>
@@ -197,5 +366,116 @@
</div> </div>
</footer> </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> </body>
</html> </html>