feat: 移除docks-superpowers

This commit is contained in:
chaos-zhu
2026-05-25 23:25:27 +08:00
parent 2b170f75fc
commit 10891f6cb1
9 changed files with 0 additions and 8058 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,268 +0,0 @@
# EasyNode Mobile Iteration 1 Design
Date: 2026-05-16
## Goal
在 `2026-05-16-mobile-native-terminal-design.md` 的初版基础上完善登录页、服务器列表页、终端页的体验与稳定性,重点处理两个已知 bug,引入应用级终端会话管理以支撑后续多终端 / 挂起 / 批量命令等扩展。
## Scope
包含:
- 修复"保存密码"开关无法回填密码的 bug
- 修复登录态无法持久化(下次进入仍走登录页)
- 应用级 `TerminalSessionManager`,会话所有权与页面解耦
- 终端页重构为带顶部 Toolbar、底部 shortcut bar 的 shell 页面
- 服务器列表页 UI/体验优化
- 登录页 UI/体验优化
- 跨页 401/403 自动跳回登录、暗色主题
不包含:
- 服务器 CRUD、SFTP、RDP、跳板机
- SH 会话挂起到磁盘(manager 内存级生命周期已为后续挂起预留接口)
- 字号 / 主题持久化(终端字号本轮固定)
## Bug 根因与修复
### 保存密码不回填
`EasyNodeApp._hydrateInitialPassword()` 在 `initState` 之后异步读密码并 `setState`,但 `LoginPage._LoginPageState._pwdCtrl` 只在子 State 的 `initState` 里读了一次 `widget.initialPassword`。父 State 后来把新值传下来时,`TextEditingController` 不会自动更新。
修复:将密码加载提前到启动期,与其它持久化字段一起在 `EasyNodeApp.bootstrap()` 中读完,第一帧 `LoginPage` 即拿到 `initialPassword`。
### 登录态不持久
`app.dart` 没有任何启动时恢复 session 的逻辑。即使 token / sessionCookie / deviceId 已写入安全存储,每次进入仍创建空 `_session`,回到登录页。
修复:bootstrap 中尝试恢复 session:
1. 读 serverAddress、username、token、sessionCookie、deviceId、savePassword、password
2. 如果 token 与 sessionCookie 齐全,构建 `ApiClient` 并调用 `/get-pub-pem`
- 成功:构造 `AuthSession`,跳过登录直接进入服务器列表
- 401/403/网络失败:清 token + sessionCookie + deviceId,回登录页(保留 serverAddress / username / savePassword / password 偏好)
3. 启动期间显示轻量 splash(指示器 + 应用名),避免空白闪屏
## 应用级终端会话管理
### `TerminalSessionManager`
- `extends ChangeNotifier`,根级注入,所有 widget 通过 `InheritedNotifier` / `Provider` 风格 `_TerminalSessionScope` 访问
- 持有 `List<TerminalSession>`:
- `id` (uuid v4)
- `hostId`
- `displayName`
- `status`: `connecting` | `connected` | `disconnected` | `error`
- `lastError`
- `controller`: `SshTerminalController`
- 公开方法:
- `Future<TerminalSession> openSession(SshConnectionConfig)`
- `Future<void> closeSession(String id)`
- `void setActive(String id)`
- `String? get activeId`
- `Iterable<TerminalSession> get sessions`
- `Future<void> reconnect(String id)`
- session 生命周期与页面 routes 完全解耦;manager 是唯一所有者
- session 状态变更通过 `notifyListeners` 推送 UI;同时把 `[Disconnected]` / `[Reconnecting]` 写入对应 xterm Terminal,保留 scrollback
### 侧滑返回不断开
- 路由 pop 不调 `disconnect()`;manager 持有 controllers
- 重新 push 终端页时通过 manager 拿现有 session,xterm Terminal 实例复用,scrollback 完整保留
## 终端页 (`TerminalShellPage`)
页面结构:
```
┌─────────────────────────────────┐
│ [⚏³] api-1 ●已连接 [+] [✕] │ Toolbar (高 52)
├─────────────────────────────────┤
│ │
│ xterm view (active session) │
│ │
├─────────────────────────────────┤
│ Esc Tab Ctrl-C ↵ ↑↓←→ Ctrl-D ... → │ Shortcut bar (高 44)
└─────────────────────────────────┘
```
### Toolbar
固定高度 52,水平排布。
**左:紫色堆叠图标 (`StackedSessionsIcon`)**
- `CustomPaint` 绘制三层错位矩形,从深紫到浅紫渐变,2px 错位投影
- 右上角徽标显示当前 session 数;只有 1 个时图标变单层、无徽标
- 点击通过 `OverlayEntry` 在图标左下角弹出菜单:
- 宽度 240
- 高度 `min(行数 × 48 + 16, screenHeight × 0.55)`
- 超出最大高度时启用 `Scrollbar` 常显的纵向滚动
- 行结构:状态点 + session 名(超长省略),当前项右侧 ✓ 且浅紫高亮背景
- 点击行 → `setActive(id)` 后关闭浮层
- 点外部 / 返回键关闭
**中:当前 session 名 + 状态徽章**
- session 名超长省略
- 状态徽章:圆点 + 文字(连接中黄、已连接绿、已断开灰、错误红)
**右:`+` 新建、`✕` 关闭**
`+` 点击弹出"打开服务器"菜单(同样基于 OverlayEntry):
- 宽度 280
- 高度 `min(内容高, screenHeight × 0.55)`,超出滚动
- 行结构:服务器名 + `username@host:port`;已连接的 host 右侧加绿色小点提示再点会再开一个 session
- 点击行 → 取 SSH 参数 → `manager.openSession()` → 自动 `setActive` 到新 session
`✕` 点击关闭当前 session:
- 已连接状态弹小气泡 confirm;已断开直接关
- 关闭后查剩余:
- 还有 → `setActive(剩余 list.first.id)` 留在终端页
- 没有 → `Navigator.pop` 回服务器列表
### 终端区
- `IndexedStack` 承载所有 session 的 `TerminalView`,切换不重建
- 黑底浅灰前景(强制暗色,独立于 app 主题)
- `MediaQuery.viewInsets.bottom` 决定底部留白;`onResize` 推到 `SSHSession.resizeTerminal`
### Shortcut bar
- `resizeToAvoidBottomInset: true`,键盘弹起时整条上移到键盘上沿
- 横向滚动 `ListView.scrollDirection: Axis.horizontal`,不换行
- 按频率排序(首版顺序,后续可调):
```
Esc Tab Ctrl-C ↵ ↑ ↓ ← → Ctrl-D Ctrl-Z | ~ / - Ctrl-L Ctrl-A Ctrl-E PgUp PgDn
```
- 每键 minWidth 48,左右各 4 padding
- Ctrl 粘性键:按一下进入"Ctrl 待发"高亮态,下一个字母键发送 `Ctrl-X` 后自动复位;再按 Ctrl 取消
### 断线策略
- 断线 tab 不自动关闭,状态点灰,xterm 写入 `\r\n[Disconnected]\r\n`
- Toolbar `⚏` 菜单中断线项支持点击触发 reconnect;`SshTerminalController` 复用同一 xterm Terminal,保留 scrollback
- 第一版不主动发 SSH keep-alive
## 服务器列表页
### 顶部活跃终端 banner
`manager.sessions.isNotEmpty` 时显示:紫色堆叠小图标 + `N 个终端运行中`,整个 banner 点击 push `TerminalShellPage`。
### 列表
- ListTile → Card 卡片样式
- 主标题:服务器名(无名时回退 host);左侧绿色小点表示该 host 已有 session
- 副标题:`username@host:port`
- chip 行:authType、group(仅非空时)、`expired`(红色)
- 行尾按钮:
- host 已有 session:`进入`,点击 `setActive` 到该 host 的 session 并 push 终端页
- host 未连接:`连接`,点击取参数 + `openSession` + push 终端页
- 不可连接(`!isConfig` 或 `expired`):禁用并显示 `未配置` / `已过期`
### 分组
按 `group` 字段分组渲染(sticky header),未分组归"默认"组;分组之间组间距更明显。
### 顶部搜索框
实时过滤 name / host / username / tag / group。
### 其它
- 连接中:行内替换连接按钮为 `CircularProgressIndicator`
- 退出登录二次确认 `AlertDialog`
- 空 / 错误态使用统一组件
## 登录页
- 顶部 logo / 标题区 + 副标题
- 密码字段加可见切换(suffix `IconButton`)
- 服务地址 / 用户名 / 密码 IME action 串联:next → next → done(submit)
- HTTP 警告改 inline banner,确认一次后记入状态,不再每次弹窗
- 错误信息改为带图标的容器,不再裸文本
- 字段间距统一为 12
## 跨页公共体验
### 主题
- `ThemeMode.system`
- `ColorScheme.fromSeed(seedColor: Colors.indigo)` 双套(light / dark)
- 暗色下文本 / 分隔线 / 卡片层级统一
### 401 / 403 自动登出
- `ApiClient` 把 401/403 的 `DioException` 抛 `UnauthorizedFailure extends ApiFailure`
- App 根注册 `onSessionExpired`:清 token + sessionCookie + deviceId(保留 serverAddress / username / savePassword / password 偏好),回登录页
- 列表页与 SSH 凭据接口捕获 `UnauthorizedFailure` 后调用 `onSessionExpired`
### 通用组件
`LoadingView`、`EmptyView`、`ErrorView`,居中布局,可选标题 + 副标题 + 行动按钮。
## Flutter Structure 调整
```text
mobile/lib/
app.dart
main.dart
core/
api/...
crypto/...
storage/...
ui/
loading_view.dart
empty_view.dart
error_view.dart
stacked_sessions_icon.dart
anchored_overlay_menu.dart
utils/...
features/
auth/...
servers/
server_list_page.dart
server_card.dart
server_repository.dart
server_model.dart
terminal/
terminal_session.dart
terminal_session_manager.dart
terminal_shell_page.dart
terminal_toolbar.dart
terminal_shortcut_bar.dart
ssh_connection_config.dart
ssh_terminal_controller.dart
```
`features/terminal/terminal_page.dart` 删除。
## Testing
新增 / 修改:
Dart 单测:
- `TerminalSessionManager`:open / close / setActive / 状态流转 / 关闭最后一个
- `SshTerminalController`:reconnect 时复用同一 Terminal、scrollback 累积(用 fake transport)
- `EasyNodeAppBootstrap`:登录态恢复(成功路径 / 401 路径 / 缺字段路径)
Flutter widget 测:
- `TerminalShellPage`:堆叠图标菜单展开 / `+` 菜单展开 / 关闭最后一个 session 自动 pop / 断线状态显示
- `ServerListPage`:分组渲染 / 搜索过滤 / 已连接 host 显示绿点 / 顶部 banner 行为
- `LoginPage`:密码可见切换 / 初始密码回填 / HTTP inline banner
后端:未改后端,不新增 server 测试。
## Migration
- 删除 `mobile/lib/features/terminal/terminal_page.dart`
- 路由从 `MaterialPageRoute(TerminalPage)` 切到 `TerminalShellPage`
- 从单 session push → 改为 manager.openSession + push shell 页
@@ -1,357 +0,0 @@
# EasyNode Mobile Native Terminal Design
Date: 2026-05-16
## Goal
Build the first mobile EasyNode app with Flutter for Android and iOS. The first release focuses on login, server list, and native SSH terminal connection. Existing Web and server behavior must remain compatible.
The app may learn from `C:\Users\chaos\Desktop\flutter_server_box` only at the level of general implementation ideas and dependency choices. That project is AGPL v3, so this implementation must not copy its source code, UI layout, component structure, assets, text, or visual design.
## Scope
Included:
- Login to an existing EasyNode server.
- Persist server address and username by default.
- Save password only when the user explicitly enables it.
- Store password, token, session cookie, and the server-returned login `deviceId` in platform secure storage.
- The server-issued `deviceId` (returned by `/api/v1/login`) is preserved so the app can revoke its own session via the existing `DELETE /api/v1/revoke-login/:deviceId` endpoint later.
- Fetch server data from the existing `/api/v1/host-list` API.
- Show a mobile server list with a connect action.
- Request SSH connection parameters through one new mobile-only server API.
- Use native Flutter/Dart SSH for terminal connections.
- Support password authentication, private-key authentication, and credential-backed hosts that resolve to one of those two methods.
- Support Android and iOS from one Flutter codebase, with small platform configuration differences.
Excluded from the first release:
- Server add/edit/delete flows.
- SFTP.
- RDP.
- Jump hosts and proxy servers.
- Multi-tab terminals.
- Suspended terminal sessions.
- Script library, Docker, one-key commands, AI integrations, and other Web-only features.
- A separate mobile auth/session protocol.
## Architecture
The Flutter app uses one shared Dart codebase for Android and iOS. Platform-specific work is limited to network permissions, HTTP cleartext policy, ATS exceptions, and secure-storage plugin configuration.
The app reuses existing EasyNode APIs where possible:
- `GET /api/v1/get-pub-pem`
- `POST /api/v1/login`
- `GET /api/v1/host-list`
- optionally `DELETE /api/v1/revoke-login/:deviceId`
Only one new server API is required:
- `POST /api/v1/mobile/ssh-connection`
The new endpoint exists because `/host-list` intentionally clears `password` and `privateKey`, while native SSH requires the app to receive decrypted connection parameters at connect time.
## Login Flow
The login page contains:
- server address, for example `http://192.168.1.10:8082`
- username
- password
- optional MFA2 token
- save-password switch
- login expiry choice: temporary, current day, three days, seven days
Server address and username are saved in ordinary app storage because other normal apps cannot read the app sandbox directly on Android or iOS. They are not treated as high-sensitivity secrets.
Password is saved only when the user enables save-password. Saved passwords must use platform secure storage:
- Android: Keystore-backed encrypted storage through `flutter_secure_storage`
- iOS: Keychain through `flutter_secure_storage`
Token, session cookie, and the server-returned login `deviceId` also use secure storage.
The first release does not implement public-key fingerprint binding or change-detection. The RSA public key returned from `/api/v1/get-pub-pem` is still required, because the login password and the per-request temporary AES key are both encrypted with it.
On login:
1. Validate and normalize the server address.
2. If the address uses HTTP, show a strong warning before any login request.
3. Fetch the server public key from `/api/v1/get-pub-pem`.
4. Encrypt the password with the server public key.
5. Call `/api/v1/login` with the existing Web-compatible payload (`loginName`, `ciphertext`, `jwtExpires`, `jwtExpireAt`, optional `mfa2Token`).
6. Store returned `token` and the response `deviceId`; the `session` cookie is written automatically by the server's `Set-Cookie` header.
7. Load the server list with `/api/v1/host-list`.
The HTTP warning should be explicit: HTTP can expose the login token and session cookie, allowing an attacker to take over the app session. The encrypted SSH-parameter response does not replace HTTPS. The warning is shown only when the configured server address uses HTTP; it is not used for any other purpose.
## Server List
The server list uses the existing `/api/v1/host-list` response. The app consumes only the fields needed for a mobile list:
- `id`
- `name`
- `host`
- `port`
- `username`
- `authType`
- `group`
- `tag`
- `expired`
- `isConfig`
The initial UI is intentionally simple and distinct from the reference project:
- title: server name
- subtitle: `username@host:port`
- small metadata chips or labels for auth type, group, and configured status
- primary action: connect
The list supports pull-to-refresh. If the auth configuration is missing, the connect action is disabled or explains that the server has no SSH credentials configured.
When a protected API returns 401 or 403, the app clears token and session and returns to the login page while preserving server address, username, and password-save preference.
## SSH Credential API
Endpoint:
```http
POST /api/v1/mobile/ssh-connection
```
Request body:
```json
{
"hostId": "host id",
"encryptedKey": "RSA encrypted temporary key"
}
```
Rules:
- The endpoint uses the existing Koa auth middleware.
- It requires the existing `token` header and `session` cookie.
- `hostId` must exist.
- The client generates a fresh 32-byte random key, base64-encodes it, then RSA-encrypts the base64 string with the server public key. The server RSA-decrypts to a utf8 string and base64-decodes that string back into the 32-byte key. This round-trip preserves binary key bytes through the existing RSA helper.
- The decrypted temporary key must be 32 bytes after decoding.
- The temporary key is used only for this response.
- On any failure, the response message must be a generic string; details only go to the server log.
Server behavior:
1. Verify the existing EasyNode login state.
2. Resolve the host record.
3. Resolve `authType=credential` into the underlying credential record.
4. Decrypt the stored password or private key using the existing EasyNode database encryption logic.
5. Build a minimal SSH connection payload.
6. Encrypt that payload with AES-256-GCM using the client temporary key.
7. Return only encrypted fields.
Encrypted response shape:
```json
{
"status": 200,
"msg": "success",
"data": {
"alg": "AES-256-GCM",
"iv": "base64 iv",
"tag": "base64 auth tag",
"ciphertext": "base64 ciphertext"
}
}
```
Plaintext payload after mobile decryption:
```json
{
"hostId": "host id",
"name": "server name",
"host": "1.2.3.4",
"port": 22,
"username": "root",
"authType": "privateKey",
"password": "",
"privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----...",
"passphrase": ""
}
```
For password authentication, `authType` is `password` and `password` is populated. For private-key authentication, `privateKey` is populated and `passphrase` may be populated.
No response body outside the AES-GCM ciphertext may contain SSH passwords, private keys, or passphrases.
## Encryption Design
The first release uses a pragmatic encryption envelope for sensitive SSH parameters:
- The app generates a fresh 32-byte random key for each SSH-parameter request.
- The app RSA-encrypts this key using the EasyNode public key from `/get-pub-pem`.
- The server decrypts the key with its private key.
- The server AES-256-GCM-encrypts the SSH parameters using that key.
- The app decrypts the response and immediately starts the SSH connection.
- The temporary key and SSH parameters stay in memory only.
This protects the SSH credential response body from passive network capture. It does not fully protect HTTP users because the existing `token + session` login state can still be captured on HTTP. That is why the login flow must warn before HTTP use.
The first release may use the current RSA mode for compatibility with the existing login flow. AES for the new response envelope should use Node's native `crypto` module rather than the existing CryptoJS passphrase mode.
## Terminal
The terminal page uses:
- `dartssh2` for native SSH connections.
- the pub.dev `xterm` package for terminal rendering. The package is MIT licensed and may be used as a normal dependency.
- a project-owned `SshTerminalController` to bridge `dartssh2` shell streams to xterm input and output.
The app must not copy terminal page code, local package code, UI layout, or component organization from the AGPL reference project.
The first terminal page provides:
- server name and connection status
- full-height terminal area
- minimal mobile toolbar with `Esc`, `Ctrl`, `Tab`, paste, disconnect, and navigation keys
- reconnect and return-to-list actions after disconnect
Resize should be sent to the SSH shell when the terminal viewport changes.
## Flutter Structure
Proposed structure:
```text
mobile/lib/
main.dart
app.dart
core/
api/
api_client.dart
api_result.dart
cookie_store.dart
crypto/
rsa_crypto.dart
aes_gcm_crypto.dart
storage/
app_storage.dart
secure_storage.dart
device_id.dart
ssh/
ssh_connection_config.dart
ssh_terminal_controller.dart
utils/
validators.dart
jwt_expiry.dart
features/
auth/
login_page.dart
login_controller.dart
auth_session.dart
servers/
server_list_page.dart
server_model.dart
server_repository.dart
terminal/
terminal_page.dart
terminal_toolbar.dart
```
The app should avoid heavy generated architecture for the first release. A simple controller/store approach is enough. `ChangeNotifier` or another small state layer is preferred over a large framework until the app grows.
Candidate dependencies:
- `dio`
- `cookie_jar`
- `dio_cookie_manager`
- `flutter_secure_storage`
- `shared_preferences`
- `pointycastle` or another suitable crypto package
- `dartssh2`
- `xterm`
Dependencies must be checked for permissive licenses before implementation.
## Platform Policy
Android:
- Add `INTERNET` permission.
- Allow cleartext HTTP for user-provided self-hosted servers.
- Show in-app HTTP warning before login.
iOS:
- Add ATS exceptions required for user-provided HTTP servers.
- Keep in-app wording clear that HTTPS is recommended.
- For App Store review, explain that users connect to their own self-hosted EasyNode instance and HTTP is retained for LAN and legacy deployment compatibility.
HTTPS with a valid certificate is the recommended path. Self-signed HTTPS can be supported later with certificate-fingerprint binding if needed, but the first implementation does not require a custom TLS trust manager.
## Error Handling
Login:
- Invalid address: block locally.
- HTTP address: show strong warning before login.
- Public key fetch failure: show server/network error.
- Login failure: show the server message and keep address and username.
- 401/403: clear token/session and return to login.
Server list:
- Fetch failure: show retry.
- Empty list: show empty state.
- Missing SSH auth: disable connect or explain the reason.
- Credential-backed host: allow connect; the server resolves it.
Terminal:
- SSH-parameter API failure: show error and allow return.
- Decryption failure: stop before SSH and show error.
- SSH auth failure: show error and allow retry.
- Network disconnect: write disconnect status to the terminal and provide reconnect/return.
- App backgrounding: no explicit keepalive in first release.
## Testing
Dart unit tests:
- login-expiry conversion
- server address normalization
- HTTP risk detection
- deviceId generation and persistence
- AES-GCM decrypt/encrypt helpers
- host-list JSON model mapping
- SSH credential encrypted response decoding
Flutter widget tests:
- login validation
- HTTP warning flow
- save-password switch behavior
- server list rendering
- disabled connect action for unconfigured hosts
Node server tests:
- missing token/session rejects `POST /api/v1/mobile/ssh-connection`
- missing or unknown `hostId` rejects
- unconfigured auth rejects
- password host returns encrypted response
- private-key host returns encrypted response
- credential-backed host returns encrypted response
- response body never includes raw password/private key/passphrase outside ciphertext
Manual acceptance:
- Android HTTP login shows warning.
- Android login succeeds against a local EasyNode server.
- Android server list loads from `/host-list`.
- Android password SSH connects.
- Android private-key SSH connects.
- Token expiration returns to login with address and username retained.
- iOS builds with compatible code and required network configuration.
@@ -1,277 +0,0 @@
# EasyNode Mobile Redesign and I18n Design
Date: 2026-05-19
## Goal
Redesign all existing Flutter mobile screens according to `DESIGN.md`, adapted for a compact operational server-management app rather than a marketing site. Add a lightweight two-language system for English and Simplified Chinese, with language switching available on the login page and settings page.
The first implementation should land the light theme, while keeping the theme/token structure compatible with a later dark-mode pass.
## Confirmed Scope
Included:
- Login page visual redesign.
- Main shell bottom navigation redesign.
- Servers tab redesign.
- Terminal shell page and terminal shortcut toolbar redesign.
- Settings tab redesign.
- SFTP and Scripts placeholder page redesign.
- English and Simplified Chinese app strings.
- Locale selection on Login and Settings.
- Locale persistence in local app storage.
- First-launch locale detection from the system locale.
- Tests for locale behavior and key redesigned UI surfaces.
Excluded:
- New server-side APIs.
- New SFTP, Scripts, or Settings features beyond the existing surfaces.
- Full dark-mode implementation and runtime dark-mode toggle.
- CRUD flows for servers.
- Pixel-perfect recreation of `DESIGN.md` marketing-page hero sections.
## Design Direction
Use the selected "Editorial ops app" direction:
- Pure white app canvas for the primary light theme.
- Near-black ink for primary text.
- Cool gray for secondary text.
- Black primary actions with 8px radius.
- Compact 12px-radius cards with 1px hairline borders.
- Sparse sky-blue atmospheric wash only on the Login intro area.
- JetBrains Mono or platform monospace for terminal, SSH labels, and code-like connection strings.
- Terminal content remains an intentional dark working surface, independent of the app's light shell.
- No saturated purple/indigo seed-color look in user-facing mobile screens.
This adapts `DESIGN.md` into a mobile operations UI: restrained, scannable, dense enough for repeated server work, and visually consistent without feeling like a landing page.
## Theme Architecture
The app should introduce a small theme layer instead of scattering inline colors across pages.
Add a mobile design token module, for example:
```text
mobile/lib/core/ui/
app_theme.dart
app_tokens.dart
```
The theme should provide:
- `ThemeData` light theme using Material 3.
- A dark-compatible token extension with semantic values for canvas, card, hairline, strong hairline, muted text, warning surface, success, and terminal surfaces.
- Button, input, card, app bar, navigation bar, chip, dialog, and snack bar defaults that match `DESIGN.md`.
- A future dark token set, even if `ThemeMode.system` remains and the first visual pass focuses on light mode.
Pages should consume `Theme.of(context)`, `ColorScheme`, and token extension values. Avoid hard-coded black/white page backgrounds except for the terminal's fixed dark ANSI workspace and unavoidable text constants inside custom painters.
## Typography
Flutter should use the platform font stack by default unless bundled fonts are added later. Typography should match `DESIGN.md` proportions:
- Page titles: 22-30px, weight 600.
- Component titles: 16-18px, weight 600.
- Body text: 14-16px, weight 400.
- Captions and metadata: 12-13px.
- Connection strings and terminal content: monospace.
- Letter spacing stays at zero in normal controls; only small uppercase labels may use modest positive tracking.
## Page Designs
### Login Page
Remove the standard AppBar and use a full-page layout:
- Top brand intro with `EasyNode`, language switch, headline, and short supporting copy.
- A single subtle sky-blue wash behind the intro area.
- Form area with server address, username, password, MFA code, session duration, and save-password control.
- Black primary login button.
- Inline HTTP warning notice.
- Inline error notice with icon and consistent padding.
- Language switch in the top-right of the intro area.
The login page must remain usable on small screens with the keyboard open. Text fields keep at least 44px height.
### Main Shell
Keep the existing four tabs:
- Servers
- SFTP
- Scripts
- Settings
Update the bottom navigation to use:
- White/surface background.
- Top hairline divider.
- Compact icons and labels.
- Black selected state.
- Muted gray unselected state.
Keep the current `IndexedStack` behavior so tab state is preserved.
### Servers Tab
Keep the current provider and connection behavior. Redesign the UI:
- Top title row with page title and icon actions.
- Search field shown as a compact bordered field.
- Active terminal banner styled as a light operational banner with stack icon, count, enter affordance, and close-all action.
- Grouped server sections with small uppercase group labels.
- Server cards with:
- server display name;
- connection string in monospace;
- status indicator when a host already has a session;
- auth/group/tag badges;
- black or bordered action button depending on state.
- Empty, error, and loading states use a shared visual language.
The connection logic should not change.
### Terminal Shell
The terminal page keeps its existing structure and behavior:
- 52px top toolbar.
- dark terminal viewport.
- bottom shortcut toolbar.
- session overlay menus.
- reconnect, close, and new-terminal actions.
Visual changes:
- Top toolbar becomes a light control surface with hairline border.
- Session title and status are compact and scannable.
- Stacked sessions icon should use theme-compatible colors or token constants that have light/dark counterparts.
- The terminal viewport stays dark with monospace text.
- Shortcut bar uses small bordered controls in light mode and token-driven surfaces for dark mode later.
- Shortcut labels should be localized where they are words, but terminal control labels such as `Esc`, `Tab`, `Ctrl`, `PgUp`, and `PgDn` remain conventional.
### Settings Tab
Settings should become the user's place to manage app-level preferences:
- Account/server summary card.
- Language setting row with current language and picker/action sheet.
- Logout row with confirmation dialog.
Settings must use the same shared strings system as Login.
### SFTP and Scripts Placeholder Tabs
Keep these as placeholders but make them consistent:
- AppBar/title matching the shell.
- Empty-state component with icon, title, and description.
- Proper English and Chinese strings.
- Remove current mojibake/garbled copy.
## I18n Architecture
Use a lightweight local implementation rather than adding a large dependency.
Suggested files:
```text
mobile/lib/core/i18n/
app_locale.dart
app_strings.dart
app_localizations.dart
```
Core behavior:
- Support exactly two locales initially:
- English: `en`
- Simplified Chinese: `zh_Hans`
- If the user has not selected a language, resolve from the system locale.
- Any Chinese system locale resolves to Simplified Chinese for this phase.
- Other system locales resolve to English.
- Once the user changes language, persist it in `AppStorage`.
- Persisted user choice overrides future system-locale changes.
- Login and Settings both expose language switching.
- Switching language updates visible UI immediately.
Integration shape:
- Add `localeCode` to `AppStorage`.
- Bootstrap reads the saved locale before building `MaterialApp`.
- `_AppRoot` owns the current locale state and passes change callbacks to Login and Main Shell/Settings.
- `MaterialApp.locale` is set to the resolved locale.
- Widgets read copy via a small `context.strings` extension or `AppStrings.of(context)` helper.
Do not localize values that are command syntax or terminal conventions. Do localize labels, hints, validation errors, notices, empty states, dialog titles, and button text.
## State and Data Flow
Startup:
1. Read `SharedPreferences`.
2. Read saved server/user/save-password preferences.
3. Read saved locale code, if any.
4. Resolve effective locale from saved preference or platform locale.
5. Restore auth session as today.
6. Build `MaterialApp` with the effective locale and redesigned theme.
Language switch:
1. User opens switcher from Login or Settings.
2. App updates locale state.
3. App writes locale code to `AppStorage`.
4. `MaterialApp` rebuilds and visible strings update.
Auth and terminal-session behavior remain unchanged.
## Error Handling
- If a saved locale is unknown, fall back to system resolution.
- If writing the locale preference fails, keep the in-memory language for the current session and surface no blocking error; the user can retry later.
- Existing auth expiration behavior remains unchanged.
- Login validation messages become localized.
- HTTP warning and login failure messages become localized when generated by the app. Server-returned messages may stay as returned.
## Testing
Add or update tests for:
- `AppStorage` locale persistence.
- Locale resolution:
- no saved locale + Chinese system locale -> Chinese;
- no saved locale + English/other system locale -> English;
- saved locale overrides system locale.
- Login page renders English and Chinese labels.
- Login language switch updates visible copy.
- Settings language switch updates visible copy.
- SFTP/Scripts placeholders render non-garbled localized copy.
- Servers tab still renders existing cards and connect action.
- Terminal toolbar still emits expected escape sequences after visual changes.
Run at minimum:
```bash
flutter test
```
from the `mobile` directory.
## Migration Notes
- Existing stored users will default from system locale unless they choose a language.
- Existing saved server address, username, password preference, token, session cookie, and device ID storage remain unchanged.
- No server migration is needed.
- No database or API changes are needed.
## Acceptance Criteria
- All current mobile screens visually align with the confirmed Editorial ops app direction.
- Login and Settings can switch between English and Simplified Chinese.
- Language selection persists across app restarts.
- First launch follows system language when no user preference exists.
- Current SSH connection behavior, terminal sessions, and shortcut input behavior continue to work.
- SFTP and Scripts placeholder pages no longer contain garbled text.
- Theme code is structured so a later dark-mode pass can add complete dark colors without rewriting page layouts.
@@ -1,259 +0,0 @@
# Mobile Native Proxy and Jump Host Support Design
## Context
The web terminal connects through `server/app/socket/terminal.js`. The server reads the target host, decrypts credentials, applies `proxyType`, and either opens a direct SSH connection, creates a proxy tunnel, or connects through jump hosts before handing the final socket to `ssh2`.
The mobile terminal currently connects locally with `dartssh2`:
```text
mobile -> target SSH
```
That preserves a native terminal experience, but it means server-side proxy and jump-host handling does not apply. Mobile must support the same connection topology locally:
```text
mobile -> proxy/jump chain -> target SSH
```
## Goals
- Keep mobile terminal connections local and native, including proxy and jump-host scenarios.
- Support the existing host fields: `proxyType`, `proxyServer`, and `jumpHosts`.
- Reuse the existing encrypted `/mobile/ssh-connection` response envelope for sensitive connection details.
- Preserve current direct SSH behavior for hosts without proxy or jump hosts.
- Provide clear errors for proxy, jump-host, and target-host failures.
## Non-Goals
- Do not route mobile terminal sessions through the server terminal websocket.
- Do not change web terminal behavior.
- Do not redesign server host, proxy, or credential storage.
- Do not implement mobile RDP proxying in this change.
## Connection Modes
### Direct
When `proxyType` is empty, the mobile app connects as it does today:
```text
SSHSocket.connect(target.host, target.port)
SSHClient(socket, target auth)
```
Unsupported mobile proxy modes should fail explicitly instead of silently falling back to direct connection.
### SOCKS5 Proxy
When `proxyType === 'proxyServer'` and the selected proxy has `type === 'socks5'`, the mobile app opens a TCP socket to the proxy, performs SOCKS5 negotiation, asks the proxy to connect to the target host and port, and passes the established tunnel to `dartssh2`.
Supported SOCKS5 authentication:
- No authentication
- Username/password authentication
### HTTP Proxy
HTTP CONNECT can be added after SOCKS5 and jump hosts. If the server returns an HTTP proxy before mobile support exists, mobile should return a clear unsupported error.
### Jump Hosts
When `proxyType === 'jumpHosts'`, the mobile app connects to each jump host in order. Each jump host opens a `direct-tcpip` style channel to the next hop. The final target `SSHClient` is created over the last forwarded channel.
Single and multi-hop chains use the same algorithm:
```text
connect jump1
jump1 opens channel to jump2 or target
connect next SSHClient over that channel
repeat until target
connect final target SSHClient
```
All intermediate jump `SSHClient` instances must stay alive for the target session lifetime and must be closed when the terminal disconnects.
## Server Payload Design
`/mobile/ssh-connection` should continue to return an AES-GCM encrypted payload. The plaintext payload expands from target auth only to target auth plus connection topology.
Direct example:
```json
{
"hostId": "target",
"name": "prod",
"host": "1.2.3.4",
"port": 22,
"username": "root",
"authType": "privateKey",
"password": "",
"privateKey": "...",
"passphrase": "",
"proxyType": "",
"proxy": null,
"jumpHosts": []
}
```
SOCKS5 example:
```json
{
"hostId": "target",
"name": "prod",
"host": "1.2.3.4",
"port": 22,
"username": "root",
"authType": "password",
"password": "...",
"privateKey": "",
"passphrase": "",
"proxyType": "proxyServer",
"proxy": {
"id": "proxy1",
"name": "office socks",
"type": "socks5",
"host": "proxy.example.com",
"port": 1080,
"username": "",
"password": ""
},
"jumpHosts": []
}
```
Jump-host example:
```json
{
"hostId": "target",
"name": "prod",
"host": "10.0.0.20",
"port": 22,
"username": "root",
"authType": "privateKey",
"password": "",
"privateKey": "...",
"passphrase": "",
"proxyType": "jumpHosts",
"proxy": null,
"jumpHosts": [
{
"hostId": "jump1",
"name": "jump-1",
"host": "203.0.113.10",
"port": 22,
"username": "root",
"authType": "password",
"password": "...",
"privateKey": "",
"passphrase": ""
}
]
}
```
The server must resolve credentials for jump hosts the same way it resolves the target host. If a jump host uses `authType === 'credential'`, the payload should contain the resolved concrete auth type and decrypted secret.
## Mobile Architecture
Introduce a transport layer between `SshTerminalController` and `dartssh2`.
```text
SshTerminalController
-> SshTransportFactory.open(config)
-> DirectSshTransport
-> Socks5SshTransport
-> JumpHostSshTransport
-> SSHClient(transport.socket, target auth)
```
### Models
Extend `SshConnectionConfig` with:
- `proxyType`
- `SshProxyConfig? proxy`
- `List<SshJumpHostConfig> jumpHosts`
Add a shared auth shape for target and jump hosts:
- `hostId`
- `name`
- `host`
- `port`
- `username`
- `authType`
- `password`
- `privateKey`
- `passphrase`
- `privateKeyPassphrase`
`privateKeyPassphrase` should keep the existing behavior: empty or whitespace passphrases become `null`.
### Transport Handle
`SshTransportFactory.open` should return a handle containing:
- The stream/socket used by the final target `SSHClient`.
- Any intermediate SSH clients that must remain alive.
- A `close()` method that shuts down intermediate clients and sockets in reverse order.
This prevents `SshTerminalController` from knowing how a tunnel was built while still letting it clean up correctly.
## Error Handling
Errors should identify the failing layer:
- `SOCKS5 proxy connection failed`
- `SOCKS5 authentication failed`
- `SOCKS5 target connection failed`
- `Jump host connection failed: <name>`
- `Jump host authentication failed: <name>`
- `Jump host forwarding failed: <from> -> <to>`
- `Target SSH authentication failed`
- `Unsupported mobile proxy type: http`
The terminal page can display the error in the existing terminal output style.
## Security
Mobile already receives decrypted target SSH credentials for local native SSH. This design expands that scope to proxy credentials and jump-host credentials only when the selected target host requires them.
Mitigations:
- Keep using the existing RSA temporary key plus AES-GCM encrypted response envelope.
- Do not persist decrypted proxy or jump-host credentials.
- Keep decrypted payload lifetime scoped to the connection attempt.
- Do not include proxy credentials or jump-host credentials in list APIs.
- Avoid logging decrypted secrets on server or mobile.
## Testing
### Server
- `toMobileSshPayload` returns direct payload with empty proxy and jump-host fields.
- `toMobileSshPayload` returns SOCKS5 proxy details for `proxyType === 'proxyServer'`.
- `toMobileSshPayload` returns resolved jump-host auth details for `proxyType === 'jumpHosts'`.
- Missing proxy or jump host produces a clear error.
- Credential-based target and jump hosts resolve to concrete `password` or `privateKey` auth.
### Mobile
- `SshConnectionConfig.fromJson` parses direct, SOCKS5, and jump-host payloads.
- Empty passphrases are converted to `null` for target and jump-host private keys.
- `SshTransportFactory` selects direct, SOCKS5, or jump-host transport based on `proxyType`.
- SOCKS5 handshake supports no-auth and username/password modes.
- Jump-host transport keeps intermediate clients alive and closes them on disconnect.
- Unsupported proxy types fail explicitly.
## Rollout
1. Extend server mobile payload generation and tests.
2. Extend mobile config models and parser tests.
3. Add `SshTransportFactory` with direct transport only and migrate current controller to use it.
4. Add SOCKS5 transport and tests.
5. Add jump-host transport and tests.
6. Add user-facing error messages.
7. Add HTTP CONNECT proxy support later if needed.
@@ -1,291 +0,0 @@
# 移动端 SFTP 文本文件编辑器 — 设计稿
- 状态:已与用户对齐(2026-05-23)
- 范围:仅 `mobile/`(Flutter);后端无改动
- 设计参考:`mobile/design/mobile.pen` 节点 `sYMaF`(代码编辑页)
- 不在范围:Web 端、`/sftp-v2` socket、`flutter test` 跑测试(按 CLAUDE.md 默认只跑 analyze)
## 1. 目标
在移动端 SFTP 文件列表里,允许用户单击文本文件直接进入全屏编辑页:浏览(带语法高亮 + 行号 + 折叠)、编辑(undo/redo、按语言格式化)、保存回远端,并对大文件 / 二进制做拒绝保护。
## 2. 用户决策回顾
| 维度 | 选择 |
| --- | --- |
| 入口 | 单击文件直接进入编辑器 |
| 大文件保护 | ≤ 2 MB + 前 8 KB NUL 嗅探 |
| 「格式化」按钮 + 编码 | 保留格式化(JSON / YAML / XML),仅 UTF-8 |
| 页面承载 | `Navigator.push` 全屏路由 |
## 3. 库选型
**采用 `re_editor` + `re_highlight`**(Reqable 团队,MIT):
- 不基于 `TextField`,独立绘制;移动端大文本性能好(Reqable iOS/Android 同款)
- 自带 undo/redo、行号 (`indicatorBuilder`)、折叠 (`DefaultCodeChunkAnalyzer` 识别 `{}` `[]`)、find/replace 控制逻辑、近百种 highlight mode
- 软键盘、iOS 浮动光标、ime 输入有专门处理
替代方案:
- `flutter_code_editor` (Akvelon):基于 TextField + highlight,移动端大文件易掉帧,2025 之后更新放缓。不选。
- `code_text_field`:旧,功能少。
- `code_forge`:依赖 dart:io,依赖 LSP/AI,过重。
## 4. 文件落地
### 4.1 新增依赖(`mobile/pubspec.yaml`)
```yaml
re_editor: <pub.dev 当前稳定版>
re_highlight: <pub.dev 当前稳定版>
yaml: ^3.1.2
xml: ^6.5.0
```
`re_editor` 与 `re_highlight` 仍在 0.x,实施第一步先 `flutter pub add re_editor re_highlight yaml xml` 让 pub 决定 caret range,再把结果固化到 pubspec.yaml。
### 4.2 新增源文件
```
mobile/lib/features/shell/editor/
text_editor_page.dart # 全屏编辑页 widget
text_editor_controller.dart # ChangeNotifier:脏标记 / 保存 / 放弃
editor_language.dart # 文件名 → (语言 id, highlight Mode)
editor_text_sniffer.dart # NUL 嗅探 + UTF-8 解码兜底
editor_formatters.dart # JSON / YAML / XML formatter
```
### 4.3 修改的源文件
- `mobile/lib/features/shell/sftp_session_manager.dart`
- 新增 `readTextFile(remotePath, {maxBytes=2*1024*1024})` → 返回 `({Uint8List bytes, bool malformedUtf8})`,内部先 `sftp.stat` 拿大小(超限抛 `SftpFileTooLargeException`),再 `_readRemoteFile`,前 8 KB 嗅探 NUL(命中抛 `SftpBinaryFileException`)。
- 新增 `writeTextFile(remotePath, content)` → `_writeRemoteFile(remotePath, utf8.encode(content))`。
- 新增两个异常类型(同文件内 `class SftpFileTooLargeException`、`class SftpBinaryFileException` extends `Exception`),便于 UI 层精确分支。
- `mobile/lib/features/shell/sftp_tab.dart`
- `_SftpFileRow.onTap`:非选择态下,目录走 `manager.openPath`(已有行为),文件改为调用新增的 `_openInEditor(entry)`。
- `_openInEditor`:调用 `manager.readTextFile`,捕获 size / binary / generic 三类异常,分别 toast。成功后 `Navigator.push(MaterialPageRoute(builder: (_) => TextEditorPage(...)))`。
- `mobile/lib/l10n/strings_en.dart` + `strings_zh.dart`:新增 editor.* key(见 §7)。
## 5. UI
### 5.1 结构(对齐 sYMaF)
```
TextEditorPage (Scaffold)
├─ _EditorAppBar 返回 + 文件名 + 路径 + undo + redo
├─ _EditorMetaBar 语言徽章 + "UTF-8 · LF · 2.4 KB"
├─ Expanded
│ └─ CodeEditor re_editor 主体,dark theme,行号 + 折叠
├─ _EditorStatusBar Ln/Col + 语言·Spaces + 总行/当前行
└─ SafeArea bottom
└─ _EditorActionBar 未保存指示 + 格式化 + 保存
```
### 5.2 配色
| 区域 | 颜色 |
| --- | --- |
| 编辑器背景 | `#0A0F14` |
| 行号 / 默认 | `#4B5563` |
| 当前行号 | `#9CA3AF` |
| 状态栏背景 | `#111827` |
| 状态栏 border-top | `#1F2937` |
| 状态栏字 | `#9CA3AF` |
| 其余(AppBar、Meta、Action、Dialog) | 复用 `_SftpPalette` |
高亮主题:`re_highlight` 的 `atom-one-dark`,静态 const 引用。
### 5.3 交互
- AppBar undo / redo 按钮:直接代理 `CodeLineEditingController.undo()` / `redo()`,并按 `controller.canUndo` / `canRedo` 控制可点态。
- 格式化按钮:按当前语言是否在 `editor_formatters.dart` 中支持(JSON / YAML / XML)来控制启用;点击 → 调用对应 formatter;失败 → 弹 toast。
- 保存按钮:仅 `isDirty` 时主色高亮可点;保存中显示 loading(按钮内 spinner);成功 → toast `editor.saved`、`isDirty=false`;失败 → toast `editor.saveFailed`,保留 isDirty。
- 返回(AppBar 返回 / 系统返回 / 手势返回):`PopScope` 拦截,`isDirty` 时弹 dialog(继续编辑 / 放弃 / 保存并退出),否则直接 pop。
- 不实现:查找替换 / 编码切换 / 主题切换 / 字体大小 / 缩略图 / 自动换行开关 / 多文件 tab。
## 6. 数据流
```
[SFTP 文件行点击]
│
▼
manager.readTextFile(path)
│ ├─ sftp.stat → 大小 > 2 MB ─► SftpFileTooLargeException
│ ├─ _readRemoteFile
│ ├─ 前 8 KB 含 NUL ──────────► SftpBinaryFileException
│ └─ utf8.decode(allowMalformed:true) → malformedUtf8 标记
▼
Navigator.push → TextEditorPage(path, bytes, malformedUtf8)
│
▼
TextEditorController:
- originalText = decoded
- CodeLineEditingController.fromText(originalText)
- 监听 text → 计算 isDirty
│
▼ 用户编辑 …
▼
saveFile():
- manager.writeTextFile(path, controller.text) (utf8.encode)
- 成功 → originalText = controller.text → isDirty=false → toast
- 失败 → toast,保持 isDirty
```
## 7. i18n key
| key | zh | en |
| --- | --- | --- |
| `editor.tooLarge` | 文件超过 2 MB,请下载后再编辑 | File exceeds 2 MB. Download to edit. |
| `editor.binary` | 二进制文件不支持编辑 | Binary file is not editable. |
| `editor.readFailed` | 读取失败:{0} | Read failed: {0} |
| `editor.saveFailed` | 保存失败:{0} | Save failed: {0} |
| `editor.saved` | 已保存 | Saved |
| `editor.unsaved` | 未保存 | Unsaved |
| `editor.format` | 格式化 | Format |
| `editor.save` | 保存 | Save |
| `editor.discardTitle` | 放弃修改? | Discard changes? |
| `editor.discardBody` | 当前修改未保存,确定离开? | Unsaved edits will be lost. Leave? |
| `editor.discardKeepEditing` | 继续编辑 | Keep editing |
| `editor.discardLeave` | 放弃 | Discard |
| `editor.discardSaveAndLeave` | 保存并退出 | Save & leave |
| `editor.malformedUtf8` | 文件含非 UTF-8 字节,保存可能丢失部分字符 | File contains non-UTF-8 bytes; saving may lose some characters. |
| `editor.formatUnsupported` | 当前语言不支持格式化 | Format not supported for this language. |
| `editor.formatFailed` | 格式化失败:{0} | Format failed: {0} |
| `editor.statusEncoding` | UTF-8 · LF · {0} | UTF-8 · LF · {0} |
| `editor.statusPosition` | Ln {0}, Col {1} | Ln {0}, Col {1} |
| `editor.statusLineCount` | {0} / {1} | {0} / {1} |
| `editor.statusSpaces` | Spaces: {0} | Spaces: {0} |
## 8. 错误处理矩阵
| 场景 | 行为 |
| --- | --- |
| 文件 > 2 MB | toast `editor.tooLarge`,不进入页面 |
| 二进制文件(前 8 KB 含 NUL) | toast `editor.binary`,不进入页面 |
| 读取失败(权限 / 网络) | toast `editor.readFailed: <e>`,不进入页面 |
| 解码遇到无效字节 | `utf8.decode(allowMalformed:true)` 兜底进入;进入后 toast 一次 `editor.malformedUtf8` 警告 |
| 保存失败 | toast `editor.saveFailed: <e>`,保留 isDirty |
| 格式化失败 | toast `editor.formatFailed: <e>`(包含 JSON/YAML/XML parser 行列信息) |
| 当前语言不支持格式化 | toast `editor.formatUnsupported` |
## 9. 模块职责
### 9.1 `editor_text_sniffer.dart`
```dart
class TextSniffResult {
final bool isBinary;
final bool malformedUtf8;
final String text; // utf8.decode(allowMalformed:true) 结果
}
TextSniffResult sniffAndDecode(Uint8List bytes);
```
- 取前 `min(8192, bytes.length)` 个字节,遇到 0x00 → `isBinary=true`,跳过解码。
- 非二进制 → `utf8.decode(bytes, allowMalformed:true)`;同时用 `utf8.decode(bytes)` 试探一次(不抛出捕获),失败则 `malformedUtf8=true`。
### 9.2 `editor_language.dart`
```dart
class EditorLanguage {
final String id; // 用于 UI 显示,如 'YAML', 'JSON'
final Mode? highlightMode; // re_highlight Mode;plaintext 为 null
final bool formatSupported;
final int defaultIndent; // 2
}
EditorLanguage detectFromFileName(String name);
```
- 复用 `web/src/components/text-editor/index.vue` 的 ext → lang 映射;缺省 plaintext。
- 仅 json/yaml/xml 的 `formatSupported = true`。
### 9.3 `editor_formatters.dart`
```dart
String formatJson(String src); // throws FormatException
String formatYaml(String src); // throws FormatException
String formatXml(String src); // throws FormatException
```
- `formatJson`:`jsonDecode` → `JsonEncoder.withIndent(' ').convert(...)`。
- `formatYaml`:`loadYaml` → 自写递归 dumper(YamlMap / YamlList / scalar / null / bool / num / string,2 空格缩进,字符串按需带引号)。注释会丢失,UI 上用 toast 警告。
- `formatXml`:`XmlDocument.parse(src).toXmlString(pretty: true, indent: ' ')`。
### 9.4 `text_editor_controller.dart`
```dart
class TextEditorController extends ChangeNotifier {
TextEditorController({
required this.sessionManager, // SftpSessionManager
required this.remotePath,
required String originalText,
required this.language,
required this.totalBytes,
});
final CodeLineEditingController code; // re_editor controller
String _originalText;
bool _saving = false;
bool get isDirty => code.text != _originalText;
bool get saving => _saving;
bool get canFormat => language.formatSupported;
Future<void> save(); // writeTextFile + 更新 originalText
Future<void> saveAndLeave(NavigatorState nav);
void format(); // 按 language 选 formatter;写回 code.text
void dispose(); // dispose code
}
```
- `code` 监听文本变化时 `notifyListeners()`,驱动 footer 未保存指示器。
- `save` 内置 `_saving` 互斥,防止双击。
### 9.5 `text_editor_page.dart`
- `StatefulWidget`,`initState` 创建 `TextEditorController`,`dispose` 释放。
- 用 `PopScope(canPop: !isDirty, onPopInvoked: ...)` 拦截返回。
## 10. 风险与缓解
| 风险 | 缓解 |
| --- | --- |
| Android 第三方 IME(搜狗 / 百度)联想抖动 | `re_editor` 已处理大多数 IME;遇到 QA 反馈可临时打开 wordWrap 减少水平滚动冲突 |
| 用户误点击大文件等待几秒才被拒 | 先 `sftp.stat` 拿大小,未读取数据就拦截;NUL 嗅探在内存里很快 |
| YAML 格式化丢注释 | toast 警告;本期不引入 `yaml_writer` / 自研 round-trip |
| 保存中网络中断 | toast 报错,保留 isDirty,用户可手动重试 |
| 文件不是 UTF-8(如 GB18030) | `allowMalformed:true` 不让进入页面崩溃;toast 告知用户保存可能损坏 |
## 11. YAGNI 列表(本期不做)
- 查找 / 替换 UI(re_editor 已有逻辑,UI 待后续 PR)
- 编码切换、行尾切换、主题切换、字体大小、缩略图、word wrap toggle
- 多文件 tab、最近编辑历史、本地草稿恢复
- 长按菜单的「编辑」入口(如有需求再加,到时和单击同走 `_openInEditor`)
- 后端 socket 协作编辑
## 12. 测试
按 CLAUDE.md,默认只跑 `flutter analyze` + format;下列单测仅在用户明确要求时执行:
- `mobile/test/features/shell/editor/editor_text_sniffer_test.dart`
- `mobile/test/features/shell/editor/editor_language_test.dart`
- `mobile/test/features/shell/editor/editor_formatters_test.dart`
- `mobile/test/features/shell/editor/text_editor_controller_test.dart`(用 fake `SftpSessionManager`)
## 13. 实施步骤索引(实际拆分见 plan)
1. 加依赖 + 创建 `editor/` 目录骨架。
2. `editor_text_sniffer.dart` + `editor_language.dart` + 单测。
3. `editor_formatters.dart` + 单测。
4. `SftpSessionManager` 新增 `readTextFile` / `writeTextFile` + 异常类。
5. `TextEditorController` + 单测。
6. `TextEditorPage` UI 拼装(AppBar / Meta / Editor / StatusBar / ActionBar)。
7. i18n 补 key。
8. `sftp_tab.dart` 接入单击入口,三类异常 toast。
9. `flutter analyze` + 手动 / 模拟器自查。