From f626099d0594c9eb2503f2caca0cf66e97c848c5 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 11 Jun 2026 17:33:42 +0700 Subject: [PATCH 1/8] =?UTF-8?q?docs:=20complete=200.1.35=20release=20check?= =?UTF-8?q?list=20=E2=80=94=20changelog,=20roadmap,=20wiki?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CHANGELOG 0.1.35: add macOS universal build, perf/size pass, and the VS 2026 Windows build fix alongside the existing entries - roadmap: bump to 0.1.35, move path jump / universal build / perf pass to Shipped, refresh Top 3 (K8s panel shipped in 0.1.34 — replaced by Docker panel completion) - wiki: Getting Started points at the universal DMG (Apple Silicon + Intel); SFTP guide documents the Go-to-path breadcrumb editor --- CHANGELOG.md | 6 ++++++ docs/roadmap.md | 10 +++++----- docs/wiki/User-Guide-Getting-Started.md | 2 +- docs/wiki/User-Guide-SFTP.md | 1 + 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa5b61ee..172bd8d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Breadcrumb path jump** — the shared `PathBreadcrumb` gains an inline path editor: an edit affordance opens a text field seeded with the current path; Enter navigates there, Escape cancels. Wired into both the remote SFTP panel and the local file panel; remote-typed paths are normalized to absolute POSIX (trailing slash dropped) so derived child paths don't double up +- **macOS universal build** — Intel Macs are now supported: one universal (arm64 + x86_64) artifact (`YourSSH-x.x.x-macOS-universal.dmg/zip`) built on the arm64 runner; `build.sh` lipos both Rust dylib targets and the release workflow asserts both arches via `lipo -archs` so an arm64-only artifact can never ship under the universal name; the in-app updater matches the universal DMG on both archs (Intel falls back to the browser against pre-universal releases) + +### Changed +- **Performance pass** — compiled keyword-highlight rules are memoized in `SettingsProvider` (previously every terminal build recompiled each rule's RegExp, duplicated across three widgets); `SessionProvider.setActive` no longer notifies when re-clicking the already-active tab; SSH agent messages are built with a direct buffer write instead of double list spreads +- **Smaller bundles** — removed the unused `local_auth` dependency (pulled native plugins into macOS/Windows bundles); dropped the MesloLGS NF Italic / Bold Italic faces (−4.8 MB per bundle; the engine falls back to a synthetic slant); desktop release builds use `--split-debug-info`, with per-platform symbols zips attached to each release for `flutter symbolize` ### Fixed - **Non-ASCII terminal input** — typed input was sent to the SSH shell via truncated UTF-16 code units, corrupting any character above U+00FF (e.g. Vietnamese: "ế" arrived as a single garbage byte). Input is now UTF-8 encoded at every user-text write site (keystroke/IME, `terminal.input` plugin hook, startup command, snippet insert), matching the local-shell path +- **Windows build on VS 2026** — unblocked the MSVC STL1011 coroutine error --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 3283af69..4f549cc8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,11 +1,11 @@ # YourSSH — Roadmap > Direction: **infra workstation for DevOps/SRE managing 10–100+ hosts**, not just an SSH client. -> Current version: 0.1.34 · updated: 2026-06-09 +> Current version: 0.1.35 · updated: 2026-06-11 This document lists proposed features ordered by priority. Each item can be broken out into its own spec (`docs/superpowers/specs/`) when ready for implementation. -Already shipped (not repeated in roadmap): multi-tab terminal, split view, broadcast, recording (asciicast), snippet, SFTP dual-panel, port forwarding, jump host, Supabase sync + P2P LAN, AI chat sidebar with tool calling, plugin system (DevOps / WebTools / Snippets), Cloudflare tunnel, MCP gateway, mail catcher, code editor (Monaco), customizable hotkeys, TOFU known-hosts, **Command Palette (Cmd/Ctrl+K)** — fuzzy search hosts / nav / snippets / actions, **Workspace persistence** — auto-reconnect tabs + layout on relaunch, **Search-in-scrollback (Cmd/Ctrl+F)** — regex, highlights, prev/next navigation, **Script Engine plugin system** — disk-based JS plugins via QuickJS FFI, HookBus (terminal.output / terminal.input / session events), SSH/SFTP/Storage/UI bridges, hot-reload file watcher, PermissionGuard + circuit breaker, consent dialog, manager screen + console log viewer, **Import** — paste SSH config / JSON / CSV with per-host include toggles (`parseSshConfig` in `import_panel.dart`), **Host tagging** — comma-separated tags on the `Host` model, editable in host detail and searchable from the dashboard, **Smart filter + multi-dimensional query (0.1.10)** — `HostQuery` parser with `key:value` faceted AND/OR semantics, toggleable facet chips on hosts dashboard, tag-based search, **Terminal sharing / multiplayer (0.1.13)** — share a live SSH session via Supabase Realtime; guests join with a session code, watch or interact in real time; `ShareSessionService`, `ShareProvider`, `ShareEvent`, split-view watch banner, **Advanced tab management** — rename, color tag, pin, drag reorder; all tab metadata persists per host, **Connection health badge (0.1.17)** — live latency-driven dot per session tab (green/amber/red/grey + pulse), hover tooltip with uptime / last-ping / reconnect count; `HealthMonitorService` pings the live channel (`SSHClient.ping`) as sole pinger, 5s timeout surfaces half-open silent drops, **Shell integration (0.1.18)** — injected bash/zsh prompt-hooks emit OSC 7 + OSC 133 captured via xterm `onPrivateOSC`; cwd on the session tab, per-command status gutter (green/red/grey), jump-to-prompt (`Cmd/Ctrl+↑/↓`), and cwd-aware path autocomplete in the input bar; auto-on with per-host + global opt-out, **In-app updates (0.1.19)** — checks GitHub `releases/latest` on launch (24h debounce) + manual check in Settings; since 0.1.29 also re-checks while the app stays running (6h periodic timer + window-focus check, same 24h debounce) so the notification bell picks up new releases without a restart; dismissible update banner + Settings Updates section; downloads the correct OS/arch artifact and hands off to the OS installer (assisted flow, unsigned-app friendly: macOS strips quarantine + opens the DMG, Windows runs the installer, Linux opens the package); falls back to the Releases page when no artifact matches (e.g. Intel Mac); `UpdateService` + `UpdateProvider` + `UpdateBanner`, **Sudo SFTP (0.1.20)** — per-host SFTP mode running the entire SFTP session as root through `sudo` over an exec channel (WinSCP-style); distro auto-detection, `NOPASSWD` guidance, root badge on elevated panels, **External edit + Open with… (0.1.20–0.1.21)** — open remote files with the OS default app or any installed app (hover submenu; per-OS discovery via `NSWorkspace` / XDG `.desktop` / Windows registry); the local copy is watched and auto-uploaded on every save; plain-text editor fallback where Monaco is unavailable, **SFTP View mode (0.1.21)** — read-only preview separate from Edit, **Invisible shell-integration injection (0.1.21)** — bracketed-paste readiness detection + `read -rs` two-phase handshake delivers the OSC hook installer without ever echoing into the terminal or recordings, **Terminal snippets panel (0.1.21)** — collapsible right-side panel in the terminal workspace to browse/search/copy/run snippets against the active pane, backed by the new plugin `sendInput` API, **Unified terminal tabs (0.1.24)** — local shell sessions are first-class tabs in the global top bar (`TerminalSession` model), split into panes alongside SSH, recordable to asciicast, **SFTP two-panel with switchable sources (0.1.24)** — per-panel Local/host source chip, unified headers (filter + actions), clickable breadcrumbs (#41), workspace kept alive across tab switches (#42), **Terminal copy/paste UX (0.1.24)** — selection-gated Ctrl+C copy (SIGINT preserved), Ctrl(+Shift)+V paste, right-click Copy/Paste/Select All menu, middle-click paste (#43), readable semi-transparent selection colors in all themes (#40), **Notification bell (0.1.25)** — bell in the top tab bar with unread badge + anchored popover; update-available items carry a one-click Update button, unexpected session drops (no pending auto-reconnect) are collected per session; mark-read on open, per-item dismiss, clear all; in-memory `NotificationCenterProvider` (deduped, capped at 50), **Terminal appearance side panel** — tune icon in the terminal toolbar opens a right-side panel (mutually exclusive with the snippets panel via the `SidePanel` enum; shared `WorkspaceSidePanel` frame) to change color theme, font size (live preview while dragging, persisted once on release), and font family without leaving the workspace; controls shared with Settings → Terminal via `TerminalAppearanceControls`, **Terminal theme catalog 35 → 44** — added Kanagawa Dragon/Lotus, Tokyo Night Day, Nord Light, Light Owl, Flexoki Dark/Light, Aura, and Cyberpunk from their authors' published palettes, grouped next to their families in the picker, with visibility-tuned cursor/selection/search colors on the light variants, **Port forwarding runtime (0.1.27)** — saved local/remote/dynamic SOCKS5 rules actually start and stop (`PortForwardService` over the dartssh2 forward APIs behind a testable `TunnelTransport` abstraction); tunnels reuse the host's open SSH client or auto-connect with stored credentials (no terminal tab required), auto-reconnect with 2 s → 30 s exponential backoff keeping local listeners bound across drops, per-rule edit panel, auto-start on launch, live connection counters, inline error reporting, **SSH Agent Forwarding (0.1.28, #49)** — per-host toggle (like `ssh -A`); forwarded `auth-agent@openssh.com` channels are served by the local system agent (`SSH_AUTH_SOCK` / Windows OpenSSH agent pipe) with fallback to app-Keychain keys when no system agent is running; requested on shell channels only, server refusal shows a terminal warning instead of killing the session (`AgentForwardingHandler`, `SystemAgentProxy.roundtrip`, dartssh2 fork agent-channel support), **Strict KEX (0.1.29)** — CVE-2023-48795 "Terrapin" mitigation in the dartssh2 fork: sequence numbers reset after every NEWKEYS, non-KEX messages during the initial exchange terminate the connection, KEXINIT must be the first packet, **Quick wins (0.1.29)** — middle-click closes unpinned session tabs; right-click context menu on port-forward rules with Duplicate (new id, "(copy)" label, auto-start off); distro-level OS icons (`/etc/os-release` ID → ubuntu/debian/fedora/centos/rocky/alma/alpine/amazon/arch/suse/redhat glyphs) on the hosts dashboard and SSH session tabs (`os_detection.dart`, `SessionTab` extracted from main_screen); empty-password SSH behavior locked in by tests (blank passwords are never persisted; connect sends ''), **SFTP permissions editor + unified entry context menu (0.1.29)** — chmod dialog (9-checkbox rwx grid two-way synced with a validated octal field: octal-only input, 3–4 digits, Apply gated while invalid; unknown current permissions fall back to stat() then warn and gate Apply instead of offering 000) on both the remote SFTP and local panels; `SftpFileOpsService.chmod` with a hardened recursive walk (entries with omitted modes classified via lstat, symlinks never followed — SETSTAT would chmod the target, directory modes applied post-order so a restrictive mode can't lock the walk out, file chmods batched 8-wide) sharing its `listWalkChildren` classification with recursive delete; st_mode carried on `SftpEntry`/`LocalEntry` from listing/scan time (no blocking stat at dialog-open); one shared `EntryContextMenu` for both panels (Open / Open with / View / Edit / Copy to target with up-front feasibility reasons incl. the same-folder block / Refresh / New folder / Permissions / Rename / Delete) wired through the dual-panel transfer matrix; shared app-launch helpers extracted to `util/app_launcher.dart`, **Bulk action panel (0.1.30)** — SELECT mode on the hosts dashboard (per-card checkboxes, filter-aware Select all, Esc to exit) with Connect all (skips already-connected hosts, confirms before opening more than 5 tabs), Run command in parallel (free text or snippet; bounded concurrency, 30 s per-host timeout, per-host failure isolation; per-host exit code / duration / expandable stdout+stderr; a Diff tab groups identical outputs against a promotable baseline and side-by-side compares any two hosts), and Push files to one remote path on every host (destination created if missing, per-host byte progress, cancel); closing a dialog mid-run confirms, cancels queued hosts and lets in-flight operations record their real result, **Dashboard grid & list view + sorting (0.1.30)** — card grid ↔ compact one-line list toggle and a sort dropdown (name / creation date / hostname, asc/desc), both persisted across restarts; default order Name A–Z, **Agent forwarding observability (0.1.30)** — pre-connect agent status line in the host panel (system agent identities / app-Keychain fallback / nothing to serve), live per-session key icon on the session tab (grey ready / green active / orange Keychain fallback / red refused), and a notification-bell item with tap-to-jump when the server refuses forwarding, **Connection Chain editor (0.1.30)** — Termius-style visual chain replacing the jump-host dropdown in the host panel (bastion card → arrow → destination, searchable Add-a-Host picker, agent-forwarding key icon, Clear; `HostChainEditor`, single hop), **Jump host on auto-connect paths (0.1.30)** — `ensureClient` (SFTP / exec / port forwarding) now resolves `Host.jumpHostId` via `defaultJumpHostLookup`, so hosts behind a bastion tunnel correctly outside interactive sessions; plus `tool/jump_probe.dart`, a layer-by-layer jump diagnostic CLI, **Session template / per-host preset (0.1.32)** — per-host working dir + env vars delivered invisibly via the shell-integration handshake (tty canonical mode lifted during the payload read so large templates don't truncate at the 4096-byte line cap), startup snippet typed visibly after the DONE sentinel (skipped under tmux and on handshake abort — a re-attach must not replay it), and per-host terminal theme / font / size / TERM / tmux overrides falling back to globals (out-of-range synced values rejected at render); SESSION TEMPLATE section in the host panel with env-key validation incl. duplicate-name detection, **Internal audit log (0.1.32)** — local SQLite (`sqlite3`, WAL, `/audit.db`) trail of connect/disconnect/exec/input events with per-caller source tagging (`bulk` / `devops` / `plugin:` / `input-bar` / broadcast targets each get their own row; internal polls like network stats and OS detection are exempt), secret redaction before insert (key=value incl. quoted multi-word values, Bearer tokens, sshpass/mysql-family `-p`, redis-cli `-a`, URL userinfo — applied to meta error strings too), Audit Log screen with type/time/search filters, keyset pagination, CSV/JSON export (macOS sandbox entitlement upgraded to user-selected read-write), retention pruning at launch (default 90 days, configurable in Settings → Audit) and fail-soft writes that can never break an SSH operation, **In-app SSH key generation (0.1.32)** — generate Ed25519 (pure Dart in the dartssh2 fork: `OpenSSHEd25519KeyPair.generate()` + passphrase-encrypting `toPem` via bcrypt-pbkdf + aes256-ctr, interop-verified against real `ssh-keygen -y`) / RSA-4096 / ECDSA-P256 (via system `ssh-keygen`; options gated by a binary probe) from the Keychain screen; keys land in `Documents/YourSSH/keys` mode 600 with the passphrase in secure storage; copy public key + ssh-copy-id-style **deploy-to-host dialog** (idempotent `grep -qxF`, EXISTS/ADDED markers) — includes the deploy stretch goal, **Local shell picker (0.1.32)** — choose which shell local terminal tabs run: auto-detected per platform (Windows: PowerShell, cmd, PowerShell 7, Git Bash, one profile per WSL distro; macOS/Linux: `$SHELL` + `/etc/shells`) plus user-added custom executables with arguments; default in Settings → Terminal, per-session choice in the new-tab (+) menu, Restart shell reuses the session's shell, an uninstalled default falls back to the platform default with a terminal warning (`ShellProfile`, `shell_detection.dart`), **Multi-hop jump chain (0.1.32)** — bastion → bastion → target for layered networks: `Host.jumpHostIds` ordered chain (legacy `jumpHostId` migrates and is dual-written to JSON for cross-version sync), `SshService` dials hop-over-hop via `forwardLocal` with chain-prefix-keyed client cache, deepest-first refcounted teardown and a cycle guard, per-hop host-key verification; `HostChainEditor` appends/removes hops (persistent Add a Host, per-hop ×, Clear); `ensureClient` and test-connection resolve the full chain, **Recording redaction (0.1.32)** — secrets masked before being written to `.cast`: line-buffered redaction in `RecordingService` reusing `AuditRedactor` unchanged (split at the last newline, start-once 500 ms flush timer, stop flushes the tail; per-line coalescing also strips keystroke timing), global Settings toggle AND per-host opt-out (both default on; pure `effectiveRecordingRedaction` policy sampled once at record start with a fresh `HostProvider` lookup), **In-app RDP client (0.1.33, #44)** — Windows / xrdp desktops as first-class tabs: IronRDP Rust engine via flutter_rust_bridge v2 (`packages/yourssh_rdp`), NLA/TLS/auto security, direct or SSH-tunneled through a saved jump host (full connection chain reused), TOFU certificate pinning enforced in Rust pre-CredSSP (changed cert aborts before credentials are sent), server-negotiated resolution handling, bidirectional clipboard, full keyboard/mouse incl. Ctrl+Alt+Del, OS-level fullscreen with mstsc-style hover pill + auto-exit safety, graceful server-initiated disconnect (MCS ultimatum → clean message, not a raw protocol error), protocol-aware dashboard actions, RDP badge, tab parity (rename/color/pin/restore/audit/notification bell), **Kubernetes panel (0.1.34)** — `KubernetesPanel` with context switcher, `kubectl logs -f`, and 1-click port-forward via `ContainerService.execStream`, **Keyword highlighting (0.1.34)** — user-defined regex rules with per-rule color picker (defaults: Error/Warning/Fail in red/yellow/cyan); rendered in the xterm fork at paint time (`paintKeywordForeground`, `KeywordHighlightRule`) wired through `TerminalView → RenderTerminal`; toggle + rule list + add/edit dialog in Settings → Terminal and the terminal config side panel, **Server monitor panel (0.1.34)** — per-host live dashboard (CPU/mem/disk/uptime/listening ports/firewall) via draggable bottom sheet; accessed from host card hover button and context menu; `SystemStatsService` polls every 5 s via compound SSH exec with sentinel markers (`__CPU1__`/`__CPU2__`/`__MEM__`/`__DISK__`/`__UPTIME__`/`__PORTS__`), `FirewallStatusService` polls every 30 s (ufw/iptables-save/nftables auto-detected); pure parser models `SystemSnapshot` + `FirewallStatus`; requires an active SSH session for the host, **Network discovery (0.1.34)** — scan local network for SSH/RDP hosts without typing an IP; `NetworkDiscoveryService` combines mDNS (`_ssh._tcp` / `_rdp._tcp`) and a TCP port scan on the local subnet; results in a draggable bottom sheet with one-tap Add Host; also linked from the Add Host panel; `DiscoveredHost` model + `multicast_dns` dep, **Import sources expansion (0.1.34)** — five new import formats beyond SSH config/JSON/CSV: PuTTY `.reg` (hex port, URL-decoded names), MobaXterm `.mxtsessions` (SSH type-0 only, multi-`[Bookmarks_N]` files), SecureCRT XML (recursive folder → group path), Ansible INI inventory (`ansible_host`/`ansible_user`/`ansible_port`; `:vars`/`:children` skipped), WinSCP `.ini` (URL-decoded, nested path → label + group); **Known hosts import** from `~/.ssh/known_hosts` via the Known Hosts screen (MD5(key\_blob) fingerprints, duplicate-skip, hashed-entry skip). +Already shipped (not repeated in roadmap): multi-tab terminal, split view, broadcast, recording (asciicast), snippet, SFTP dual-panel, port forwarding, jump host, Supabase sync + P2P LAN, AI chat sidebar with tool calling, plugin system (DevOps / WebTools / Snippets), Cloudflare tunnel, MCP gateway, mail catcher, code editor (Monaco), customizable hotkeys, TOFU known-hosts, **Command Palette (Cmd/Ctrl+K)** — fuzzy search hosts / nav / snippets / actions, **Workspace persistence** — auto-reconnect tabs + layout on relaunch, **Search-in-scrollback (Cmd/Ctrl+F)** — regex, highlights, prev/next navigation, **Script Engine plugin system** — disk-based JS plugins via QuickJS FFI, HookBus (terminal.output / terminal.input / session events), SSH/SFTP/Storage/UI bridges, hot-reload file watcher, PermissionGuard + circuit breaker, consent dialog, manager screen + console log viewer, **Import** — paste SSH config / JSON / CSV with per-host include toggles (`parseSshConfig` in `import_panel.dart`), **Host tagging** — comma-separated tags on the `Host` model, editable in host detail and searchable from the dashboard, **Smart filter + multi-dimensional query (0.1.10)** — `HostQuery` parser with `key:value` faceted AND/OR semantics, toggleable facet chips on hosts dashboard, tag-based search, **Terminal sharing / multiplayer (0.1.13)** — share a live SSH session via Supabase Realtime; guests join with a session code, watch or interact in real time; `ShareSessionService`, `ShareProvider`, `ShareEvent`, split-view watch banner, **Advanced tab management** — rename, color tag, pin, drag reorder; all tab metadata persists per host, **Connection health badge (0.1.17)** — live latency-driven dot per session tab (green/amber/red/grey + pulse), hover tooltip with uptime / last-ping / reconnect count; `HealthMonitorService` pings the live channel (`SSHClient.ping`) as sole pinger, 5s timeout surfaces half-open silent drops, **Shell integration (0.1.18)** — injected bash/zsh prompt-hooks emit OSC 7 + OSC 133 captured via xterm `onPrivateOSC`; cwd on the session tab, per-command status gutter (green/red/grey), jump-to-prompt (`Cmd/Ctrl+↑/↓`), and cwd-aware path autocomplete in the input bar; auto-on with per-host + global opt-out, **In-app updates (0.1.19)** — checks GitHub `releases/latest` on launch (24h debounce) + manual check in Settings; since 0.1.29 also re-checks while the app stays running (6h periodic timer + window-focus check, same 24h debounce) so the notification bell picks up new releases without a restart; dismissible update banner + Settings Updates section; downloads the correct OS/arch artifact and hands off to the OS installer (assisted flow, unsigned-app friendly: macOS strips quarantine + opens the DMG, Windows runs the installer, Linux opens the package); falls back to the Releases page when no artifact matches (e.g. Intel Mac); `UpdateService` + `UpdateProvider` + `UpdateBanner`, **Sudo SFTP (0.1.20)** — per-host SFTP mode running the entire SFTP session as root through `sudo` over an exec channel (WinSCP-style); distro auto-detection, `NOPASSWD` guidance, root badge on elevated panels, **External edit + Open with… (0.1.20–0.1.21)** — open remote files with the OS default app or any installed app (hover submenu; per-OS discovery via `NSWorkspace` / XDG `.desktop` / Windows registry); the local copy is watched and auto-uploaded on every save; plain-text editor fallback where Monaco is unavailable, **SFTP View mode (0.1.21)** — read-only preview separate from Edit, **Invisible shell-integration injection (0.1.21)** — bracketed-paste readiness detection + `read -rs` two-phase handshake delivers the OSC hook installer without ever echoing into the terminal or recordings, **Terminal snippets panel (0.1.21)** — collapsible right-side panel in the terminal workspace to browse/search/copy/run snippets against the active pane, backed by the new plugin `sendInput` API, **Unified terminal tabs (0.1.24)** — local shell sessions are first-class tabs in the global top bar (`TerminalSession` model), split into panes alongside SSH, recordable to asciicast, **SFTP two-panel with switchable sources (0.1.24)** — per-panel Local/host source chip, unified headers (filter + actions), clickable breadcrumbs (#41), workspace kept alive across tab switches (#42), **Terminal copy/paste UX (0.1.24)** — selection-gated Ctrl+C copy (SIGINT preserved), Ctrl(+Shift)+V paste, right-click Copy/Paste/Select All menu, middle-click paste (#43), readable semi-transparent selection colors in all themes (#40), **Notification bell (0.1.25)** — bell in the top tab bar with unread badge + anchored popover; update-available items carry a one-click Update button, unexpected session drops (no pending auto-reconnect) are collected per session; mark-read on open, per-item dismiss, clear all; in-memory `NotificationCenterProvider` (deduped, capped at 50), **Terminal appearance side panel** — tune icon in the terminal toolbar opens a right-side panel (mutually exclusive with the snippets panel via the `SidePanel` enum; shared `WorkspaceSidePanel` frame) to change color theme, font size (live preview while dragging, persisted once on release), and font family without leaving the workspace; controls shared with Settings → Terminal via `TerminalAppearanceControls`, **Terminal theme catalog 35 → 44** — added Kanagawa Dragon/Lotus, Tokyo Night Day, Nord Light, Light Owl, Flexoki Dark/Light, Aura, and Cyberpunk from their authors' published palettes, grouped next to their families in the picker, with visibility-tuned cursor/selection/search colors on the light variants, **Port forwarding runtime (0.1.27)** — saved local/remote/dynamic SOCKS5 rules actually start and stop (`PortForwardService` over the dartssh2 forward APIs behind a testable `TunnelTransport` abstraction); tunnels reuse the host's open SSH client or auto-connect with stored credentials (no terminal tab required), auto-reconnect with 2 s → 30 s exponential backoff keeping local listeners bound across drops, per-rule edit panel, auto-start on launch, live connection counters, inline error reporting, **SSH Agent Forwarding (0.1.28, #49)** — per-host toggle (like `ssh -A`); forwarded `auth-agent@openssh.com` channels are served by the local system agent (`SSH_AUTH_SOCK` / Windows OpenSSH agent pipe) with fallback to app-Keychain keys when no system agent is running; requested on shell channels only, server refusal shows a terminal warning instead of killing the session (`AgentForwardingHandler`, `SystemAgentProxy.roundtrip`, dartssh2 fork agent-channel support), **Strict KEX (0.1.29)** — CVE-2023-48795 "Terrapin" mitigation in the dartssh2 fork: sequence numbers reset after every NEWKEYS, non-KEX messages during the initial exchange terminate the connection, KEXINIT must be the first packet, **Quick wins (0.1.29)** — middle-click closes unpinned session tabs; right-click context menu on port-forward rules with Duplicate (new id, "(copy)" label, auto-start off); distro-level OS icons (`/etc/os-release` ID → ubuntu/debian/fedora/centos/rocky/alma/alpine/amazon/arch/suse/redhat glyphs) on the hosts dashboard and SSH session tabs (`os_detection.dart`, `SessionTab` extracted from main_screen); empty-password SSH behavior locked in by tests (blank passwords are never persisted; connect sends ''), **SFTP permissions editor + unified entry context menu (0.1.29)** — chmod dialog (9-checkbox rwx grid two-way synced with a validated octal field: octal-only input, 3–4 digits, Apply gated while invalid; unknown current permissions fall back to stat() then warn and gate Apply instead of offering 000) on both the remote SFTP and local panels; `SftpFileOpsService.chmod` with a hardened recursive walk (entries with omitted modes classified via lstat, symlinks never followed — SETSTAT would chmod the target, directory modes applied post-order so a restrictive mode can't lock the walk out, file chmods batched 8-wide) sharing its `listWalkChildren` classification with recursive delete; st_mode carried on `SftpEntry`/`LocalEntry` from listing/scan time (no blocking stat at dialog-open); one shared `EntryContextMenu` for both panels (Open / Open with / View / Edit / Copy to target with up-front feasibility reasons incl. the same-folder block / Refresh / New folder / Permissions / Rename / Delete) wired through the dual-panel transfer matrix; shared app-launch helpers extracted to `util/app_launcher.dart`, **Bulk action panel (0.1.30)** — SELECT mode on the hosts dashboard (per-card checkboxes, filter-aware Select all, Esc to exit) with Connect all (skips already-connected hosts, confirms before opening more than 5 tabs), Run command in parallel (free text or snippet; bounded concurrency, 30 s per-host timeout, per-host failure isolation; per-host exit code / duration / expandable stdout+stderr; a Diff tab groups identical outputs against a promotable baseline and side-by-side compares any two hosts), and Push files to one remote path on every host (destination created if missing, per-host byte progress, cancel); closing a dialog mid-run confirms, cancels queued hosts and lets in-flight operations record their real result, **Dashboard grid & list view + sorting (0.1.30)** — card grid ↔ compact one-line list toggle and a sort dropdown (name / creation date / hostname, asc/desc), both persisted across restarts; default order Name A–Z, **Agent forwarding observability (0.1.30)** — pre-connect agent status line in the host panel (system agent identities / app-Keychain fallback / nothing to serve), live per-session key icon on the session tab (grey ready / green active / orange Keychain fallback / red refused), and a notification-bell item with tap-to-jump when the server refuses forwarding, **Connection Chain editor (0.1.30)** — Termius-style visual chain replacing the jump-host dropdown in the host panel (bastion card → arrow → destination, searchable Add-a-Host picker, agent-forwarding key icon, Clear; `HostChainEditor`, single hop), **Jump host on auto-connect paths (0.1.30)** — `ensureClient` (SFTP / exec / port forwarding) now resolves `Host.jumpHostId` via `defaultJumpHostLookup`, so hosts behind a bastion tunnel correctly outside interactive sessions; plus `tool/jump_probe.dart`, a layer-by-layer jump diagnostic CLI, **Session template / per-host preset (0.1.32)** — per-host working dir + env vars delivered invisibly via the shell-integration handshake (tty canonical mode lifted during the payload read so large templates don't truncate at the 4096-byte line cap), startup snippet typed visibly after the DONE sentinel (skipped under tmux and on handshake abort — a re-attach must not replay it), and per-host terminal theme / font / size / TERM / tmux overrides falling back to globals (out-of-range synced values rejected at render); SESSION TEMPLATE section in the host panel with env-key validation incl. duplicate-name detection, **Internal audit log (0.1.32)** — local SQLite (`sqlite3`, WAL, `/audit.db`) trail of connect/disconnect/exec/input events with per-caller source tagging (`bulk` / `devops` / `plugin:` / `input-bar` / broadcast targets each get their own row; internal polls like network stats and OS detection are exempt), secret redaction before insert (key=value incl. quoted multi-word values, Bearer tokens, sshpass/mysql-family `-p`, redis-cli `-a`, URL userinfo — applied to meta error strings too), Audit Log screen with type/time/search filters, keyset pagination, CSV/JSON export (macOS sandbox entitlement upgraded to user-selected read-write), retention pruning at launch (default 90 days, configurable in Settings → Audit) and fail-soft writes that can never break an SSH operation, **In-app SSH key generation (0.1.32)** — generate Ed25519 (pure Dart in the dartssh2 fork: `OpenSSHEd25519KeyPair.generate()` + passphrase-encrypting `toPem` via bcrypt-pbkdf + aes256-ctr, interop-verified against real `ssh-keygen -y`) / RSA-4096 / ECDSA-P256 (via system `ssh-keygen`; options gated by a binary probe) from the Keychain screen; keys land in `Documents/YourSSH/keys` mode 600 with the passphrase in secure storage; copy public key + ssh-copy-id-style **deploy-to-host dialog** (idempotent `grep -qxF`, EXISTS/ADDED markers) — includes the deploy stretch goal, **Local shell picker (0.1.32)** — choose which shell local terminal tabs run: auto-detected per platform (Windows: PowerShell, cmd, PowerShell 7, Git Bash, one profile per WSL distro; macOS/Linux: `$SHELL` + `/etc/shells`) plus user-added custom executables with arguments; default in Settings → Terminal, per-session choice in the new-tab (+) menu, Restart shell reuses the session's shell, an uninstalled default falls back to the platform default with a terminal warning (`ShellProfile`, `shell_detection.dart`), **Multi-hop jump chain (0.1.32)** — bastion → bastion → target for layered networks: `Host.jumpHostIds` ordered chain (legacy `jumpHostId` migrates and is dual-written to JSON for cross-version sync), `SshService` dials hop-over-hop via `forwardLocal` with chain-prefix-keyed client cache, deepest-first refcounted teardown and a cycle guard, per-hop host-key verification; `HostChainEditor` appends/removes hops (persistent Add a Host, per-hop ×, Clear); `ensureClient` and test-connection resolve the full chain, **Recording redaction (0.1.32)** — secrets masked before being written to `.cast`: line-buffered redaction in `RecordingService` reusing `AuditRedactor` unchanged (split at the last newline, start-once 500 ms flush timer, stop flushes the tail; per-line coalescing also strips keystroke timing), global Settings toggle AND per-host opt-out (both default on; pure `effectiveRecordingRedaction` policy sampled once at record start with a fresh `HostProvider` lookup), **In-app RDP client (0.1.33, #44)** — Windows / xrdp desktops as first-class tabs: IronRDP Rust engine via flutter_rust_bridge v2 (`packages/yourssh_rdp`), NLA/TLS/auto security, direct or SSH-tunneled through a saved jump host (full connection chain reused), TOFU certificate pinning enforced in Rust pre-CredSSP (changed cert aborts before credentials are sent), server-negotiated resolution handling, bidirectional clipboard, full keyboard/mouse incl. Ctrl+Alt+Del, OS-level fullscreen with mstsc-style hover pill + auto-exit safety, graceful server-initiated disconnect (MCS ultimatum → clean message, not a raw protocol error), protocol-aware dashboard actions, RDP badge, tab parity (rename/color/pin/restore/audit/notification bell), **Kubernetes panel (0.1.34)** — `KubernetesPanel` with context switcher, `kubectl logs -f`, and 1-click port-forward via `ContainerService.execStream`, **Keyword highlighting (0.1.34)** — user-defined regex rules with per-rule color picker (defaults: Error/Warning/Fail in red/yellow/cyan); rendered in the xterm fork at paint time (`paintKeywordForeground`, `KeywordHighlightRule`) wired through `TerminalView → RenderTerminal`; toggle + rule list + add/edit dialog in Settings → Terminal and the terminal config side panel, **Server monitor panel (0.1.34)** — per-host live dashboard (CPU/mem/disk/uptime/listening ports/firewall) via draggable bottom sheet; accessed from host card hover button and context menu; `SystemStatsService` polls every 5 s via compound SSH exec with sentinel markers (`__CPU1__`/`__CPU2__`/`__MEM__`/`__DISK__`/`__UPTIME__`/`__PORTS__`), `FirewallStatusService` polls every 30 s (ufw/iptables-save/nftables auto-detected); pure parser models `SystemSnapshot` + `FirewallStatus`; requires an active SSH session for the host, **Network discovery (0.1.34)** — scan local network for SSH/RDP hosts without typing an IP; `NetworkDiscoveryService` combines mDNS (`_ssh._tcp` / `_rdp._tcp`) and a TCP port scan on the local subnet; results in a draggable bottom sheet with one-tap Add Host; also linked from the Add Host panel; `DiscoveredHost` model + `multicast_dns` dep, **Import sources expansion (0.1.34)** — five new import formats beyond SSH config/JSON/CSV: PuTTY `.reg` (hex port, URL-decoded names), MobaXterm `.mxtsessions` (SSH type-0 only, multi-`[Bookmarks_N]` files), SecureCRT XML (recursive folder → group path), Ansible INI inventory (`ansible_host`/`ansible_user`/`ansible_port`; `:vars`/`:children` skipped), WinSCP `.ini` (URL-decoded, nested path → label + group); **Known hosts import** from `~/.ssh/known_hosts` via the Known Hosts screen (MD5(key\_blob) fingerprints, duplicate-skip, hashed-entry skip), **SFTP breadcrumb path jump (0.1.35, #62)** — inline path editor on the shared `PathBreadcrumb` (edit affordance → type path → Enter; Esc cancels) in both the remote SFTP and local panels; remote input normalized to absolute POSIX, **macOS universal build (0.1.35, #64)** — Intel + Apple Silicon from one arm64+x86_64 artifact: `build.sh` lipos both Rust dylib targets, the release workflow asserts both arches via `lipo -archs`, and the updater matches the universal DMG on both archs (browser fallback on pre-universal releases for Intel), **Perf & size pass (0.1.35, #63)** — memoized keyword-rule compilation in `SettingsProvider`, `setActive` equality guard, direct-buffer agent message build; dropped unused `local_auth` dep and MesloLGS NF italic faces (−4.8 MB per bundle); `--split-debug-info` on all desktop release builds with per-release symbols zips. --- @@ -71,9 +71,9 @@ _All P0 items shipped. See P1 for next priorities._ ## Top 3 suggestions for the next sprint -1. **Kubernetes panel completion** (P0 #1) — context switcher + `logs -f` + 1-click port-forward; the container browser shipped in 0.1.12, finishing the K8s story is the clearest next DevOps milestone. -2. **Connection proxy support** (P1 Security) — HTTP CONNECT / SOCKS5 per host for restricted networks; the natural complement to the just-shipped multi-hop jump chain. -3. **OSC 52 clipboard** (P1 Terminal UX) — let remote apps (tmux, vim) write to the local clipboard through the escape sequence; xterm fork addition, opt-in per host for safety; small, focused, high-visibility with no server-side requirements. +1. **Connection proxy support** (P1 Security) — HTTP CONNECT / SOCKS5 per host for restricted networks; the natural complement to the shipped multi-hop jump chain. +2. **OSC 52 clipboard** (P1 Terminal UX) — let remote apps (tmux, vim) write to the local clipboard through the escape sequence; xterm fork addition, opt-in per host for safety; small, focused, high-visibility with no server-side requirements. +3. **Docker panel completion** (P1 Workflow) — logs, restart/stop, Compose awareness; finishes the container story (browser + exec shipped 0.1.12, K8s logs + port-forward shipped 0.1.34). --- diff --git a/docs/wiki/User-Guide-Getting-Started.md b/docs/wiki/User-Guide-Getting-Started.md index 02a873ea..8b6dfdec 100644 --- a/docs/wiki/User-Guide-Getting-Started.md +++ b/docs/wiki/User-Guide-Getting-Started.md @@ -8,7 +8,7 @@ YourSSH is a dark-theme SSH client for macOS, Windows, and Linux. Install it, ad ### macOS -1. Download `YourSSH-x.x.x-macOS-arm64.dmg` from the [Releases page](https://github.com/thangnm93/yourssh/releases). +1. Download `YourSSH-x.x.x-macOS-universal.dmg` from the [Releases page](https://github.com/thangnm93/yourssh/releases) — one build for both Apple Silicon and Intel Macs. (Releases before 0.1.35 shipped an arm64-only `-macOS-arm64.dmg`.) 2. Open the `.dmg` and drag **YourSSH** to `/Applications`. 3. **First launch only:** macOS may block the app because it is not yet notarized. Right-click → **Open** → **Open** in the dialog. You only need to do this once. diff --git a/docs/wiki/User-Guide-SFTP.md b/docs/wiki/User-Guide-SFTP.md index 2c84055d..5abb329d 100644 --- a/docs/wiki/User-Guide-SFTP.md +++ b/docs/wiki/User-Guide-SFTP.md @@ -14,6 +14,7 @@ You can also open SFTP for a specific session from the session toolbar. - Click a folder to open it. - Click the breadcrumb trail to jump up. +- **Go to path**: click the edit icon next to the breadcrumb (or its tooltip "Go to path"), type or paste any path, and press **Enter** to jump straight there — **Esc** cancels. Works on both the remote and local panels. - Press **Backspace** or the **←** button to go up one level. ## Transferring Files From ffbdaa4d5e04470fd2cf35741323db9143fa6348 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 11 Jun 2026 23:09:45 +0700 Subject: [PATCH 2/8] fix(xterm): report wheel buttons as 64-67 and fix legacy row byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wheel up/down were reported with button codes 68/69 (flag 64 added to the X11 button number instead of its low two bits), which no application recognizes — mouse-wheel scrolling was dead inside every mouse-aware TUI (claude CLI, htop, vim mouse=a, lazygit, tmux with mouse on). Wheel buttons are now encoded as 64-67 per the xterm spec. Normal/UTF report mode also added an extra +1 to the row byte, placing every mouse event one row below the pointer. Closes #65 --- app/test/widgets/xterm_mouse_report_test.dart | 94 +++++++++++++++++++ packages/xterm/lib/src/core/mouse/button.dart | 19 ++-- .../xterm/lib/src/core/mouse/reporter.dart | 4 +- 3 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 app/test/widgets/xterm_mouse_report_test.dart diff --git a/app/test/widgets/xterm_mouse_report_test.dart b/app/test/widgets/xterm_mouse_report_test.dart new file mode 100644 index 00000000..5ceb316a --- /dev/null +++ b/app/test/widgets/xterm_mouse_report_test.dart @@ -0,0 +1,94 @@ +// Tests for the xterm fork's mouse report encoding — wheel scrolling inside +// mouse-aware TUIs (claude, htop, vim mouse=a, tmux mouse on) depends on the +// exact button codes: wheel up/down must be reported as 64/65 (flag 64 + low +// two bits of X11 buttons 4/5), in SGR form `ESC [ < 64 ; x ; y M`. +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; + +void main() { + Terminal mouseTerminal({String reportMode = '\x1b[?1006h'}) { + final terminal = Terminal(); + // Application enables all-motion tracking (1003) + the report mode. + terminal.write('\x1b[?1003h$reportMode'); + return terminal; + } + + String? lastOutput(Terminal terminal, void Function() act) { + String? out; + terminal.onOutput = (data) => out = data; + act(); + return out; + } + + test('SGR wheel up reports button 64', () { + final terminal = mouseTerminal(); + final out = lastOutput(terminal, () { + final handled = terminal.mouseInput( + TerminalMouseButton.wheelUp, + TerminalMouseButtonState.down, + const CellOffset(4, 9), + ); + expect(handled, isTrue); + }); + expect(out, '\x1b[<64;5;10M'); + }); + + test('SGR wheel down reports button 65', () { + final terminal = mouseTerminal(); + final out = lastOutput(terminal, () { + terminal.mouseInput( + TerminalMouseButton.wheelDown, + TerminalMouseButtonState.down, + const CellOffset(0, 0), + ); + }); + expect(out, '\x1b[<65;1;1M'); + }); + + test('normal-mode wheel up encodes 32+64 with 1-based coords', () { + final terminal = mouseTerminal(reportMode: ''); // default normal mode + final out = lastOutput(terminal, () { + terminal.mouseInput( + TerminalMouseButton.wheelUp, + TerminalMouseButtonState.down, + const CellOffset(2, 3), + ); + }); + // ESC [ M, button byte 32+64=96 ('`'), col 32+3=35 ('#'), row 32+4=36 ('$') + expect(out, '\x1b[M\x60#\$'); + }); + + test('left click in SGR mode reports button 0 press and release', () { + final terminal = mouseTerminal(); + final down = lastOutput(terminal, () { + terminal.mouseInput( + TerminalMouseButton.left, + TerminalMouseButtonState.down, + const CellOffset(0, 0), + ); + }); + expect(down, '\x1b[<0;1;1M'); + final up = lastOutput(terminal, () { + terminal.mouseInput( + TerminalMouseButton.left, + TerminalMouseButtonState.up, + const CellOffset(0, 0), + ); + }); + expect(up, '\x1b[<0;1;1m'); + }); + + test('wheel is not reported when the app never enabled mouse mode', () { + final terminal = Terminal(); + final out = lastOutput(terminal, () { + final handled = terminal.mouseInput( + TerminalMouseButton.wheelUp, + TerminalMouseButtonState.down, + const CellOffset(0, 0), + ); + expect(handled, isFalse, + reason: 'unhandled wheel falls back to viewport scrolling'); + }); + expect(out, isNull); + }); +} diff --git a/packages/xterm/lib/src/core/mouse/button.dart b/packages/xterm/lib/src/core/mouse/button.dart index 53dd08b0..3e7ddcd6 100644 --- a/packages/xterm/lib/src/core/mouse/button.dart +++ b/packages/xterm/lib/src/core/mouse/button.dart @@ -5,21 +5,24 @@ enum TerminalMouseButton { right(id: 2), - wheelUp(id: 64 + 4, isWheel: true), + // YOURSSH PATCH: wheel buttons are X11 buttons 4-7, encoded as flag 64 plus + // the LOW TWO BITS of the button index (4→0, 5→1, 6→2, 7→3), i.e. 64-67. + // The previous ids (64+4 … 64+7 = 68-71) produced reports no application + // recognizes, so mouse-wheel scrolling was dead inside every mouse-aware + // TUI (claude, htop, vim mouse=a, lazygit, tmux with mouse on, …). + wheelUp(id: 64 + 0, isWheel: true), - wheelDown(id: 64 + 5, isWheel: true), + wheelDown(id: 64 + 1, isWheel: true), - wheelLeft(id: 64 + 6, isWheel: true), + wheelLeft(id: 64 + 2, isWheel: true), - wheelRight(id: 64 + 7, isWheel: true), + wheelRight(id: 64 + 3, isWheel: true), ; /// The id that is used to report a button press or release to the terminal. /// - /// Mouse wheel up / down use button IDs 4 = 0100 (binary) and 5 = 0101 (binary). - /// The bits three and four of the button are transposed by 64 and 128 - /// respectively, when reporting the id of the button and have have to be - /// adjusted correspondingly. + /// Mouse wheel up / down are X11 buttons 4 and 5; in reports they are + /// encoded as 64 (the wheel flag) plus their low two bits (0 and 1). final int id; /// Whether this button is a mouse wheel button. diff --git a/packages/xterm/lib/src/core/mouse/reporter.dart b/packages/xterm/lib/src/core/mouse/reporter.dart index 3417b634..ea0be275 100644 --- a/packages/xterm/lib/src/core/mouse/reporter.dart +++ b/packages/xterm/lib/src/core/mouse/reporter.dart @@ -29,10 +29,12 @@ abstract class MouseReporter { (reportMode == MouseReportMode.utf && x > 2015) ? '\x00' : String.fromCharCode(32 + x); + // YOURSSH PATCH: y is already 1-based here — the extra +1 reported + // every event one row below the pointer. final row = (reportMode == MouseReportMode.normal && y > 223) || (reportMode == MouseReportMode.utf && y > 2015) ? '\x00' - : String.fromCharCode(32 + y + 1); + : String.fromCharCode(32 + y); return "\x1b[M$btn$col$row"; case MouseReportMode.sgr: final buttonID = button.id; From 7756331bbbaf6e935406c6ec8d9121889aa8afe5 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 11 Jun 2026 23:09:55 +0700 Subject: [PATCH 3/8] fix(xterm): compose combining marks into the preceding cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buffer.writeChar wrote every codepoint into its own cell, including zero-width combining marks — decomposed (NFD) Vietnamese text (macOS ls filenames, output of many tools) rendered with one displaced cell per diacritic and a wrong cursor column. A zero-width mark is now canonically composed (NFC, via unorm_dart) with the codepoint in the preceding cell when a precomposed form exists; every Vietnamese letter has one, so Vietnamese NFD coverage is complete. The base cell's style is preserved and wide-char continuation cells are skipped. Marks with no precomposed form keep the legacy own-cell behavior (the cell model stores a single codepoint). Closes #67 --- app/pubspec.lock | 8 ++ .../widgets/xterm_combining_chars_test.dart | 93 +++++++++++++++++++ .../xterm/lib/src/core/buffer/buffer.dart | 42 +++++++++ packages/xterm/pubspec.yaml | 3 + 4 files changed, 146 insertions(+) create mode 100644 app/test/widgets/xterm_combining_chars_test.dart diff --git a/app/pubspec.lock b/app/pubspec.lock index 5ee1f00c..d284e755 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1074,6 +1074,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3" + unorm_dart: + dependency: transitive + description: + name: unorm_dart + sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7" + url: "https://pub.dev" + source: hosted + version: "0.3.2" url_launcher: dependency: "direct main" description: diff --git a/app/test/widgets/xterm_combining_chars_test.dart b/app/test/widgets/xterm_combining_chars_test.dart new file mode 100644 index 00000000..e647a77d --- /dev/null +++ b/app/test/widgets/xterm_combining_chars_test.dart @@ -0,0 +1,93 @@ +// Tests for combining-diacritic handling in the xterm fork's buffer. +// Vietnamese text arrives in two Unicode forms: +// - NFC (precomposed): one codepoint per letter, one cell. Always worked. +// - NFD (decomposed): base char followed by combining marks. macOS +// filenames (ls output!) and many tools emit this form. +// A terminal must merge combining marks into the preceding cell; writing them +// to their own cells breaks rendering and cursor position. +// +// Every non-ASCII char is spelled with explicit \u escapes -- source-file +// literals would silently be NFC and test nothing. +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; + +void main() { + Terminal newTerminal() { + final t = Terminal(); + t.resize(80, 25); + return t; + } + + String rowText(Terminal t, [int row = 0]) => + t.buffer.lines[row].getText().trimRight(); + + // "Tieng Viet" with full diacritics, both Unicode forms. + const nfc = 'Ti\u{1EBF}ng Vi\u{1EC7}t'; + const nfd = 'Tie\u{302}\u{301}ng Vie\u{323}\u{302}t'; + + test('NFC (precomposed) Vietnamese renders one cell per letter', () { + final t = newTerminal(); + t.write(nfc); + expect(rowText(t), nfc); + expect(t.buffer.cursorX, 10); + }); + + test('NFD (decomposed) Vietnamese composes into the preceding cell', () { + final t = newTerminal(); + t.write(nfd); + expect(rowText(t), nfc, + reason: 'combining marks must merge with the base character'); + expect(t.buffer.cursorX, 10, + reason: 'marks must not advance the cursor'); + }); + + test('NFD across many Vietnamese letters', () { + final t = newTerminal(); + // "duong pho ha noi" fully decomposed (canonical mark order). + t.write('\u{111}u\u{31B}o\u{31B}\u{300}ng pho\u{302}\u{301} ha\u{300} no\u{323}\u{302}i'); + expect(rowText(t), '\u{111}\u{1B0}\u{1EDD}ng ph\u{1ED1} h\u{E0} n\u{1ED9}i'); + }); + + test('mark applies to the cell styled by the base char, not cursor style', + () { + final t = newTerminal(); + // Base char written in red, mark arrives after a style reset. + t.write('\x1b[31me\x1b[0m\u{302}'); + expect(rowText(t), '\u{EA}'); // e-circumflex + final line = t.buffer.lines[0]; + expect(line.getForeground(0), isNot(0), + reason: 'composition must preserve the base cell style'); + }); + + test('combining mark at column 0 does not crash', () { + final t = newTerminal(); + t.write('\u{301}abc'); + expect(rowText(t).endsWith('abc'), isTrue); + }); + + test('combining mark after a wide (CJK) character no-ops gracefully', () { + final t = newTerminal(); + t.write('\u{6F22}\u{301}x'); + // The CJK char has no precomposed form with the mark -- the mark keeps + // its own zero-width cell (legacy behavior) and must not corrupt + // neighbors. + expect(rowText(t).contains('x'), isTrue); + expect(t.buffer.cursorX, 4); + }); + + test('zero-width joiner does not destroy preceding text', () { + final t = newTerminal(); + t.write('ab\u{200D}c'); + expect(rowText(t).startsWith('ab'), isTrue); + expect(rowText(t).endsWith('c'), isTrue); + }); + + test('NFD text wraps without losing diacritics at the line boundary', () { + final t = Terminal(); + t.resize(5, 5); + // 6 decomposed letters on a 5-col line -> wraps after 5. + t.write('e\u{302}\u{301}' * 6); + expect(t.buffer.lines[0].getText().trimRight(), '\u{1EBF}' * 5); + expect(t.buffer.lines[1].getText().trimRight(), '\u{1EBF}'); + }); +} diff --git a/packages/xterm/lib/src/core/buffer/buffer.dart b/packages/xterm/lib/src/core/buffer/buffer.dart index 10789382..85e5d913 100644 --- a/packages/xterm/lib/src/core/buffer/buffer.dart +++ b/packages/xterm/lib/src/core/buffer/buffer.dart @@ -1,5 +1,6 @@ import 'dart:math' show max, min; +import 'package:unorm_dart/unorm_dart.dart' as unorm; import 'package:xterm/src/core/buffer/cell_offset.dart'; import 'package:xterm/src/core/buffer/line.dart'; import 'package:xterm/src/core/buffer/range_line.dart'; @@ -110,6 +111,17 @@ class Buffer { codePoint = charset.translate(codePoint); final cellWidth = unicodeV11.wcwidth(codePoint); + + // YOURSSH PATCH: a zero-width combining mark merges into the preceding + // cell when a precomposed (NFC) form exists. Decomposed Vietnamese text + // (macOS filenames in ls output, many tools) was unreadable otherwise: + // every mark occupied its own cell, displacing the diacritics and the + // cursor. Marks with no precomposed form keep the legacy own-cell + // behavior (the cell model stores a single codepoint). + if (cellWidth == 0 && _tryComposeMark(codePoint)) { + return; + } + if (_cursorX >= terminal.viewWidth) { index(); setCursorX(0); @@ -130,6 +142,36 @@ class Buffer { } } + /// Tries to canonically compose the combining [mark] with the codepoint in + /// the cell preceding the cursor. Returns true when the cell was rewritten + /// with the precomposed character (the mark must then NOT be written). + bool _tryComposeMark(int mark) { + var x = _cursorX - 1; + final line = currentLine; + if (x < 0 || x >= line.length) { + return false; // column 0 — nothing to attach to + } + var base = line.getCodePoint(x); + // Skip the continuation cell of a wide character. + if (base == 0 && x >= 1 && line.getWidth(x - 1) == 2) { + x -= 1; + base = line.getCodePoint(x); + } + if (base == 0) { + return false; + } + final composed = + unorm.nfc(String.fromCharCode(base) + String.fromCharCode(mark)); + final runes = composed.runes; + if (runes.length != 1) { + return false; // no precomposed form exists + } + // setCodePoint preserves the cell's own fg/bg/attrs — the mark belongs + // to the base character's style, not the current cursor style. + line.setCodePoint(x, runes.first); + return true; + } + /// The line at the current cursor position. BufferLine get currentLine { return lines[absoluteCursorY]; diff --git a/packages/xterm/pubspec.yaml b/packages/xterm/pubspec.yaml index 945265ef..3b0847eb 100644 --- a/packages/xterm/pubspec.yaml +++ b/packages/xterm/pubspec.yaml @@ -15,6 +15,9 @@ dependencies: flutter: sdk: flutter zmodem: ^0.0.6 + # YOURSSH PATCH: canonical (NFC) composition of combining marks into the + # preceding cell — decomposed Vietnamese text was unreadable otherwise. + unorm_dart: ^0.3.0 dev_dependencies: flutter_test: From 14b0c75ee6579e8c0b55ba6693f04ea0d3079884 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 11 Jun 2026 23:10:05 +0700 Subject: [PATCH 4/8] feat(terminal): scrollback paging keys and Reset Terminal action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift+PageUp / Shift+PageDown page through the scrollback locally (standard terminal-emulator behavior); only in the main buffer, so alternate-screen apps still receive the keys. Terminal.recoverFromStuckState() is the local equivalent of reset for a full-screen app that died uncleanly (crash, kill -9, dropped SSH connection) and left the session trapped in the alternate screen with mouse reporting on — wheel scrolling appeared completely dead at a prompt. Wired to a new right-click 'Reset Terminal' menu item. --- app/lib/widgets/terminal_context_menu.dart | 13 +++++++++++- packages/xterm/lib/src/terminal.dart | 14 +++++++++++++ packages/xterm/lib/src/terminal_view.dart | 23 ++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/app/lib/widgets/terminal_context_menu.dart b/app/lib/widgets/terminal_context_menu.dart index 809c3856..2e1121d8 100644 --- a/app/lib/widgets/terminal_context_menu.dart +++ b/app/lib/widgets/terminal_context_menu.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:xterm/xterm.dart'; /// Actions offered by the terminal right-click menu (issue #43). -enum TerminalMenuAction { copy, paste, selectAll } +enum TerminalMenuAction { copy, paste, selectAll, resetTerminal } /// Shows the Copy / Paste / Select All context menu for a terminal at /// [globalPosition] and performs the chosen action. @@ -41,6 +41,12 @@ Future showTerminalContextMenu({ height: 36, child: const Text('Select All'), ), + const PopupMenuDivider(), + PopupMenuItem( + value: TerminalMenuAction.resetTerminal, + height: 36, + child: const Text('Reset Terminal'), + ), ], ); @@ -60,6 +66,11 @@ Future showTerminalContextMenu({ } case TerminalMenuAction.selectAll: terminalSelectAll(terminal, controller); + case TerminalMenuAction.resetTerminal: + // A full-screen app that died uncleanly can leave the terminal stuck + // in the alternate screen with mouse reporting on — wheel scrolling + // goes dead until recovered (the `reset` command equivalent). + terminal.recoverFromStuckState(); case null: break; } diff --git a/packages/xterm/lib/src/terminal.dart b/packages/xterm/lib/src/terminal.dart index 461e2084..8bc89f4b 100644 --- a/packages/xterm/lib/src/terminal.dart +++ b/packages/xterm/lib/src/terminal.dart @@ -720,6 +720,20 @@ class Terminal with Observable implements TerminalState, EscapeHandler { _buffer = _mainBuffer; } + /// Recovers from terminal state left behind by a full-screen application + /// that exited uncleanly (crash, kill -9, dropped SSH connection): returns + /// to the main screen buffer (where the scrollback lives), turns off mouse + /// reporting so wheel events scroll the viewport again, and re-shows the + /// cursor. Purely local — nothing is sent to the remote application. + void recoverFromStuckState() { + _buffer = _mainBuffer; + _mouseMode = MouseMode.none; + _mouseReportMode = MouseReportMode.normal; + _cursorVisibleMode = true; + _mainBuffer.resetVerticalMargins(); + notifyListeners(); + } + @override void clearAltBuffer() { _altBuffer.clear(); diff --git a/packages/xterm/lib/src/terminal_view.dart b/packages/xterm/lib/src/terminal_view.dart index 75673550..eaf71530 100644 --- a/packages/xterm/lib/src/terminal_view.dart +++ b/packages/xterm/lib/src/terminal_view.dart @@ -417,6 +417,29 @@ class TerminalViewState extends State { return resultOverride; } + // YOURSSH PATCH: Shift+PageUp / Shift+PageDown page through the + // scrollback locally (standard terminal-emulator behavior) instead of + // reaching the shell. Only in the main buffer — alternate-screen apps + // own the keyboard. + if (event is! KeyUpEvent && + HardwareKeyboard.instance.isShiftPressed && + !widget.terminal.isUsingAltBuffer && + (event.logicalKey == LogicalKeyboardKey.pageUp || + event.logicalKey == LogicalKeyboardKey.pageDown)) { + final position = _scrollableKey.currentState?.position; + if (position != null && position.maxScrollExtent > 0) { + final page = position.viewportDimension * 0.9; + final up = event.logicalKey == LogicalKeyboardKey.pageUp; + position.animateTo( + (position.pixels + (up ? -page : page)) + .clamp(0.0, position.maxScrollExtent), + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + ); + return KeyEventResult.handled; + } + } + // ignore: invalid_use_of_protected_member final shortcutResult = _shortcutManager.handleKeypress( focusNode.context!, From 70a884cff22bf242d20709144497917c8611b6dc Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 11 Jun 2026 23:10:20 +0700 Subject: [PATCH 5/8] perf(xterm): per-line picture cache; hold scroll position at the cap Rendering painted every visible cell as its own Paragraph every frame (~10k drawParagraph calls for a 50x180 viewport) and re-ran keyword regexes per frame. Visible lines are now painted via cached per-line recorded Pictures keyed by a new monotonic BufferLine.version, so scrolling and steady output replay O(visible lines) pictures instead: ~7x less per-frame paint work in the included benchmark, with keyword highlights baked into the cached picture (regex runs on line change only). The LRU cache (1024 entries) is invalidated on textStyle/textScaler/theme/font/keyword-rule changes; pixel equivalence with the direct path is covered by tests. Scrollback trim compensation: once the buffer reaches maxLines, each new line trims one from the top while the pixel offset stayed put, so the content a scrolled-up reader was viewing streamed past uncontrollably. The circular buffer now exposes a monotonic droppedLines counter and RenderTerminal shifts the offset by the trimmed amount each layout (clamped at 0; re-baselined on main/alt buffer switches). Closes #66 --- .../widgets/terminal_scroll_probe_test.dart | 208 ++++++++++++++++++ .../xterm_line_picture_cache_test.dart | 148 +++++++++++++ .../widgets/xterm_paint_benchmark_test.dart | 69 ++++++ packages/xterm/lib/src/core/buffer/line.dart | 21 ++ packages/xterm/lib/src/ui/painter.dart | 129 +++++++++++ packages/xterm/lib/src/ui/render.dart | 115 ++++------ .../xterm/lib/src/utils/circular_buffer.dart | 12 + 7 files changed, 632 insertions(+), 70 deletions(-) create mode 100644 app/test/widgets/terminal_scroll_probe_test.dart create mode 100644 app/test/widgets/xterm_line_picture_cache_test.dart create mode 100644 app/test/widgets/xterm_paint_benchmark_test.dart diff --git a/app/test/widgets/terminal_scroll_probe_test.dart b/app/test/widgets/terminal_scroll_probe_test.dart new file mode 100644 index 00000000..8df4ba0d --- /dev/null +++ b/app/test/widgets/terminal_scroll_probe_test.dart @@ -0,0 +1,208 @@ +// Regression tests for TerminalView scrollback scrolling: mouse wheel, +// trackpad pan, holding position while output streams, and scroll-offset +// compensation when the buffer trims at the maxLines cap. +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; + +void main() { + Future pumpTerminal( + WidgetTester tester, + Terminal terminal, + ) async { + final scrollController = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TerminalView( + terminal, + scrollController: scrollController, + autofocus: true, + ), + ), + ), + ); + await tester.pump(); + return scrollController; + } + + void fillLines(Terminal terminal, int count, [String prefix = 'line']) { + for (var i = 0; i < count; i++) { + terminal.write('$prefix $i\r\n'); + } + } + + testWidgets('mouse wheel scrolls scrollback up', (tester) async { + final terminal = Terminal(maxLines: 10000); + fillLines(terminal, 300); + final sc = await pumpTerminal(tester, terminal); + + final max0 = sc.position.maxScrollExtent; + debugPrint('PROBE initial: offset=${sc.offset} max=$max0'); + expect(max0, greaterThan(0), reason: 'scrollback must exist'); + expect(sc.offset, max0, reason: 'should start stuck to bottom'); + + final center = tester.getCenter(find.byType(TerminalView)); + final pointer = TestPointer(1, PointerDeviceKind.mouse); + pointer.hover(center); + await tester.sendEventToBinding( + pointer.scroll(const Offset(0, -100))); // wheel up + await tester.pump(); + debugPrint('PROBE after wheel-up: offset=${sc.offset} max=${sc.position.maxScrollExtent}'); + expect(sc.offset, lessThan(max0), reason: 'wheel up should scroll back'); + }); + + testWidgets('scroll position holds while output streams', (tester) async { + final terminal = Terminal(maxLines: 10000); + fillLines(terminal, 300); + final sc = await pumpTerminal(tester, terminal); + + final center = tester.getCenter(find.byType(TerminalView)); + final pointer = TestPointer(1, PointerDeviceKind.mouse); + pointer.hover(center); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, -200))); + await tester.pump(); + final scrolledBack = sc.offset; + debugPrint('PROBE scrolled back to: $scrolledBack'); + + // Stream more output — viewport must NOT snap back to bottom. + fillLines(terminal, 50, 'stream'); + await tester.pump(); + debugPrint('PROBE after stream: offset=${sc.offset} max=${sc.position.maxScrollExtent}'); + expect(sc.offset, lessThan(sc.position.maxScrollExtent), + reason: 'must not snap to bottom while user reads scrollback'); + }); + + testWidgets('trackpad pan scrolls scrollback up', (tester) async { + final terminal = Terminal(maxLines: 10000); + fillLines(terminal, 300); + final sc = await pumpTerminal(tester, terminal); + final max0 = sc.position.maxScrollExtent; + + final center = tester.getCenter(find.byType(TerminalView)); + final pointer = TestPointer(2, PointerDeviceKind.trackpad); + await tester.sendEventToBinding(pointer.panZoomStart(center)); + await tester.sendEventToBinding( + pointer.panZoomUpdate(center, pan: const Offset(0, 150))); + await tester.sendEventToBinding(pointer.panZoomEnd()); + await tester.pump(); + debugPrint('PROBE after trackpad pan: offset=${sc.offset} max=$max0'); + expect(sc.offset, lessThan(max0), + reason: 'trackpad two-finger scroll should scroll back'); + }); + + testWidgets('scroll-up holds CONTENT when buffer is at maxLines cap', + (tester) async { + // Small cap so the buffer trims from the top while output streams. + final terminal = Terminal(maxLines: 500); + fillLines(terminal, 600); // buffer is now full (trimming active) + final sc = await pumpTerminal(tester, terminal); + + final center = tester.getCenter(find.byType(TerminalView)); + final pointer = TestPointer(1, PointerDeviceKind.mouse); + pointer.hover(center); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, -300))); + await tester.pump(); + + // What content line sits at the top of the viewport right now? + String topLineText() { + final lineHeight = sc.position.viewportDimension > 0 + ? (sc.position.maxScrollExtent + sc.position.viewportDimension) / + terminal.buffer.lines.length + : 1; + final topIdx = (sc.offset / lineHeight).floor(); + return terminal.buffer.lines[topIdx].getText().trim(); + } + + final before = topLineText(); + debugPrint('PROBE top line before stream: "$before" offset=${sc.offset}'); + + // Stream 100 more lines — buffer trims 100 from the top. + fillLines(terminal, 100, 'stream'); + await tester.pump(); + + final after = topLineText(); + debugPrint('PROBE top line after stream: "$after" offset=${sc.offset}'); + expect(after, before, + reason: 'the content the user scrolled to must stay put while ' + 'the buffer trims (otherwise scroll-up is useless during ' + 'fast output once scrollback is full)'); + }); + + testWidgets('Shift+PageUp / Shift+PageDown page through scrollback', + (tester) async { + final terminal = Terminal(maxLines: 10000); + fillLines(terminal, 300); + final sc = await pumpTerminal(tester, terminal); + final max0 = sc.position.maxScrollExtent; + expect(sc.offset, max0); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.pageUp); + await tester.sendKeyUpEvent(LogicalKeyboardKey.pageUp); + await tester.pumpAndSettle(); + final afterPageUp = sc.offset; + expect(afterPageUp, lessThan(max0), + reason: 'Shift+PageUp must scroll the viewport back'); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.pageDown); + await tester.sendKeyUpEvent(LogicalKeyboardKey.pageDown); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pumpAndSettle(); + expect(sc.offset, greaterThan(afterPageUp), + reason: 'Shift+PageDown must scroll back toward the bottom'); + }); + + testWidgets('recoverFromStuckState restores scrolling after a dead TUI', + (tester) async { + final terminal = Terminal(maxLines: 10000); + fillLines(terminal, 300); + final sc = await pumpTerminal(tester, terminal); + + // Simulate a full-screen app that died uncleanly: alt screen + mouse + // reporting left on (the state `reset` would normally clear). + terminal.write('\x1b[?1049h\x1b[?1000h\x1b[?1006h\x1b[?25l'); + await tester.pump(); + expect(terminal.isUsingAltBuffer, isTrue); + + // Wheel does nothing in this state: no scrollback in the alt screen. + final center = tester.getCenter(find.byType(TerminalView)); + final pointer = TestPointer(1, PointerDeviceKind.mouse); + pointer.hover(center); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, -100))); + await tester.pump(); + expect(sc.position.maxScrollExtent, 0, + reason: 'alt screen has no scrollback'); + + terminal.recoverFromStuckState(); + await tester.pump(); + expect(terminal.isUsingAltBuffer, isFalse); + expect(terminal.cursorVisibleMode, isTrue); + + // Back in the main buffer the scrollback is intact and wheel scrolls. + final max0 = sc.position.maxScrollExtent; + expect(max0, greaterThan(0)); + await tester.sendEventToBinding(pointer.scroll(const Offset(0, -100))); + await tester.pump(); + expect(sc.offset, lessThan(max0), + reason: 'wheel must scroll again after recovery'); + }); + + testWidgets('mouse drag does NOT scroll (selection instead)', (tester) async { + // Documents the mouse-drag behavior for completeness. + final terminal = Terminal(maxLines: 10000); + fillLines(terminal, 300); + final sc = await pumpTerminal(tester, terminal); + final max0 = sc.position.maxScrollExtent; + + final center = tester.getCenter(find.byType(TerminalView)); + final gesture = + await tester.startGesture(center, kind: PointerDeviceKind.mouse); + await gesture.moveBy(const Offset(0, 100)); + await gesture.up(); + await tester.pump(); + debugPrint('PROBE after mouse drag: offset=${sc.offset} max=$max0'); + }); +} diff --git a/app/test/widgets/xterm_line_picture_cache_test.dart b/app/test/widgets/xterm_line_picture_cache_test.dart new file mode 100644 index 00000000..8b0d388c --- /dev/null +++ b/app/test/widgets/xterm_line_picture_cache_test.dart @@ -0,0 +1,148 @@ +// Tests for the xterm fork's per-line picture cache (render smoothness fix): +// cache hits on unchanged lines, invalidation on content/style/keyword-rule +// changes, and pixel-equivalence with the direct paintLine path. +import 'dart:ui' as ui; + +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/src/ui/painter.dart'; +import 'package:xterm/xterm.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + TerminalPainter newPainter() => TerminalPainter( + theme: TerminalThemes.defaultTheme, + textStyle: const TerminalStyle(), + textScaler: TextScaler.noScaling, + ); + + BufferLine lineWithText(Terminal terminal, String text) { + terminal.write(text); + return terminal.buffer.lines[terminal.buffer.absoluteCursorY]; + } + + test('unchanged line returns the identical cached picture', () { + final terminal = Terminal(); + final painter = newPainter(); + final line = lineWithText(terminal, 'hello world'); + + final first = painter.getLinePicture(line); + final second = painter.getLinePicture(line); + expect(identical(first, second), isTrue, + reason: 'no content change → cache hit'); + }); + + test('mutating the line re-records the picture', () { + final terminal = Terminal(); + final painter = newPainter(); + final line = lineWithText(terminal, 'hello'); + + final before = painter.getLinePicture(line); + terminal.write(' more'); + final after = painter.getLinePicture(line); + expect(identical(before, after), isFalse, + reason: 'version bump → re-record'); + }); + + test('BufferLine.version bumps on every mutation kind', () { + final line = BufferLine(80); + var last = line.version; + void expectBumped(String what) { + expect(line.version, greaterThan(last), reason: what); + last = line.version; + } + + line.setCell(0, 0x41, 1, CursorStyle.empty); + expectBumped('setCell'); + line.setForeground(0, 123); + expectBumped('setForeground'); + line.setBackground(0, 123); + expectBumped('setBackground'); + line.setAttributes(0, 1); + expectBumped('setAttributes'); + line.setCodePoint(0, 0x42); + expectBumped('setCodePoint'); + line.eraseCell(0, CursorStyle.empty); + expectBumped('eraseCell'); + line.eraseRange(0, 10, CursorStyle.empty); + expectBumped('eraseRange'); + line.removeCells(0, 2); + expectBumped('removeCells'); + line.insertCells(0, 2); + expectBumped('insertCells'); + line.resize(120); + expectBumped('resize'); + line.resetCell(0); + expectBumped('resetCell'); + }); + + test('style / theme / keyword-rule changes invalidate the cache', () { + final terminal = Terminal(); + final painter = newPainter(); + final line = lineWithText(terminal, 'ERROR something'); + + final p0 = painter.getLinePicture(line); + + painter.textStyle = const TerminalStyle(fontSize: 20); + final p1 = painter.getLinePicture(line); + expect(identical(p0, p1), isFalse, reason: 'textStyle change'); + + painter.keywordRules = [ + KeywordHighlightRule( + pattern: RegExp('ERROR'), + background: const Color(0xFFFF0000), + ), + ]; + final p2 = painter.getLinePicture(line); + expect(identical(p1, p2), isFalse, reason: 'keyword rules change'); + }); + + test('cache is bounded (LRU eviction, no unbounded growth)', () { + final terminal = Terminal(maxLines: 3000); + final painter = newPainter(); + for (var i = 0; i < 2000; i++) { + terminal.write('line $i\r\n'); + } + // Paint every line once — must not throw / grow unbounded. + final lines = terminal.buffer.lines; + for (var i = 0; i < lines.length; i++) { + painter.getLinePicture(lines[i]); + } + // Re-request the newest line: still valid (either cached or re-recorded). + final p = painter.getLinePicture(lines[lines.length - 1]); + expect(p, isNotNull); + }); + + testWidgets('picture path is pixel-identical to direct paintLine path', + (tester) async { + await tester.runAsync(() async { + final terminal = Terminal(); + final painter = newPainter(); + terminal.write('hi \x1b[31mred\x1b[0m \x1b[1mbold\x1b[0m wide: 漢字'); + final line = terminal.buffer.lines[terminal.buffer.absoluteCursorY]; + + final width = (painter.cellSize.width * 60).ceil(); + final height = painter.cellSize.height.ceil(); + + Future> rasterize(void Function(ui.Canvas) draw) async { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + draw(canvas); + final image = + await recorder.endRecording().toImage(width, height); + final data = + await image.toByteData(format: ui.ImageByteFormat.rawRgba); + return data!.buffer.asUint8List(); + } + + final direct = await rasterize( + (canvas) => painter.paintLine(canvas, Offset.zero, line)); + final viaPicture = await rasterize( + (canvas) => canvas.drawPicture(painter.getLinePicture(line))); + + expect(viaPicture, equals(direct), + reason: 'cached picture must paint exactly what paintLine paints'); + }); + }); +} diff --git a/app/test/widgets/xterm_paint_benchmark_test.dart b/app/test/widgets/xterm_paint_benchmark_test.dart new file mode 100644 index 00000000..ca36a833 --- /dev/null +++ b/app/test/widgets/xterm_paint_benchmark_test.dart @@ -0,0 +1,69 @@ +// Microbenchmark: per-frame paint cost of a full viewport, direct per-cell +// path vs the cached line-picture path. Not a strict perf test (no assert on +// timings) — prints numbers for the changelog/PR. +import 'dart:ui' as ui; + +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/src/ui/painter.dart'; +import 'package:xterm/xterm.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('paint benchmark: direct vs picture cache', () { + const cols = 180; + const rows = 50; + const frames = 120; + + final terminal = Terminal(maxLines: 1000); + terminal.resize(cols, rows); + for (var i = 0; i < rows; i++) { + terminal.write( + 'job-$i \x1b[32mOK\x1b[0m ${'x' * (cols - 20)}\r\n'); + } + final lines = terminal.buffer.lines; + + final painter = TerminalPainter( + theme: TerminalThemes.defaultTheme, + textStyle: const TerminalStyle(), + textScaler: TextScaler.noScaling, + ); + + // Warm the paragraph cache for both paths. + for (var i = 0; i < lines.length; i++) { + painter.paintLine(ui.Canvas(ui.PictureRecorder()), Offset.zero, lines[i]); + } + + final sw1 = Stopwatch()..start(); + for (var f = 0; f < frames; f++) { + final canvas = ui.Canvas(ui.PictureRecorder()); + for (var i = 0; i < lines.length; i++) { + painter.paintLine( + canvas, Offset(0, i * painter.cellSize.height), lines[i]); + } + } + sw1.stop(); + + final sw2 = Stopwatch()..start(); + for (var f = 0; f < frames; f++) { + final canvas = ui.Canvas(ui.PictureRecorder()); + for (var i = 0; i < lines.length; i++) { + canvas.save(); + canvas.translate(0, i * painter.cellSize.height); + canvas.drawPicture(painter.getLinePicture(lines[i])); + canvas.restore(); + } + } + sw2.stop(); + + final perFrameDirect = sw1.elapsedMicroseconds / frames / 1000; + final perFrameCached = sw2.elapsedMicroseconds / frames / 1000; + // ignore: avoid_print + print('BENCH ${rows}x$cols, $frames frames:'); + // ignore: avoid_print + print(' direct per-cell : ${perFrameDirect.toStringAsFixed(2)} ms/frame'); + // ignore: avoid_print + print(' line-picture LRU : ${perFrameCached.toStringAsFixed(2)} ms/frame'); + }); +} diff --git a/packages/xterm/lib/src/core/buffer/line.dart b/packages/xterm/lib/src/core/buffer/line.dart index 6e72114d..0b88b7ee 100644 --- a/packages/xterm/lib/src/core/buffer/line.dart +++ b/packages/xterm/lib/src/core/buffer/line.dart @@ -33,6 +33,13 @@ class BufferLine with IndexedItem { int get length => _length; + /// Monotonic content revision, bumped on every mutation. Lets renderers + /// cache per-line artifacts (e.g. recorded pictures) and cheaply detect + /// when a line needs re-rendering. + var _version = 0; + + int get version => _version; + final _anchors = []; List get anchors => _anchors; @@ -70,6 +77,7 @@ class BufferLine with IndexedItem { } CellData createCellData(int index) { + _version++; final cellData = CellData.empty(); final offset = index * _cellSize; _data[offset + _cellForeground] = cellData.foreground; @@ -80,18 +88,22 @@ class BufferLine with IndexedItem { } void setForeground(int index, int value) { + _version++; _data[index * _cellSize + _cellForeground] = value; } void setBackground(int index, int value) { + _version++; _data[index * _cellSize + _cellBackground] = value; } void setAttributes(int index, int value) { + _version++; _data[index * _cellSize + _cellAttributes] = value; } void setContent(int index, int value) { + _version++; _data[index * _cellSize + _cellContent] = value; } @@ -101,6 +113,7 @@ class BufferLine with IndexedItem { } void setCell(int index, int char, int witdh, CursorStyle style) { + _version++; final offset = index * _cellSize; _data[offset + _cellForeground] = style.foreground; _data[offset + _cellBackground] = style.background; @@ -109,6 +122,7 @@ class BufferLine with IndexedItem { } void setCellData(int index, CellData cellData) { + _version++; final offset = index * _cellSize; _data[offset + _cellForeground] = cellData.foreground; _data[offset + _cellBackground] = cellData.background; @@ -117,6 +131,7 @@ class BufferLine with IndexedItem { } void eraseCell(int index, CursorStyle style) { + _version++; final offset = index * _cellSize; _data[offset + _cellForeground] = style.foreground; _data[offset + _cellBackground] = style.background; @@ -125,6 +140,7 @@ class BufferLine with IndexedItem { } void resetCell(int index) { + _version++; final offset = index * _cellSize; _data[offset + _cellForeground] = 0; _data[offset + _cellBackground] = 0; @@ -154,6 +170,7 @@ class BufferLine with IndexedItem { /// Remove [count] cells starting at [start]. Cells that are empty after the /// removal are filled with [style]. void removeCells(int start, int count, [CursorStyle? style]) { + _version++; assert(start >= 0 && start < _length); assert(count >= 0 && start + count <= _length); @@ -191,6 +208,7 @@ class BufferLine with IndexedItem { /// Inserts [count] cells at [start]. New cells are initialized with [style]. void insertCells(int start, int count, [CursorStyle? style]) { + _version++; style ??= CursorStyle.empty; if (start > 0 && getWidth(start - 1) == 2) { @@ -236,6 +254,8 @@ class BufferLine with IndexedItem { return; } + _version++; + if (length > _length) { final newBufferSize = _calcCapacity(length) * _cellSize; @@ -287,6 +307,7 @@ class BufferLine with IndexedItem { /// Copies [len] cells from [src] starting at [srcCol] to [dstCol] at this /// line. void copyFrom(BufferLine src, int srcCol, int dstCol, int len) { + _version++; resize(dstCol + len); // data.setRange( diff --git a/packages/xterm/lib/src/ui/painter.dart b/packages/xterm/lib/src/ui/painter.dart index bbb87ce4..e3b3110f 100644 --- a/packages/xterm/lib/src/ui/painter.dart +++ b/packages/xterm/lib/src/ui/painter.dart @@ -1,10 +1,20 @@ import 'dart:ui'; import 'package:flutter/painting.dart'; +import 'package:xterm/src/ui/keyword_highlight.dart'; import 'package:xterm/src/ui/palette_builder.dart'; import 'package:xterm/src/ui/paragraph_cache.dart'; import 'package:xterm/xterm.dart'; +/// A recorded picture of one buffer line (text + keyword highlights), painted +/// at origin, valid for a specific [BufferLine.version]. +class _LinePicture { + _LinePicture(this.picture, this.version); + + final Picture picture; + final int version; +} + /// Encapsulates the logic for painting various terminal elements. class TerminalPainter { TerminalPainter({ @@ -26,6 +36,17 @@ class TerminalPainter { /// [_textStyle] is changed, or when the system font changes. final _paragraphCache = ParagraphCache(10240); + /// Recorded pictures of recently painted lines, keyed by line identity and + /// re-recorded only when the line's [BufferLine.version] changes. Turns the + /// steady-state per-frame paint cost from O(visible cells) paragraph draws + /// into O(visible lines) picture replays — the difference between janky and + /// smooth scrolling/streaming. Insertion-ordered for LRU eviction. + final _linePictures = {}; + + /// Upper bound on cached line pictures (~20 viewports of 50 lines); beyond + /// this the least recently used entries are disposed. + static const _linePictureCacheLimit = 1024; + TerminalStyle get textStyle => _textStyle; TerminalStyle _textStyle; set textStyle(TerminalStyle value) { @@ -33,6 +54,7 @@ class TerminalPainter { _textStyle = value; _cellSize = _measureCharSize(); _paragraphCache.clear(); + _invalidateLinePictures(); } TextScaler get textScaler => _textScaler; @@ -42,6 +64,7 @@ class TerminalPainter { _textScaler = value; _cellSize = _measureCharSize(); _paragraphCache.clear(); + _invalidateLinePictures(); } TerminalTheme get theme => _theme; @@ -51,6 +74,24 @@ class TerminalPainter { _theme = value; _colorPalette = PaletteBuilder(value).build(); _paragraphCache.clear(); + _invalidateLinePictures(); + } + + /// Keyword highlighting rules baked into the cached line pictures. The + /// caller (RenderTerminal) only assigns on structural change, so a new + /// value always invalidates the cache. + List get keywordRules => _keywordRules; + List _keywordRules = const []; + set keywordRules(List value) { + _keywordRules = value; + _invalidateLinePictures(); + } + + void _invalidateLinePictures() { + for (final entry in _linePictures.values) { + entry.picture.dispose(); + } + _linePictures.clear(); } Size _measureCharSize() { @@ -83,6 +124,94 @@ class TerminalPainter { void clearFontCache() { _cellSize = _measureCharSize(); _paragraphCache.clear(); + _invalidateLinePictures(); + } + + /// Returns a picture that paints [line] (text and keyword highlights) at + /// origin. Cached: the line is re-recorded only when its content version + /// changed since the last paint. + Picture getLinePicture(BufferLine line) { + // Remove + reinsert keeps the map insertion-ordered by recency (LRU). + final cached = _linePictures.remove(line); + if (cached != null && cached.version == line.version) { + _linePictures[line] = cached; + return cached.picture; + } + cached?.picture.dispose(); + + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + paintLine(canvas, Offset.zero, line); + _paintLineKeywords(canvas, line); + final picture = recorder.endRecording(); + + _linePictures[line] = _LinePicture(picture, line.version); + if (_linePictures.length > _linePictureCacheLimit) { + final eldest = _linePictures.keys.first; + _linePictures.remove(eldest)!.picture.dispose(); + } + return picture; + } + + // Returns a list mapping string-character index → cell column. + // getText() emits one code-unit per code-point, skipping continuation cells + // of double-width characters, so string index ≠ cell column when wide chars + // are present. + List _buildStrToCell(BufferLine line) { + final result = []; + for (var col = 0; col < line.length; col++) { + final cp = line.getCodePoint(col); + if (cp != 0) { + result.add(col); + if (line.getWidth(col) == 2) col++; + } + } + return result; + } + + /// Paints keyword highlights for [line] in line-local coordinates (y = 0). + /// Recorded into the line's cached picture, so the regex matching runs only + /// when the line content changes — not on every frame. + void _paintLineKeywords(Canvas canvas, BufferLine line) { + if (_keywordRules.isEmpty) return; + + final lineText = line.getText(); + final strToCell = _buildStrToCell(line); + + for (final rule in _keywordRules) { + for (final m in rule.pattern.allMatches(lineText)) { + if (m.start == m.end) continue; + + final startCell = + m.start < strToCell.length ? strToCell[m.start] : m.start; + final lastCharCell = m.end > 0 && m.end - 1 < strToCell.length + ? strToCell[m.end - 1] + : m.end - 1; + final endCell = + lastCharCell + (line.getWidth(lastCharCell) == 2 ? 2 : 1); + final cellCount = endCell - startCell; + + if (rule.background != null) { + paintHighlight( + canvas, + Offset(startCell * _cellSize.width, 0), + cellCount, + rule.background!, + ); + } + + if (rule.foreground != null) { + paintKeywordForeground( + canvas, + Offset.zero, + line, + startCell, + endCell, + rule.foreground!, + ); + } + } + } } /// Paints the cursor based on the current cursor type. diff --git a/packages/xterm/lib/src/ui/render.dart b/packages/xterm/lib/src/ui/render.dart index 198f085e..10483eee 100644 --- a/packages/xterm/lib/src/ui/render.dart +++ b/packages/xterm/lib/src/ui/render.dart @@ -174,11 +174,20 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { if (same) return; } _keywordRules = value; + // The painter bakes keyword highlights into its cached line pictures; + // a rule change must invalidate them. + _painter.keywordRules = value; markNeedsPaint(); } var _stickToBottom = true; + /// Bookkeeping for scrollback-trim compensation: the lines list whose + /// [droppedLines] counter [_seenDropped] refers to. The terminal swaps + /// between the main and alt buffer's line lists, so track identity. + Object? _trackedLines; + var _seenDropped = 0; + void _onScroll() { _stickToBottom = _scrollOffset >= _maxScrollExtent; markNeedsLayout(); @@ -236,6 +245,8 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _updateViewportSize(); + _compensateScrollbackTrim(); + _updateScrollOffset(); if (_stickToBottom) { @@ -243,6 +254,30 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { } } + /// When the scrollback buffer is at capacity, every new line trims one from + /// the top, shifting all content up while the pixel offset stays put — to a + /// user reading scrollback the text appears to stream past uncontrollably. + /// Compensate by shifting the offset up by exactly the trimmed amount so the + /// content under the viewport stays still. + void _compensateScrollbackTrim() { + final lines = _terminal.buffer.lines; + final dropped = lines.droppedLines; + if (!identical(lines, _trackedLines)) { + // First layout, or the terminal switched between main/alt buffers: + // just (re)baseline, never correct across different line lists. + _trackedLines = lines; + _seenDropped = dropped; + return; + } + if (dropped == _seenDropped) return; + final delta = dropped - _seenDropped; + _seenDropped = dropped; + if (!_stickToBottom && _offset.hasPixels) { + final shift = delta * _painter.cellSize.height; + _offset.correctBy(-shift.clamp(0.0, _offset.pixels)); + } + } + /// Total height of the terminal in pixels. Includes scrollback buffer. double get _terminalHeight => _terminal.buffer.lines.length * _painter.cellSize.height; @@ -440,15 +475,19 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { final effectLastLine = lastLine.clamp(0, lines.length - 1); for (var i = effectFirstLine; i <= effectLastLine; i++) { - _painter.paintLine( - canvas, - offset.translate(0, (i * charHeight + _lineOffset).truncateToDouble()), - lines[i], + // Lines are painted via cached per-line pictures: a line is re-recorded + // only when its content changes, so scrolling and steady output replay + // pictures instead of re-laying-out every visible cell each frame. + final picture = _painter.getLinePicture(lines[i]); + canvas.save(); + canvas.translate( + offset.dx, + offset.dy + (i * charHeight + _lineOffset).truncateToDouble(), ); + canvas.drawPicture(picture); + canvas.restore(); } - _paintKeywordHighlights(canvas, effectFirstLine, effectLastLine); - if (_terminal.buffer.absoluteCursorY >= effectFirstLine && _terminal.buffer.absoluteCursorY <= effectLastLine) { if (_isComposingText) { @@ -578,68 +617,4 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _painter.paintHighlight(canvas, startOffset, end - start, color); } - // Returns a list mapping string-character index → cell column. - // getText() emits one code-unit per code-point, skipping continuation cells - // of double-width characters, so string index ≠ cell column when wide chars - // are present. - List _buildStrToCell(BufferLine line) { - final result = []; - for (var col = 0; col < line.length; col++) { - final cp = line.getCodePoint(col); - if (cp != 0) { - result.add(col); - if (line.getWidth(col) == 2) col++; - } - } - return result; - } - - void _paintKeywordHighlights(Canvas canvas, int firstLine, int lastLine) { - if (_keywordRules.isEmpty) return; - final lines = _terminal.buffer.lines; - final charHeight = _painter.cellSize.height; - - for (var i = firstLine; i <= lastLine; i++) { - if (i >= lines.length) break; - final line = lines[i]; - final lineText = line.getText(); - final lineY = (i * charHeight + _lineOffset).truncateToDouble(); - final strToCell = _buildStrToCell(line); - - for (final rule in _keywordRules) { - for (final m in rule.pattern.allMatches(lineText)) { - if (m.start == m.end) continue; - - final startCell = - m.start < strToCell.length ? strToCell[m.start] : m.start; - final lastCharCell = m.end > 0 && m.end - 1 < strToCell.length - ? strToCell[m.end - 1] - : m.end - 1; - final endCell = - lastCharCell + (line.getWidth(lastCharCell) == 2 ? 2 : 1); - final cellCount = endCell - startCell; - - if (rule.background != null) { - _painter.paintHighlight( - canvas, - Offset(startCell * _painter.cellSize.width, lineY), - cellCount, - rule.background!, - ); - } - - if (rule.foreground != null) { - _painter.paintKeywordForeground( - canvas, - Offset(0, lineY), - line, - startCell, - endCell, - rule.foreground!, - ); - } - } - } - } - } } diff --git a/packages/xterm/lib/src/utils/circular_buffer.dart b/packages/xterm/lib/src/utils/circular_buffer.dart index f74fb436..157da2c5 100644 --- a/packages/xterm/lib/src/utils/circular_buffer.dart +++ b/packages/xterm/lib/src/utils/circular_buffer.dart @@ -18,6 +18,15 @@ class IndexAwareCircularBuffer { /// overflow var _absoluteStartIndex = 0; + /// Total number of elements ever removed from the front of the list — + /// overflow trims on [push]/[insert] plus explicit [trimStart] calls. + /// Monotonic; lets consumers (e.g. the terminal viewport) detect that the + /// content above a given index has shifted and compensate scroll offsets. + var _droppedLines = 0; + + /// Monotonic count of elements removed from the front of the list. + int get droppedLines => _droppedLines; + /// Gets the cyclic index for the specified regular index. The cyclic index /// can then be used on the backing array to get the element associated with /// the regular index. @@ -137,6 +146,7 @@ class IndexAwareCircularBuffer { // When the list is full, we trim the first element _startIndex++; _absoluteStartIndex++; + _droppedLines++; if (_startIndex == _array.length) { _startIndex = 0; } @@ -197,6 +207,7 @@ class IndexAwareCircularBuffer { if (_length >= _array.length) { _startIndex += 1; _absoluteStartIndex += 1; + _droppedLines += 1; } else { _length++; } @@ -227,6 +238,7 @@ class IndexAwareCircularBuffer { _startIndex += count; _startIndex %= _array.length; _length -= count; + _droppedLines += count; } /// Replaces all elements in the list with [replacement]. From 5e0f8e021133f1bdd6130b44d60b7484d26c193e Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Thu, 11 Jun 2026 23:10:27 +0700 Subject: [PATCH 6/8] docs: changelog and xterm fork patch notes for terminal fixes --- CHANGELOG.md | 16 ++++++++++++++++ CLAUDE.md | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 172bd8d6..c656987a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Scrollback paging keys** — Shift+PageUp / Shift+PageDown page through the terminal scrollback (main buffer only; alternate-screen apps still receive the keys) +- **Reset Terminal** — right-click action that recovers a session stuck in the alternate screen with mouse reporting left on (full-screen app crashed / SSH dropped mid-TUI): returns to the main buffer, disables mouse mode, re-shows the cursor — the local equivalent of `reset` + +### Changed +- **Terminal render performance** — visible lines are painted via cached per-line pictures re-recorded only when a line's content changes (new `BufferLine.version`), turning a steady frame from O(visible cells) paragraph draws into O(visible lines) picture replays (~7× less per-frame paint work); keyword-highlight regexes now run on line change instead of every frame + +### Fixed +- **Mouse wheel inside mouse-aware TUIs** (#65) — wheel up/down were reported with button codes 68/69 instead of the standard 64/65, so claude CLI, htop, `vim mouse=a`, lazygit, and tmux (mouse on) ignored every wheel event; legacy-mode reports also placed events one row below the pointer +- **Scrollback drift at the cap** (#66) — once the buffer hit `maxLines`, content a scrolled-up reader was viewing streamed past as lines were trimmed from the top; the viewport now compensates for trimmed lines and stays pinned to the text +- **Decomposed (NFD) Vietnamese text** (#67) — combining marks are now canonically composed into the preceding cell (every Vietnamese letter has a precomposed form), so macOS `ls` filenames and other NFD sources render correctly instead of one displaced cell per diacritic + +--- + ## [0.1.35] — 2026-06-11 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index f24832d8..c3d4e55f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ The active codebase is `app/` — a Flutter app targeting macOS, Windows, and Li - `packages/yourssh_rdp` — **Rust RDP client** (IronRDP 0.15, flutter_rust_bridge v2); exposes `RdpClient` + `RdpConfig`, a `StreamSink` event bus, and `rdp_lib_version()`; `RdpClient.ensureInitialized()` lazily loads the native library + inits the FRB runtime on first RDP connect (no init in main.dart); `RdpConfig.expectedFingerprint` carries the pinned cert fingerprint — the Rust engine verifies it post-TLS / **pre-CredSSP** and aborts with `RdpEvent.certMismatch` before any credentials are sent (TLS itself uses no cert verification — the pin is the only server check); `RdpEvent.connected` carries the **server-negotiated** desktop size (may differ from the request); dirty rects are inclusive-rectangle corrected (+1) and clamped before extraction; the run loop peeks every X224 frame for an MCS Disconnect Provider Ultimatum (server-side session end: remote sign-out / session takeover / admin disconnect) and turns it into a graceful `RdpEvent.disconnected` — ironrdp-session 0.9's x224 processor would otherwise surface it as a raw decode error; build scripts produce `libyourssh_rdp.dylib` / `.so` / `.dll` which the Dart `NativeLoader` resolves at runtime from the app bundle (release) or `assets/native/` (dev); the built libraries are **not tracked in git** (`assets/native/` is gitignored — run `build.sh`/`build.ps1` once after clone; CI builds them fresh) - `packages/dartssh2` — **local fork** of dartssh2; overrides the pub.dev version via `dependency_overrides` in `app/pubspec.yaml`; adds `signAsync()` for agent-backed auth and SSH agent forwarding (sends `auth-agent-req@openssh.com` on shell channels only — never exec; accepts server-opened `auth-agent@openssh.com` channels via `SSHClient.agentHandler`; a refused request is non-fatal and surfaces as `SSHSession.agentForwardingRefused`); implements strict KEX (`kex-strict-c/s-v00@openssh.com`, CVE-2023-48795 "Terrapin" mitigation: sequence numbers reset after every NEWKEYS, non-KEX messages during the initial key exchange terminate the connection, KEXINIT must be the first packet); adds `OpenSSHEd25519KeyPair.generate()` and a passphrase-encrypting `toPem({passphrase})` (bcrypt-pbkdf + aes256-ctr; null passphrase output unchanged, interop-verified against real `ssh-keygen -y`) - `packages/flutter_pty` — **local fork** of flutter_pty 0.4.2 (also via `dependency_overrides`); patches `src/flutter_pty_win.c` so the Windows command line doesn't duplicate `argv[0]` (upstream issue #19 — broke keyboard input in the local PowerShell terminal) -- `packages/xterm` — **local fork** of xterm 4.0.0 (also via `dependency_overrides`); patches: (1) `lib/src/ui/custom_text_edit.dart` passes `viewId: View.maybeOf(context)?.viewId` to `TextInputConfiguration` (upstream issue #207 — newer Flutter engines on Windows reject a text-input client without a viewId, so printable keys never reached any `TerminalView` while Enter/Tab/paste still worked); (2) copy/paste reachability (issue #43) — `shortcut/shortcuts.dart` adds Ctrl+C → `TerminalCopyAndClearIntent` and Ctrl+Shift+V paste alias on Windows/Linux, `shortcut/actions.dart` adds `_CopySelectionAndClearAction` (enabled only with an active selection; copies then clears it so the next Ctrl+C reaches the shell as SIGINT — without a selection `ShortcutManager` ignores the key and it falls through as ^C), `ui/gesture/gesture_handler.dart` un-aliases tertiary taps from the secondary-tap callbacks (middle clicks also reported to mouse-mode apps as `TerminalMouseButton.middle`, not right), and `terminal_view.dart` pastes the clipboard on middle-click unless `readOnly` +- `packages/xterm` — **local fork** of xterm 4.0.0 (also via `dependency_overrides`); patches: (1) `lib/src/ui/custom_text_edit.dart` passes `viewId: View.maybeOf(context)?.viewId` to `TextInputConfiguration` (upstream issue #207 — newer Flutter engines on Windows reject a text-input client without a viewId, so printable keys never reached any `TerminalView` while Enter/Tab/paste still worked); (2) copy/paste reachability (issue #43) — `shortcut/shortcuts.dart` adds Ctrl+C → `TerminalCopyAndClearIntent` and Ctrl+Shift+V paste alias on Windows/Linux, `shortcut/actions.dart` adds `_CopySelectionAndClearAction` (enabled only with an active selection; copies then clears it so the next Ctrl+C reaches the shell as SIGINT — without a selection `ShortcutManager` ignores the key and it falls through as ^C), `ui/gesture/gesture_handler.dart` un-aliases tertiary taps from the secondary-tap callbacks (middle clicks also reported to mouse-mode apps as `TerminalMouseButton.middle`, not right), and `terminal_view.dart` pastes the clipboard on middle-click unless `readOnly`; (3) scrollback-trim scroll compensation — `IndexAwareCircularBuffer` exposes a monotonic `droppedLines` counter (bumped on overflow push/insert and `trimStart`) and `RenderTerminal._compensateScrollbackTrim()` shifts the scroll offset up by the trimmed amount each layout, so once the buffer hits `maxLines` the content a scrolled-up user is reading stays put instead of streaming past (offset clamped at 0; re-baselined on main↔alt buffer switches, never corrected across them); (4) per-line picture render cache — `BufferLine.version` (monotonic, bumped by every mutator) keys `TerminalPainter.getLinePicture()`, an LRU (`_linePictureCacheLimit` 1024, disposed on eviction) of per-line recorded `Picture`s replayed by `RenderTerminal._paint`, so scrolling/steady frames replay O(visible lines) pictures instead of issuing O(visible cells) `drawParagraph` calls (~7× less per-frame paint work); keyword highlights are baked into the cached picture (regex runs on line change, not per frame) — `RenderTerminal.keywordRules` forwards to `painter.keywordRules` on structural change, and the cache invalidates on textStyle/textScaler/theme/font-change/keyword-rule updates (pixel-equivalence + invalidation covered by `app/test/widgets/xterm_line_picture_cache_test.dart`, scroll behavior by `app/test/widgets/terminal_scroll_probe_test.dart`); (5) scrollback keyboard paging — Shift+PageUp/PageDown in `TerminalViewState._handleKeyEvent` page the viewport locally (main buffer only; in the alt buffer the keys still reach the application); (6) `Terminal.recoverFromStuckState()` — local `reset`-equivalent for a full-screen app that died uncleanly and left the terminal trapped in the alt screen with mouse reporting on (the cause of "wheel scrolling completely dead at a prompt"): returns to the main buffer, disables mouse mode/report mode, re-shows the cursor, resets main-buffer margins; wired to the right-click "Reset Terminal" item in `app/lib/widgets/terminal_context_menu.dart`; (7) mouse-report encoding fixes — wheel buttons reported as 64-67 (flag 64 + low two bits of X11 buttons 4-7; upstream sent 68-71, which no application recognizes, so wheel scrolling was dead inside every mouse-aware TUI: claude CLI, htop, vim `mouse=a`, tmux with mouse on), and normal/UTF report mode no longer adds an extra +1 to the row byte (every event was reported one row below the pointer); covered by `app/test/widgets/xterm_mouse_report_test.dart`; (8) combining-mark composition — `Buffer.writeChar` merges a zero-width combining mark into the preceding cell when a precomposed NFC form exists (`unorm_dart`; preserves the base cell's style via `setCodePoint`, skips wide-char continuation cells), so decomposed Vietnamese (macOS `ls` filenames, NFD output from many tools) renders correctly instead of one displaced cell per diacritic; marks with no precomposed form keep the legacy own-cell behavior (single-codepoint cell model); covered by `app/test/widgets/xterm_combining_chars_test.dart` - `packages/yourssh_plugin_api` — abstract plugin interface (`YourSSHPlugin`, `YourSSHPluginContext`) - `packages/yourssh_devops` — DevOps plugin (containers (Docker/K8s), network tools, Cloudflare tunnel, mail catcher, MCP server, S3 browser) - `packages/yourssh_web_tools` — Web Tools plugin (in-app browser over port-forwarded HTTP) From fe45ec0e600e2fa3fad17c50bdc9727fd19ec391 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 12 Jun 2026 10:22:27 +0700 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20complete=200.1.36=20release=20check?= =?UTF-8?q?list=20=E2=80=94=20changelog,=20roadmap,=20wiki?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - version: 0.1.35+1 -> 0.1.36+1 - CHANGELOG: [Unreleased] -> 0.1.36 (2026-06-12); add the missing 0.1.33-0.1.36 comparison links - roadmap: bump to 0.1.36, add the terminal scroll & rendering overhaul to Shipped (wheel in TUIs, NFD Vietnamese, scrollback stability, render cache, paging keys, Reset Terminal) - wiki: Terminal guide gains a Scrolling section (wheel behavior in full-screen apps, Shift+PageUp/PageDown, Reset Terminal recovery) --- CHANGELOG.md | 7 +++++-- app/pubspec.yaml | 2 +- docs/roadmap.md | 4 ++-- docs/wiki/User-Guide-Terminal.md | 10 +++++++++- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c656987a..ce74ffed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.1.36] — 2026-06-12 ### Added - **Scrollback paging keys** — Shift+PageUp / Shift+PageDown page through the terminal scrollback (main buffer only; alternate-screen apps still receive the keys) @@ -636,7 +636,10 @@ Initial release of YourSSH — a cross-platform SSH client for macOS, Windows, a - **Host management** — CRUD for SSH host profiles with `StorageService` - **Known hosts** — TOFU dialog for host-key verification; `KnownHostsProvider` -[Unreleased]: https://github.com/YoursshLabs/yourssh/compare/v0.1.32...HEAD +[0.1.36]: https://github.com/YoursshLabs/yourssh/compare/v0.1.35...v0.1.36 +[0.1.35]: https://github.com/YoursshLabs/yourssh/compare/v0.1.34...v0.1.35 +[0.1.34]: https://github.com/YoursshLabs/yourssh/compare/v0.1.33...v0.1.34 +[0.1.33]: https://github.com/YoursshLabs/yourssh/compare/v0.1.32...v0.1.33 [0.1.32]: https://github.com/YoursshLabs/yourssh/compare/v0.1.31...v0.1.32 [0.1.31]: https://github.com/YoursshLabs/yourssh/compare/v0.1.30...v0.1.31 [0.1.30]: https://github.com/YoursshLabs/yourssh/compare/v0.1.29...v0.1.30 diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 7c227adb..e517d82d 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,7 +1,7 @@ name: yourssh description: YourSSH - A professional SSH client for macOS, Windows, and Linux with advanced features like SFTP, port forwarding, and a built-in code editor. publish_to: 'none' -version: 0.1.35+1 +version: 0.1.36+1 environment: sdk: ^3.12.0 diff --git a/docs/roadmap.md b/docs/roadmap.md index 4f549cc8..a9d466b9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,11 +1,11 @@ # YourSSH — Roadmap > Direction: **infra workstation for DevOps/SRE managing 10–100+ hosts**, not just an SSH client. -> Current version: 0.1.35 · updated: 2026-06-11 +> Current version: 0.1.36 · updated: 2026-06-12 This document lists proposed features ordered by priority. Each item can be broken out into its own spec (`docs/superpowers/specs/`) when ready for implementation. -Already shipped (not repeated in roadmap): multi-tab terminal, split view, broadcast, recording (asciicast), snippet, SFTP dual-panel, port forwarding, jump host, Supabase sync + P2P LAN, AI chat sidebar with tool calling, plugin system (DevOps / WebTools / Snippets), Cloudflare tunnel, MCP gateway, mail catcher, code editor (Monaco), customizable hotkeys, TOFU known-hosts, **Command Palette (Cmd/Ctrl+K)** — fuzzy search hosts / nav / snippets / actions, **Workspace persistence** — auto-reconnect tabs + layout on relaunch, **Search-in-scrollback (Cmd/Ctrl+F)** — regex, highlights, prev/next navigation, **Script Engine plugin system** — disk-based JS plugins via QuickJS FFI, HookBus (terminal.output / terminal.input / session events), SSH/SFTP/Storage/UI bridges, hot-reload file watcher, PermissionGuard + circuit breaker, consent dialog, manager screen + console log viewer, **Import** — paste SSH config / JSON / CSV with per-host include toggles (`parseSshConfig` in `import_panel.dart`), **Host tagging** — comma-separated tags on the `Host` model, editable in host detail and searchable from the dashboard, **Smart filter + multi-dimensional query (0.1.10)** — `HostQuery` parser with `key:value` faceted AND/OR semantics, toggleable facet chips on hosts dashboard, tag-based search, **Terminal sharing / multiplayer (0.1.13)** — share a live SSH session via Supabase Realtime; guests join with a session code, watch or interact in real time; `ShareSessionService`, `ShareProvider`, `ShareEvent`, split-view watch banner, **Advanced tab management** — rename, color tag, pin, drag reorder; all tab metadata persists per host, **Connection health badge (0.1.17)** — live latency-driven dot per session tab (green/amber/red/grey + pulse), hover tooltip with uptime / last-ping / reconnect count; `HealthMonitorService` pings the live channel (`SSHClient.ping`) as sole pinger, 5s timeout surfaces half-open silent drops, **Shell integration (0.1.18)** — injected bash/zsh prompt-hooks emit OSC 7 + OSC 133 captured via xterm `onPrivateOSC`; cwd on the session tab, per-command status gutter (green/red/grey), jump-to-prompt (`Cmd/Ctrl+↑/↓`), and cwd-aware path autocomplete in the input bar; auto-on with per-host + global opt-out, **In-app updates (0.1.19)** — checks GitHub `releases/latest` on launch (24h debounce) + manual check in Settings; since 0.1.29 also re-checks while the app stays running (6h periodic timer + window-focus check, same 24h debounce) so the notification bell picks up new releases without a restart; dismissible update banner + Settings Updates section; downloads the correct OS/arch artifact and hands off to the OS installer (assisted flow, unsigned-app friendly: macOS strips quarantine + opens the DMG, Windows runs the installer, Linux opens the package); falls back to the Releases page when no artifact matches (e.g. Intel Mac); `UpdateService` + `UpdateProvider` + `UpdateBanner`, **Sudo SFTP (0.1.20)** — per-host SFTP mode running the entire SFTP session as root through `sudo` over an exec channel (WinSCP-style); distro auto-detection, `NOPASSWD` guidance, root badge on elevated panels, **External edit + Open with… (0.1.20–0.1.21)** — open remote files with the OS default app or any installed app (hover submenu; per-OS discovery via `NSWorkspace` / XDG `.desktop` / Windows registry); the local copy is watched and auto-uploaded on every save; plain-text editor fallback where Monaco is unavailable, **SFTP View mode (0.1.21)** — read-only preview separate from Edit, **Invisible shell-integration injection (0.1.21)** — bracketed-paste readiness detection + `read -rs` two-phase handshake delivers the OSC hook installer without ever echoing into the terminal or recordings, **Terminal snippets panel (0.1.21)** — collapsible right-side panel in the terminal workspace to browse/search/copy/run snippets against the active pane, backed by the new plugin `sendInput` API, **Unified terminal tabs (0.1.24)** — local shell sessions are first-class tabs in the global top bar (`TerminalSession` model), split into panes alongside SSH, recordable to asciicast, **SFTP two-panel with switchable sources (0.1.24)** — per-panel Local/host source chip, unified headers (filter + actions), clickable breadcrumbs (#41), workspace kept alive across tab switches (#42), **Terminal copy/paste UX (0.1.24)** — selection-gated Ctrl+C copy (SIGINT preserved), Ctrl(+Shift)+V paste, right-click Copy/Paste/Select All menu, middle-click paste (#43), readable semi-transparent selection colors in all themes (#40), **Notification bell (0.1.25)** — bell in the top tab bar with unread badge + anchored popover; update-available items carry a one-click Update button, unexpected session drops (no pending auto-reconnect) are collected per session; mark-read on open, per-item dismiss, clear all; in-memory `NotificationCenterProvider` (deduped, capped at 50), **Terminal appearance side panel** — tune icon in the terminal toolbar opens a right-side panel (mutually exclusive with the snippets panel via the `SidePanel` enum; shared `WorkspaceSidePanel` frame) to change color theme, font size (live preview while dragging, persisted once on release), and font family without leaving the workspace; controls shared with Settings → Terminal via `TerminalAppearanceControls`, **Terminal theme catalog 35 → 44** — added Kanagawa Dragon/Lotus, Tokyo Night Day, Nord Light, Light Owl, Flexoki Dark/Light, Aura, and Cyberpunk from their authors' published palettes, grouped next to their families in the picker, with visibility-tuned cursor/selection/search colors on the light variants, **Port forwarding runtime (0.1.27)** — saved local/remote/dynamic SOCKS5 rules actually start and stop (`PortForwardService` over the dartssh2 forward APIs behind a testable `TunnelTransport` abstraction); tunnels reuse the host's open SSH client or auto-connect with stored credentials (no terminal tab required), auto-reconnect with 2 s → 30 s exponential backoff keeping local listeners bound across drops, per-rule edit panel, auto-start on launch, live connection counters, inline error reporting, **SSH Agent Forwarding (0.1.28, #49)** — per-host toggle (like `ssh -A`); forwarded `auth-agent@openssh.com` channels are served by the local system agent (`SSH_AUTH_SOCK` / Windows OpenSSH agent pipe) with fallback to app-Keychain keys when no system agent is running; requested on shell channels only, server refusal shows a terminal warning instead of killing the session (`AgentForwardingHandler`, `SystemAgentProxy.roundtrip`, dartssh2 fork agent-channel support), **Strict KEX (0.1.29)** — CVE-2023-48795 "Terrapin" mitigation in the dartssh2 fork: sequence numbers reset after every NEWKEYS, non-KEX messages during the initial exchange terminate the connection, KEXINIT must be the first packet, **Quick wins (0.1.29)** — middle-click closes unpinned session tabs; right-click context menu on port-forward rules with Duplicate (new id, "(copy)" label, auto-start off); distro-level OS icons (`/etc/os-release` ID → ubuntu/debian/fedora/centos/rocky/alma/alpine/amazon/arch/suse/redhat glyphs) on the hosts dashboard and SSH session tabs (`os_detection.dart`, `SessionTab` extracted from main_screen); empty-password SSH behavior locked in by tests (blank passwords are never persisted; connect sends ''), **SFTP permissions editor + unified entry context menu (0.1.29)** — chmod dialog (9-checkbox rwx grid two-way synced with a validated octal field: octal-only input, 3–4 digits, Apply gated while invalid; unknown current permissions fall back to stat() then warn and gate Apply instead of offering 000) on both the remote SFTP and local panels; `SftpFileOpsService.chmod` with a hardened recursive walk (entries with omitted modes classified via lstat, symlinks never followed — SETSTAT would chmod the target, directory modes applied post-order so a restrictive mode can't lock the walk out, file chmods batched 8-wide) sharing its `listWalkChildren` classification with recursive delete; st_mode carried on `SftpEntry`/`LocalEntry` from listing/scan time (no blocking stat at dialog-open); one shared `EntryContextMenu` for both panels (Open / Open with / View / Edit / Copy to target with up-front feasibility reasons incl. the same-folder block / Refresh / New folder / Permissions / Rename / Delete) wired through the dual-panel transfer matrix; shared app-launch helpers extracted to `util/app_launcher.dart`, **Bulk action panel (0.1.30)** — SELECT mode on the hosts dashboard (per-card checkboxes, filter-aware Select all, Esc to exit) with Connect all (skips already-connected hosts, confirms before opening more than 5 tabs), Run command in parallel (free text or snippet; bounded concurrency, 30 s per-host timeout, per-host failure isolation; per-host exit code / duration / expandable stdout+stderr; a Diff tab groups identical outputs against a promotable baseline and side-by-side compares any two hosts), and Push files to one remote path on every host (destination created if missing, per-host byte progress, cancel); closing a dialog mid-run confirms, cancels queued hosts and lets in-flight operations record their real result, **Dashboard grid & list view + sorting (0.1.30)** — card grid ↔ compact one-line list toggle and a sort dropdown (name / creation date / hostname, asc/desc), both persisted across restarts; default order Name A–Z, **Agent forwarding observability (0.1.30)** — pre-connect agent status line in the host panel (system agent identities / app-Keychain fallback / nothing to serve), live per-session key icon on the session tab (grey ready / green active / orange Keychain fallback / red refused), and a notification-bell item with tap-to-jump when the server refuses forwarding, **Connection Chain editor (0.1.30)** — Termius-style visual chain replacing the jump-host dropdown in the host panel (bastion card → arrow → destination, searchable Add-a-Host picker, agent-forwarding key icon, Clear; `HostChainEditor`, single hop), **Jump host on auto-connect paths (0.1.30)** — `ensureClient` (SFTP / exec / port forwarding) now resolves `Host.jumpHostId` via `defaultJumpHostLookup`, so hosts behind a bastion tunnel correctly outside interactive sessions; plus `tool/jump_probe.dart`, a layer-by-layer jump diagnostic CLI, **Session template / per-host preset (0.1.32)** — per-host working dir + env vars delivered invisibly via the shell-integration handshake (tty canonical mode lifted during the payload read so large templates don't truncate at the 4096-byte line cap), startup snippet typed visibly after the DONE sentinel (skipped under tmux and on handshake abort — a re-attach must not replay it), and per-host terminal theme / font / size / TERM / tmux overrides falling back to globals (out-of-range synced values rejected at render); SESSION TEMPLATE section in the host panel with env-key validation incl. duplicate-name detection, **Internal audit log (0.1.32)** — local SQLite (`sqlite3`, WAL, `/audit.db`) trail of connect/disconnect/exec/input events with per-caller source tagging (`bulk` / `devops` / `plugin:` / `input-bar` / broadcast targets each get their own row; internal polls like network stats and OS detection are exempt), secret redaction before insert (key=value incl. quoted multi-word values, Bearer tokens, sshpass/mysql-family `-p`, redis-cli `-a`, URL userinfo — applied to meta error strings too), Audit Log screen with type/time/search filters, keyset pagination, CSV/JSON export (macOS sandbox entitlement upgraded to user-selected read-write), retention pruning at launch (default 90 days, configurable in Settings → Audit) and fail-soft writes that can never break an SSH operation, **In-app SSH key generation (0.1.32)** — generate Ed25519 (pure Dart in the dartssh2 fork: `OpenSSHEd25519KeyPair.generate()` + passphrase-encrypting `toPem` via bcrypt-pbkdf + aes256-ctr, interop-verified against real `ssh-keygen -y`) / RSA-4096 / ECDSA-P256 (via system `ssh-keygen`; options gated by a binary probe) from the Keychain screen; keys land in `Documents/YourSSH/keys` mode 600 with the passphrase in secure storage; copy public key + ssh-copy-id-style **deploy-to-host dialog** (idempotent `grep -qxF`, EXISTS/ADDED markers) — includes the deploy stretch goal, **Local shell picker (0.1.32)** — choose which shell local terminal tabs run: auto-detected per platform (Windows: PowerShell, cmd, PowerShell 7, Git Bash, one profile per WSL distro; macOS/Linux: `$SHELL` + `/etc/shells`) plus user-added custom executables with arguments; default in Settings → Terminal, per-session choice in the new-tab (+) menu, Restart shell reuses the session's shell, an uninstalled default falls back to the platform default with a terminal warning (`ShellProfile`, `shell_detection.dart`), **Multi-hop jump chain (0.1.32)** — bastion → bastion → target for layered networks: `Host.jumpHostIds` ordered chain (legacy `jumpHostId` migrates and is dual-written to JSON for cross-version sync), `SshService` dials hop-over-hop via `forwardLocal` with chain-prefix-keyed client cache, deepest-first refcounted teardown and a cycle guard, per-hop host-key verification; `HostChainEditor` appends/removes hops (persistent Add a Host, per-hop ×, Clear); `ensureClient` and test-connection resolve the full chain, **Recording redaction (0.1.32)** — secrets masked before being written to `.cast`: line-buffered redaction in `RecordingService` reusing `AuditRedactor` unchanged (split at the last newline, start-once 500 ms flush timer, stop flushes the tail; per-line coalescing also strips keystroke timing), global Settings toggle AND per-host opt-out (both default on; pure `effectiveRecordingRedaction` policy sampled once at record start with a fresh `HostProvider` lookup), **In-app RDP client (0.1.33, #44)** — Windows / xrdp desktops as first-class tabs: IronRDP Rust engine via flutter_rust_bridge v2 (`packages/yourssh_rdp`), NLA/TLS/auto security, direct or SSH-tunneled through a saved jump host (full connection chain reused), TOFU certificate pinning enforced in Rust pre-CredSSP (changed cert aborts before credentials are sent), server-negotiated resolution handling, bidirectional clipboard, full keyboard/mouse incl. Ctrl+Alt+Del, OS-level fullscreen with mstsc-style hover pill + auto-exit safety, graceful server-initiated disconnect (MCS ultimatum → clean message, not a raw protocol error), protocol-aware dashboard actions, RDP badge, tab parity (rename/color/pin/restore/audit/notification bell), **Kubernetes panel (0.1.34)** — `KubernetesPanel` with context switcher, `kubectl logs -f`, and 1-click port-forward via `ContainerService.execStream`, **Keyword highlighting (0.1.34)** — user-defined regex rules with per-rule color picker (defaults: Error/Warning/Fail in red/yellow/cyan); rendered in the xterm fork at paint time (`paintKeywordForeground`, `KeywordHighlightRule`) wired through `TerminalView → RenderTerminal`; toggle + rule list + add/edit dialog in Settings → Terminal and the terminal config side panel, **Server monitor panel (0.1.34)** — per-host live dashboard (CPU/mem/disk/uptime/listening ports/firewall) via draggable bottom sheet; accessed from host card hover button and context menu; `SystemStatsService` polls every 5 s via compound SSH exec with sentinel markers (`__CPU1__`/`__CPU2__`/`__MEM__`/`__DISK__`/`__UPTIME__`/`__PORTS__`), `FirewallStatusService` polls every 30 s (ufw/iptables-save/nftables auto-detected); pure parser models `SystemSnapshot` + `FirewallStatus`; requires an active SSH session for the host, **Network discovery (0.1.34)** — scan local network for SSH/RDP hosts without typing an IP; `NetworkDiscoveryService` combines mDNS (`_ssh._tcp` / `_rdp._tcp`) and a TCP port scan on the local subnet; results in a draggable bottom sheet with one-tap Add Host; also linked from the Add Host panel; `DiscoveredHost` model + `multicast_dns` dep, **Import sources expansion (0.1.34)** — five new import formats beyond SSH config/JSON/CSV: PuTTY `.reg` (hex port, URL-decoded names), MobaXterm `.mxtsessions` (SSH type-0 only, multi-`[Bookmarks_N]` files), SecureCRT XML (recursive folder → group path), Ansible INI inventory (`ansible_host`/`ansible_user`/`ansible_port`; `:vars`/`:children` skipped), WinSCP `.ini` (URL-decoded, nested path → label + group); **Known hosts import** from `~/.ssh/known_hosts` via the Known Hosts screen (MD5(key\_blob) fingerprints, duplicate-skip, hashed-entry skip), **SFTP breadcrumb path jump (0.1.35, #62)** — inline path editor on the shared `PathBreadcrumb` (edit affordance → type path → Enter; Esc cancels) in both the remote SFTP and local panels; remote input normalized to absolute POSIX, **macOS universal build (0.1.35, #64)** — Intel + Apple Silicon from one arm64+x86_64 artifact: `build.sh` lipos both Rust dylib targets, the release workflow asserts both arches via `lipo -archs`, and the updater matches the universal DMG on both archs (browser fallback on pre-universal releases for Intel), **Perf & size pass (0.1.35, #63)** — memoized keyword-rule compilation in `SettingsProvider`, `setActive` equality guard, direct-buffer agent message build; dropped unused `local_auth` dep and MesloLGS NF italic faces (−4.8 MB per bundle); `--split-debug-info` on all desktop release builds with per-release symbols zips. +Already shipped (not repeated in roadmap): multi-tab terminal, split view, broadcast, recording (asciicast), snippet, SFTP dual-panel, port forwarding, jump host, Supabase sync + P2P LAN, AI chat sidebar with tool calling, plugin system (DevOps / WebTools / Snippets), Cloudflare tunnel, MCP gateway, mail catcher, code editor (Monaco), customizable hotkeys, TOFU known-hosts, **Command Palette (Cmd/Ctrl+K)** — fuzzy search hosts / nav / snippets / actions, **Workspace persistence** — auto-reconnect tabs + layout on relaunch, **Search-in-scrollback (Cmd/Ctrl+F)** — regex, highlights, prev/next navigation, **Script Engine plugin system** — disk-based JS plugins via QuickJS FFI, HookBus (terminal.output / terminal.input / session events), SSH/SFTP/Storage/UI bridges, hot-reload file watcher, PermissionGuard + circuit breaker, consent dialog, manager screen + console log viewer, **Import** — paste SSH config / JSON / CSV with per-host include toggles (`parseSshConfig` in `import_panel.dart`), **Host tagging** — comma-separated tags on the `Host` model, editable in host detail and searchable from the dashboard, **Smart filter + multi-dimensional query (0.1.10)** — `HostQuery` parser with `key:value` faceted AND/OR semantics, toggleable facet chips on hosts dashboard, tag-based search, **Terminal sharing / multiplayer (0.1.13)** — share a live SSH session via Supabase Realtime; guests join with a session code, watch or interact in real time; `ShareSessionService`, `ShareProvider`, `ShareEvent`, split-view watch banner, **Advanced tab management** — rename, color tag, pin, drag reorder; all tab metadata persists per host, **Connection health badge (0.1.17)** — live latency-driven dot per session tab (green/amber/red/grey + pulse), hover tooltip with uptime / last-ping / reconnect count; `HealthMonitorService` pings the live channel (`SSHClient.ping`) as sole pinger, 5s timeout surfaces half-open silent drops, **Shell integration (0.1.18)** — injected bash/zsh prompt-hooks emit OSC 7 + OSC 133 captured via xterm `onPrivateOSC`; cwd on the session tab, per-command status gutter (green/red/grey), jump-to-prompt (`Cmd/Ctrl+↑/↓`), and cwd-aware path autocomplete in the input bar; auto-on with per-host + global opt-out, **In-app updates (0.1.19)** — checks GitHub `releases/latest` on launch (24h debounce) + manual check in Settings; since 0.1.29 also re-checks while the app stays running (6h periodic timer + window-focus check, same 24h debounce) so the notification bell picks up new releases without a restart; dismissible update banner + Settings Updates section; downloads the correct OS/arch artifact and hands off to the OS installer (assisted flow, unsigned-app friendly: macOS strips quarantine + opens the DMG, Windows runs the installer, Linux opens the package); falls back to the Releases page when no artifact matches (e.g. Intel Mac); `UpdateService` + `UpdateProvider` + `UpdateBanner`, **Sudo SFTP (0.1.20)** — per-host SFTP mode running the entire SFTP session as root through `sudo` over an exec channel (WinSCP-style); distro auto-detection, `NOPASSWD` guidance, root badge on elevated panels, **External edit + Open with… (0.1.20–0.1.21)** — open remote files with the OS default app or any installed app (hover submenu; per-OS discovery via `NSWorkspace` / XDG `.desktop` / Windows registry); the local copy is watched and auto-uploaded on every save; plain-text editor fallback where Monaco is unavailable, **SFTP View mode (0.1.21)** — read-only preview separate from Edit, **Invisible shell-integration injection (0.1.21)** — bracketed-paste readiness detection + `read -rs` two-phase handshake delivers the OSC hook installer without ever echoing into the terminal or recordings, **Terminal snippets panel (0.1.21)** — collapsible right-side panel in the terminal workspace to browse/search/copy/run snippets against the active pane, backed by the new plugin `sendInput` API, **Unified terminal tabs (0.1.24)** — local shell sessions are first-class tabs in the global top bar (`TerminalSession` model), split into panes alongside SSH, recordable to asciicast, **SFTP two-panel with switchable sources (0.1.24)** — per-panel Local/host source chip, unified headers (filter + actions), clickable breadcrumbs (#41), workspace kept alive across tab switches (#42), **Terminal copy/paste UX (0.1.24)** — selection-gated Ctrl+C copy (SIGINT preserved), Ctrl(+Shift)+V paste, right-click Copy/Paste/Select All menu, middle-click paste (#43), readable semi-transparent selection colors in all themes (#40), **Notification bell (0.1.25)** — bell in the top tab bar with unread badge + anchored popover; update-available items carry a one-click Update button, unexpected session drops (no pending auto-reconnect) are collected per session; mark-read on open, per-item dismiss, clear all; in-memory `NotificationCenterProvider` (deduped, capped at 50), **Terminal appearance side panel** — tune icon in the terminal toolbar opens a right-side panel (mutually exclusive with the snippets panel via the `SidePanel` enum; shared `WorkspaceSidePanel` frame) to change color theme, font size (live preview while dragging, persisted once on release), and font family without leaving the workspace; controls shared with Settings → Terminal via `TerminalAppearanceControls`, **Terminal theme catalog 35 → 44** — added Kanagawa Dragon/Lotus, Tokyo Night Day, Nord Light, Light Owl, Flexoki Dark/Light, Aura, and Cyberpunk from their authors' published palettes, grouped next to their families in the picker, with visibility-tuned cursor/selection/search colors on the light variants, **Port forwarding runtime (0.1.27)** — saved local/remote/dynamic SOCKS5 rules actually start and stop (`PortForwardService` over the dartssh2 forward APIs behind a testable `TunnelTransport` abstraction); tunnels reuse the host's open SSH client or auto-connect with stored credentials (no terminal tab required), auto-reconnect with 2 s → 30 s exponential backoff keeping local listeners bound across drops, per-rule edit panel, auto-start on launch, live connection counters, inline error reporting, **SSH Agent Forwarding (0.1.28, #49)** — per-host toggle (like `ssh -A`); forwarded `auth-agent@openssh.com` channels are served by the local system agent (`SSH_AUTH_SOCK` / Windows OpenSSH agent pipe) with fallback to app-Keychain keys when no system agent is running; requested on shell channels only, server refusal shows a terminal warning instead of killing the session (`AgentForwardingHandler`, `SystemAgentProxy.roundtrip`, dartssh2 fork agent-channel support), **Strict KEX (0.1.29)** — CVE-2023-48795 "Terrapin" mitigation in the dartssh2 fork: sequence numbers reset after every NEWKEYS, non-KEX messages during the initial exchange terminate the connection, KEXINIT must be the first packet, **Quick wins (0.1.29)** — middle-click closes unpinned session tabs; right-click context menu on port-forward rules with Duplicate (new id, "(copy)" label, auto-start off); distro-level OS icons (`/etc/os-release` ID → ubuntu/debian/fedora/centos/rocky/alma/alpine/amazon/arch/suse/redhat glyphs) on the hosts dashboard and SSH session tabs (`os_detection.dart`, `SessionTab` extracted from main_screen); empty-password SSH behavior locked in by tests (blank passwords are never persisted; connect sends ''), **SFTP permissions editor + unified entry context menu (0.1.29)** — chmod dialog (9-checkbox rwx grid two-way synced with a validated octal field: octal-only input, 3–4 digits, Apply gated while invalid; unknown current permissions fall back to stat() then warn and gate Apply instead of offering 000) on both the remote SFTP and local panels; `SftpFileOpsService.chmod` with a hardened recursive walk (entries with omitted modes classified via lstat, symlinks never followed — SETSTAT would chmod the target, directory modes applied post-order so a restrictive mode can't lock the walk out, file chmods batched 8-wide) sharing its `listWalkChildren` classification with recursive delete; st_mode carried on `SftpEntry`/`LocalEntry` from listing/scan time (no blocking stat at dialog-open); one shared `EntryContextMenu` for both panels (Open / Open with / View / Edit / Copy to target with up-front feasibility reasons incl. the same-folder block / Refresh / New folder / Permissions / Rename / Delete) wired through the dual-panel transfer matrix; shared app-launch helpers extracted to `util/app_launcher.dart`, **Bulk action panel (0.1.30)** — SELECT mode on the hosts dashboard (per-card checkboxes, filter-aware Select all, Esc to exit) with Connect all (skips already-connected hosts, confirms before opening more than 5 tabs), Run command in parallel (free text or snippet; bounded concurrency, 30 s per-host timeout, per-host failure isolation; per-host exit code / duration / expandable stdout+stderr; a Diff tab groups identical outputs against a promotable baseline and side-by-side compares any two hosts), and Push files to one remote path on every host (destination created if missing, per-host byte progress, cancel); closing a dialog mid-run confirms, cancels queued hosts and lets in-flight operations record their real result, **Dashboard grid & list view + sorting (0.1.30)** — card grid ↔ compact one-line list toggle and a sort dropdown (name / creation date / hostname, asc/desc), both persisted across restarts; default order Name A–Z, **Agent forwarding observability (0.1.30)** — pre-connect agent status line in the host panel (system agent identities / app-Keychain fallback / nothing to serve), live per-session key icon on the session tab (grey ready / green active / orange Keychain fallback / red refused), and a notification-bell item with tap-to-jump when the server refuses forwarding, **Connection Chain editor (0.1.30)** — Termius-style visual chain replacing the jump-host dropdown in the host panel (bastion card → arrow → destination, searchable Add-a-Host picker, agent-forwarding key icon, Clear; `HostChainEditor`, single hop), **Jump host on auto-connect paths (0.1.30)** — `ensureClient` (SFTP / exec / port forwarding) now resolves `Host.jumpHostId` via `defaultJumpHostLookup`, so hosts behind a bastion tunnel correctly outside interactive sessions; plus `tool/jump_probe.dart`, a layer-by-layer jump diagnostic CLI, **Session template / per-host preset (0.1.32)** — per-host working dir + env vars delivered invisibly via the shell-integration handshake (tty canonical mode lifted during the payload read so large templates don't truncate at the 4096-byte line cap), startup snippet typed visibly after the DONE sentinel (skipped under tmux and on handshake abort — a re-attach must not replay it), and per-host terminal theme / font / size / TERM / tmux overrides falling back to globals (out-of-range synced values rejected at render); SESSION TEMPLATE section in the host panel with env-key validation incl. duplicate-name detection, **Internal audit log (0.1.32)** — local SQLite (`sqlite3`, WAL, `/audit.db`) trail of connect/disconnect/exec/input events with per-caller source tagging (`bulk` / `devops` / `plugin:` / `input-bar` / broadcast targets each get their own row; internal polls like network stats and OS detection are exempt), secret redaction before insert (key=value incl. quoted multi-word values, Bearer tokens, sshpass/mysql-family `-p`, redis-cli `-a`, URL userinfo — applied to meta error strings too), Audit Log screen with type/time/search filters, keyset pagination, CSV/JSON export (macOS sandbox entitlement upgraded to user-selected read-write), retention pruning at launch (default 90 days, configurable in Settings → Audit) and fail-soft writes that can never break an SSH operation, **In-app SSH key generation (0.1.32)** — generate Ed25519 (pure Dart in the dartssh2 fork: `OpenSSHEd25519KeyPair.generate()` + passphrase-encrypting `toPem` via bcrypt-pbkdf + aes256-ctr, interop-verified against real `ssh-keygen -y`) / RSA-4096 / ECDSA-P256 (via system `ssh-keygen`; options gated by a binary probe) from the Keychain screen; keys land in `Documents/YourSSH/keys` mode 600 with the passphrase in secure storage; copy public key + ssh-copy-id-style **deploy-to-host dialog** (idempotent `grep -qxF`, EXISTS/ADDED markers) — includes the deploy stretch goal, **Local shell picker (0.1.32)** — choose which shell local terminal tabs run: auto-detected per platform (Windows: PowerShell, cmd, PowerShell 7, Git Bash, one profile per WSL distro; macOS/Linux: `$SHELL` + `/etc/shells`) plus user-added custom executables with arguments; default in Settings → Terminal, per-session choice in the new-tab (+) menu, Restart shell reuses the session's shell, an uninstalled default falls back to the platform default with a terminal warning (`ShellProfile`, `shell_detection.dart`), **Multi-hop jump chain (0.1.32)** — bastion → bastion → target for layered networks: `Host.jumpHostIds` ordered chain (legacy `jumpHostId` migrates and is dual-written to JSON for cross-version sync), `SshService` dials hop-over-hop via `forwardLocal` with chain-prefix-keyed client cache, deepest-first refcounted teardown and a cycle guard, per-hop host-key verification; `HostChainEditor` appends/removes hops (persistent Add a Host, per-hop ×, Clear); `ensureClient` and test-connection resolve the full chain, **Recording redaction (0.1.32)** — secrets masked before being written to `.cast`: line-buffered redaction in `RecordingService` reusing `AuditRedactor` unchanged (split at the last newline, start-once 500 ms flush timer, stop flushes the tail; per-line coalescing also strips keystroke timing), global Settings toggle AND per-host opt-out (both default on; pure `effectiveRecordingRedaction` policy sampled once at record start with a fresh `HostProvider` lookup), **In-app RDP client (0.1.33, #44)** — Windows / xrdp desktops as first-class tabs: IronRDP Rust engine via flutter_rust_bridge v2 (`packages/yourssh_rdp`), NLA/TLS/auto security, direct or SSH-tunneled through a saved jump host (full connection chain reused), TOFU certificate pinning enforced in Rust pre-CredSSP (changed cert aborts before credentials are sent), server-negotiated resolution handling, bidirectional clipboard, full keyboard/mouse incl. Ctrl+Alt+Del, OS-level fullscreen with mstsc-style hover pill + auto-exit safety, graceful server-initiated disconnect (MCS ultimatum → clean message, not a raw protocol error), protocol-aware dashboard actions, RDP badge, tab parity (rename/color/pin/restore/audit/notification bell), **Kubernetes panel (0.1.34)** — `KubernetesPanel` with context switcher, `kubectl logs -f`, and 1-click port-forward via `ContainerService.execStream`, **Keyword highlighting (0.1.34)** — user-defined regex rules with per-rule color picker (defaults: Error/Warning/Fail in red/yellow/cyan); rendered in the xterm fork at paint time (`paintKeywordForeground`, `KeywordHighlightRule`) wired through `TerminalView → RenderTerminal`; toggle + rule list + add/edit dialog in Settings → Terminal and the terminal config side panel, **Server monitor panel (0.1.34)** — per-host live dashboard (CPU/mem/disk/uptime/listening ports/firewall) via draggable bottom sheet; accessed from host card hover button and context menu; `SystemStatsService` polls every 5 s via compound SSH exec with sentinel markers (`__CPU1__`/`__CPU2__`/`__MEM__`/`__DISK__`/`__UPTIME__`/`__PORTS__`), `FirewallStatusService` polls every 30 s (ufw/iptables-save/nftables auto-detected); pure parser models `SystemSnapshot` + `FirewallStatus`; requires an active SSH session for the host, **Network discovery (0.1.34)** — scan local network for SSH/RDP hosts without typing an IP; `NetworkDiscoveryService` combines mDNS (`_ssh._tcp` / `_rdp._tcp`) and a TCP port scan on the local subnet; results in a draggable bottom sheet with one-tap Add Host; also linked from the Add Host panel; `DiscoveredHost` model + `multicast_dns` dep, **Import sources expansion (0.1.34)** — five new import formats beyond SSH config/JSON/CSV: PuTTY `.reg` (hex port, URL-decoded names), MobaXterm `.mxtsessions` (SSH type-0 only, multi-`[Bookmarks_N]` files), SecureCRT XML (recursive folder → group path), Ansible INI inventory (`ansible_host`/`ansible_user`/`ansible_port`; `:vars`/`:children` skipped), WinSCP `.ini` (URL-decoded, nested path → label + group); **Known hosts import** from `~/.ssh/known_hosts` via the Known Hosts screen (MD5(key\_blob) fingerprints, duplicate-skip, hashed-entry skip), **SFTP breadcrumb path jump (0.1.35, #62)** — inline path editor on the shared `PathBreadcrumb` (edit affordance → type path → Enter; Esc cancels) in both the remote SFTP and local panels; remote input normalized to absolute POSIX, **macOS universal build (0.1.35, #64)** — Intel + Apple Silicon from one arm64+x86_64 artifact: `build.sh` lipos both Rust dylib targets, the release workflow asserts both arches via `lipo -archs`, and the updater matches the universal DMG on both archs (browser fallback on pre-universal releases for Intel), **Perf & size pass (0.1.35, #63)** — memoized keyword-rule compilation in `SettingsProvider`, `setActive` equality guard, direct-buffer agent message build; dropped unused `local_auth` dep and MesloLGS NF italic faces (−4.8 MB per bundle); `--split-debug-info` on all desktop release builds with per-release symbols zips, **Terminal scroll & rendering overhaul (0.1.36, #65 #66 #67)** — mouse wheel works inside mouse-aware TUIs (claude CLI, htop, vim `mouse=a`, tmux mouse on; wheel buttons were reported as 68/69 instead of the spec's 64/65), decomposed (NFD) Vietnamese text composes into precomposed cells (macOS `ls` filenames render correctly), scrollback position stays pinned while reading during fast output at the `maxLines` cap, per-line picture render cache (~7× less per-frame paint work; keyword regexes run on line change instead of every frame), Shift+PageUp/PageDown scrollback paging, and a right-click **Reset Terminal** action that recovers sessions stuck in the alternate screen after a TUI died uncleanly. --- diff --git a/docs/wiki/User-Guide-Terminal.md b/docs/wiki/User-Guide-Terminal.md index c7388ca4..43dea34a 100644 --- a/docs/wiki/User-Guide-Terminal.md +++ b/docs/wiki/User-Guide-Terminal.md @@ -60,6 +60,14 @@ Both panes share the same SSH session. Splitting is useful for running commands Send the same keystrokes to **all open sessions** simultaneously. Click the **Broadcast** toolbar button to toggle. A red banner indicates broadcast is active. Use with caution. +## Scrolling + +Scroll the wheel or trackpad to move through the scrollback buffer (10 000 lines per session). While you are scrolled up, new output does **not** yank the view back down — the content you are reading stays pinned, even during fast streams once the buffer is full. Typing or pressing Enter snaps back to the bottom. + +- **Shift+PageUp / Shift+PageDown** — page through the scrollback from the keyboard. +- Inside full-screen apps that take over the mouse (claude CLI, htop, `vim` with `mouse=a`, tmux with `mouse on`), the wheel is forwarded to the app so it scrolls its own view; apps without mouse support get arrow keys (so `less` and `man` still scroll). +- If a full-screen app dies uncleanly (crash, dropped connection) and leaves the session looking frozen — prompt works but the wheel does nothing — right-click → **Reset Terminal** to recover (the local equivalent of running `reset`). + ## Search in Scrollback Press **Cmd/Ctrl+F** to open the search bar. Type a regex or plain string; all matches highlight in the buffer. Navigate with **Enter** (next) / **Shift+Enter** (previous). Press **Esc** to close. @@ -75,7 +83,7 @@ Select text by dragging (double-click selects a word). Then: | Context menu | right-click | right-click | | Paste (mouse) | middle-click | middle-click | -On Windows/Linux, **Ctrl+C** copies only while text is selected — the selection is cleared after copying, so pressing it again interrupts the running program (SIGINT) as usual. Right-click opens a **Copy / Paste / Select All** menu; Copy is disabled when nothing is selected. Apps that capture the mouse (vim, htop) keep receiving mouse clicks instead of triggering selection or paste. +On Windows/Linux, **Ctrl+C** copies only while text is selected — the selection is cleared after copying, so pressing it again interrupts the running program (SIGINT) as usual. Right-click opens a **Copy / Paste / Select All / Reset Terminal** menu; Copy is disabled when nothing is selected. Apps that capture the mouse (vim, htop) keep receiving mouse clicks instead of triggering selection or paste. ## Shell Integration From 0b699764fd53f8b9a79303aff888b7ca154d6366 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 12 Jun 2026 10:30:31 +0700 Subject: [PATCH 8/8] perf(xterm): skip composition attempts for zero-width non-marks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wcwidth is 0 for NUL as well as combining marks, and a NUL continuation cell is written after every wide (CJK) character — each one paid for a string allocation plus an unorm.nfc() call on the hottest write path. Guard the composition attempt with codePoint >= 0x0300 (canonical combining marks all live there), so CJK streams skip it entirely. Found by post-release code review of #68. --- packages/xterm/lib/src/core/buffer/buffer.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/xterm/lib/src/core/buffer/buffer.dart b/packages/xterm/lib/src/core/buffer/buffer.dart index 85e5d913..a4b918d9 100644 --- a/packages/xterm/lib/src/core/buffer/buffer.dart +++ b/packages/xterm/lib/src/core/buffer/buffer.dart @@ -118,7 +118,13 @@ class Buffer { // every mark occupied its own cell, displacing the diacritics and the // cursor. Marks with no precomposed form keep the legacy own-cell // behavior (the cell model stores a single codepoint). - if (cellWidth == 0 && _tryComposeMark(codePoint)) { + // + // The >= U+0300 guard matters for performance, not just correctness: + // wcwidth is 0 for NUL too, and a NUL continuation cell is written after + // EVERY wide (CJK) character — without the guard each one would pay for + // a string allocation + unorm.nfc() call on the hottest write path. + // Canonical combining marks all live at U+0300 and above. + if (cellWidth == 0 && codePoint >= 0x0300 && _tryComposeMark(codePoint)) { return; }