diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56e00e10..8a1bbe77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,8 @@ jobs: cargo test local_only cargo test commands::private_key_inspection_tests cargo test ssh::tests -- --test-threads=1 + cargo test pty_output_flush + cargo test pty::tests - name: Run ignored SSH security tests on Linux if: matrix.platform == 'ubuntu-22.04' diff --git a/CHANGELOG.md b/CHANGELOG.md index 53f4aefd..def4eb43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,15 @@ All notable changes to Zync are documented in this file. The format is based on ## [Unreleased] +## [2.30.0] - 2026-09-08 + +### Changed +- **Terminal output flush:** First PTY bytes after quiet go to the terminal immediately (typing echo no longer waits up to 8 ms). Busy output merges for a 12 ms burst, or sooner at 128 KiB. The live Channel frame is still generation + raw bytes. Debug: `localStorage.zyncTerminalIoDebug = '1'`. ([2424743], [d6ca46b]) +- **Terminal sniffers:** Secret/cwd helpers scan at most the last 4 KiB of large PTY frames so `cat` does not regex the whole dump. The terminal still receives every byte. ([2424743], [1ed79d8]) +- **File list and grid windowing:** File Manager list and icon grid only paint visible cells. Sort lives on FileManager so keyboard order matches the painted order. Grid arrows use the live column count instead of scanning the DOM. Folder loads no longer grayscale/scale the listing; recycled cells skip Radix tooltips and icon fade-in so scroll and clicks stay immediate. Icon-grid tiles use a fixed row height so the first row stays on screen and selected folders are normal-sized cards. ([c266a76], [5750e36], [ccb38e4], [7fa63ae], [ae6b9fa], [ecdaefc], [e5c2254]) + ### Added +- **What's New media**: Release notes show images, GIFs, and short videos inline (GitHub attachments, HTTPS `.gif`/`.png`/`.mp4` CDNs, and local files). A bare Demo URL is embedded instead of left as a link. Click an image to enlarge. GitHub alerts, ``, and task lists render; HTML stays sanitized. ([84080c5], [d6ca46b]) - **Smooth splits**: New panes grow in (about 280ms) instead of jumping to 50/50 — keyboard split, split icons, drag-to-dock, and Open in split / Open here. A quiet accent veil marks the incoming pane. Drag-to-split preview eases between edges. Divider drag stays immediate (no laggy flex transition). `prefers-reduced-motion` skips the intro. PTY resize waits until the intro ends, same as a divider drag. ([2d3b7c8]) - **Scroll to resize a split**: Hover the seam and use the mouse wheel or trackpad to move it. Drag and arrow keys still work. PTY resize waits until scrolling settles, same as a divider drag. Ctrl/Cmd+wheel is left for zoom. ([2d3b7c8]) - **Files in split**: Open Files beside a shell in the same tab (folder button next to the split icons, or workspace **+ → Files in split**). Split icons and Ctrl+Shift+arrows still create a shell. Workspace **+ → Files** stays the full-view overlay. Closing the Files pane unsplits that leaf; the tab × still closes the whole group. Files counts toward the 4-pane cap and never replaces the last shell. ([2d3b7c8]) @@ -13,7 +21,9 @@ All notable changes to Zync are documented in this file. The format is based on - **Open here in split**: Terminal **Open File Manager Here** is one row with **In a new tab** / **Left** / **Right** / **Bottom**. Files **Open Terminal Here** and **Follow with Terminal** use the same submenu. Split docks beside the current pane (not a stray new tab). ([2d3b7c8]) ### Fixed -- **Files opening at `/`**: File Manager no longer treats `/` as home. Open File Manager Here uses the shell cwd (or a real `fs_cwd`), not the pre-connect placeholder. First open and reconnect do the same. On Windows, local home uses `%USERPROFILE%` instead of unset `HOME`. ([2d3b7c8]) +- **Files opening at `/`**: File Manager no longer treats `/` as home. Open File Manager Here uses the shell cwd (or a real `fs_cwd`), not the pre-connect placeholder. First open ignores a shell cwd of `/` or `~` (SFTP cannot list a tilde) and expands to a real home instead of walking up to `/`. Reconnect keeps `/` when that listing already has files (hash button). Switching hosts clears selection and the open editor. On Windows, local home uses `%USERPROFILE%` instead of unset `HOME`. ([e5c2254], [e7334e6]) +- **Icon grid right gap:** Grid columns fill the Files pane (no empty strip, no leftover 3-column layout in a wide window). Large folders re-render less while the shell cwd updates. ([40f3988]) + - **Drag shell tab onto itself**: Dropping the current shell tab on its own split is a no-op again and restores Files / Dashboard / Snippets if the drag started from that overlay. ([2d3b7c8]) - **Open here after a tab switch**: Open File Manager Here / Open Terminal Here keep the tab that opened the menu, instead of applying to whichever tab is active after the await. ([2d3b7c8]) - **Files overlay vs Files pane**: The keep-alive overlay FileManager no longer steals focus or drives Follow-terminal while a Files pane is showing (and the reverse). ([2d3b7c8]) @@ -1369,7 +1379,22 @@ Partial draft: desktop builds, AppImage Wayland strip, and APT `2.25.4` publishe [d3f4060]: https://github.com/zync-sh/zync/commit/d3f4060 [840afc2]: https://github.com/zync-sh/zync/commit/840afc2 [193f568]: https://github.com/zync-sh/zync/commit/193f568 -[Unreleased]: https://github.com/zync-sh/zync/compare/v2.29.0...HEAD +[Unreleased]: https://github.com/zync-sh/zync/compare/v2.30.0...HEAD +[2.30.0]: https://github.com/zync-sh/zync/compare/v2.29.0...v2.30.0 +[2424743]: https://github.com/zync-sh/zync/commit/2424743 +[1ed79d8]: https://github.com/zync-sh/zync/commit/1ed79d8 +[c266a76]: https://github.com/zync-sh/zync/commit/c266a76 +[5750e36]: https://github.com/zync-sh/zync/commit/5750e36 +[ccb38e4]: https://github.com/zync-sh/zync/commit/ccb38e4 +[7fa63ae]: https://github.com/zync-sh/zync/commit/7fa63ae +[ae6b9fa]: https://github.com/zync-sh/zync/commit/ae6b9fa +[ecdaefc]: https://github.com/zync-sh/zync/commit/ecdaefc +[e5c2254]: https://github.com/zync-sh/zync/commit/e5c2254 +[40f3988]: https://github.com/zync-sh/zync/commit/40f3988 +[e7334e6]: https://github.com/zync-sh/zync/commit/e7334e6 +[84080c5]: https://github.com/zync-sh/zync/commit/84080c5 +[d6ca46b]: https://github.com/zync-sh/zync/commit/d6ca46b +[2d3b7c8]: https://github.com/zync-sh/zync/commit/2d3b7c8 [2.29.0]: https://github.com/zync-sh/zync/compare/v2.28.0...v2.29.0 [2.28.0]: https://github.com/zync-sh/zync/compare/v2.27.1...v2.28.0 [5181d5e]: https://github.com/zync-sh/zync/commit/5181d5e diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c6fe772..d0073d9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,7 @@ Thank you for your interest in contributing to Zync. This document provides guid npm run type-check npm run tauri build ``` + Release Rust uses thin LTO (`src-tauri/Cargo.toml` `[profile.release]`). `tauri dev` is unchanged. 4. Commit with clear, descriptive messages: ```bash diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md index 6c73ddd2..84b31cae 100644 --- a/docs/TERMINAL.md +++ b/docs/TERMINAL.md @@ -211,11 +211,12 @@ Public surface exported from `index.ts`. Key modules: | File | Responsibility | |------|------| -| `src-tauri/src/pty.rs` | PtyManager: local spawn/read/write/resize/close; remote SSH reader; output batching (8ms / 4KB); explicit `child.kill()` on close | -| `src-tauri/src/commands.rs` | `terminal_create` (accepts output `Channel`), `terminal_write`, `terminal_resize`, `terminal_has_active_processes`, close variants | +| `src-tauri/src/pty.rs` | PtyManager: local spawn/read/write/resize/close; remote SSH reader; output flush via `pty_output_flush`; explicit `child.kill()` on close | +| `src-tauri/src/pty_output_flush.rs` | Idle-immediate + fixed 12 ms burst epoch + 128 KiB flush **threshold** (not max frame size). Process-wide flush-reason counters. | +| `src-tauri/src/commands.rs` | `terminal_create` (accepts output `Channel`), `terminal_write`, `terminal_resize`, `terminal_has_active_processes`, `terminal_flush_stats` (process-wide flush-reason snapshot; not on the output Channel), close variants | | `src-tauri/src/ghost/*` | Ghost suggestion persistence, parser, ranking, Tauri commands | -**Output batching (remote & local):** `REMOTE_OUTPUT_BATCH_MS` / `OUTPUT_FLUSH_THRESHOLD` coalesce before sending to frontend channel. +**Output flush (remote & local):** `OUTPUT_BURST_EPOCH_MS` (12 ms **fixed epoch**, not a debounce) and `OUTPUT_BURST_FLUSH_THRESHOLD` (128 KiB — flush at least this soon while bursting, not a max frame size). First bytes after idle send immediately; later packets in the epoch accumulate; close/EOF/`output_rx` None always drain the tail. Frame layout is unchanged: `u32` LE generation + raw bytes. **Resize (remote):** SSH resize channel drains to latest cols/rows (trailing coalesce). @@ -230,6 +231,7 @@ Public surface exported from `index.ts`. Key modules: - `terminal:resize` — cols/rows from unified resize scheduler - `terminal:close` / `terminal:close_by_connection` — programmatic teardown (**no** `terminal-exit`) - `terminal_has_active_processes` — local sysinfo child-tree probe +- `terminal:flush-stats` / `terminal_flush_stats` — process-wide flush-reason counters (`idleFirst` / `timer` / `threshold` / `close`). Debug only. Not on the PTY output Channel. ### Events (listen) @@ -310,7 +312,9 @@ xterm.onData [ u32 generation (LE) ][ raw PTY bytes... ] ``` -Decoded in `terminalOutputStream.ts` → `term.write()` after generation check. +Decoded in `terminalOutputStream.ts` → generation check → sniffers → `term.write()` of the **full** frame. + +Sniffers (secret prompt / prompt cwd) are reading aids. Frames ≤ 8 KiB are scanned in full. Larger frames scan only the last 4 KiB (`selectSnifferBytes`). A prompt is detected iff it appears in a ≤8 KiB frame or in that tail. `term.write` is never sliced (byte-exact xterm). OSC 7 cwd is parsed inside xterm, not these sniffers. **Legacy:** `terminal-output-*` events removed. `terminalOutputPayload.ts` retains base64/array decode for older dev builds. @@ -536,6 +540,7 @@ src/index.css # .terminal-container, xterm 6 viewport overrides ``` src-tauri/src/pty.rs +src-tauri/src/pty_output_flush.rs src-tauri/src/commands.rs src-tauri/src/ghost/ ``` diff --git a/package-lock.json b/package-lock.json index f8191e53..f4441aec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zync", - "version": "2.26.1", + "version": "2.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zync", - "version": "2.26.1", + "version": "2.30.0", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/lang-css": "^6.3.1", @@ -51,6 +51,8 @@ "react-virtualized-auto-sizer": "^2.0.2", "react-window": "^2.2.5", "recharts": "^3.7.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "zustand": "^5.0.10" @@ -3711,6 +3713,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-toolkit": { "version": "1.45.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.0.tgz", @@ -3982,6 +3996,26 @@ "dev": true, "license": "ISC" }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-parse-selector": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", @@ -3995,6 +4029,46 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -4022,6 +4096,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -4077,6 +4170,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -5442,6 +5545,18 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5783,6 +5898,35 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -6258,6 +6402,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -6375,6 +6533,16 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 8eb6daaf..d2abd5dd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zync", "private": true, - "version": "2.29.0", + "version": "2.30.0", "type": "module", "repository": { "type": "git", @@ -43,6 +43,7 @@ "test:vault-focus": "npm run compile:agent-tests && node tests/vaultFocus.test.mjs", "test:unlock-modal-consistency": "node tests/unlockModalConsistency.test.mjs", "test:ghost-helpers": "npm run compile:agent-tests && node tests/ghostSuggestionsHelpers.test.mjs", + "test:release-notes-markdown": "npm run compile:agent-tests && node tests/releaseNotesMarkdown.test.mjs", "test:session-persistence": "npm run compile:agent-tests && node tests/sessionPersistence.test.mjs", "test:terminal-renderer-policy": "npm run compile:agent-tests && node tests/terminalRendererPolicy.test.mjs", "test:terminal-renderer": "npm run compile:agent-tests && node tests/runTerminalRendererTests.mjs", @@ -95,6 +96,8 @@ "react-virtualized-auto-sizer": "^2.0.2", "react-window": "^2.2.5", "recharts": "^3.7.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "zustand": "^5.0.10" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e5c2265d..8638ea63 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8267,7 +8267,7 @@ dependencies = [ [[package]] name = "zync" -version = "2.29.0" +version = "2.30.0" dependencies = [ "anyhow", "argon2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a9e57c32..756f011d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zync" -version = "2.29.0" +version = "2.30.0" description = "A modern SSH client" authors = ["Gajendra"] edition = "2021" @@ -63,3 +63,9 @@ tauri-plugin-single-instance = { version = "2", default-features = false } [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.56" + +# Thin LTO for the PTY/SSH path without fat-LTO link times. codegen-units 8 is +# tighter than Cargo's default 16 and cheaper than 1 on Windows/macOS release CI. +[profile.release] +lto = "thin" +codegen-units = 8 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c016b558..143ecb9c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3058,6 +3058,13 @@ pub async fn terminal_has_active_processes( Ok(state.pty_manager.has_active_child_processes(&term_id).await) } +/// Process-wide PTY flush-reason totals. Not per-session. Does not change the +/// output Channel frame layout. +#[tauri::command] +pub fn terminal_flush_stats() -> crate::pty_output_flush::FlushReasonCounts { + crate::pty_output_flush::flush_reason_snapshot() +} + // Helper to get SFTP session - reconnects automatically if session is dead. // Zero overhead for healthy connections; only re-establishes when needed. async fn get_sftp_or_reconnect( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d57dabaf..63f46438 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod ghost; mod identity_migration; pub mod plugins; mod pty; +mod pty_output_flush; mod session; mod shell_icons; mod snippets; @@ -154,6 +155,7 @@ pub fn run() { commands::terminal_create, commands::terminal_close, commands::terminal_has_active_processes, + commands::terminal_flush_stats, commands::connections_get, commands::connections_save, commands::connections_export_to_file, diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 7ce6c62a..b6985ab4 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -5,19 +5,17 @@ use russh::{Channel, ChannelMsg}; use serde::Serialize; use std::collections::HashMap; use std::io::{Read, Write}; -use std::mem; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc as std_mpsc; use std::sync::Arc; use tauri::ipc::{Channel as IpcChannel, InvokeResponseBody}; use tauri::{AppHandle, Emitter}; use tokio::sync::{mpsc, Mutex}; -use tokio::time::{Duration, Instant}; +use tokio::time::Instant; -/// Maximum time to hold PTY output before emitting a combined frontend event. -const OUTPUT_BATCH_MS: u64 = 8; -/// Flush buffered PTY output immediately once it reaches this many bytes. -const OUTPUT_FLUSH_THRESHOLD: usize = 4096; +use crate::pty_output_flush::{ + encode_output_frame, record_flush_reason, FlushInstruction, FlushReason, OutputFlushPolicy, +}; enum LocalReaderEvent { Data(Vec), @@ -243,29 +241,45 @@ struct TerminalLifecycleEvent { exit_code: Option, } -/// Flushes buffered PTY output through the streaming IPC channel. +/// Flushes PTY bytes through the streaming IPC channel. /// /// Frames are `generation` (u32 LE) + raw PTY bytes so the frontend can ignore -/// stale chunks after suspend/restart races. -fn flush_pending_output( - output_channel: &IpcChannel, - generation: u32, - pending_output: &mut Vec, -) { - if pending_output.is_empty() { +/// stale chunks after suspend/restart races. Layout must not change. +fn flush_output_frame(output_channel: &IpcChannel, generation: u32, bytes: Vec) { + if bytes.is_empty() { return; } - let output = mem::take(pending_output); - let mut frame = Vec::with_capacity(4 + output.len()); - frame.extend_from_slice(&generation.to_le_bytes()); - frame.extend_from_slice(&output); - + let frame = encode_output_frame(generation, &bytes); if let Err(e) = output_channel.send(InvokeResponseBody::Raw(frame)) { eprintln!("[PTY] Failed to send output on channel: {}", e); } } +fn apply_flush_instruction( + output_channel: &IpcChannel, + generation: u32, + instruction: FlushInstruction, +) { + if let FlushInstruction::Flush { + bytes, + reason, + rearm_burst: _, + } = instruction { + record_flush_reason(reason); + flush_output_frame(output_channel, generation, bytes); + } +} + +fn flush_policy_tail(output_channel: &IpcChannel, generation: u32, policy: &mut OutputFlushPolicy) { + let tail = policy.take_tail_on_close(); + if tail.is_empty() { + return; + } + record_flush_reason(FlushReason::Close); + flush_output_frame(output_channel, generation, tail); +} + fn process_tree_has_children(root_pid: u32) -> bool { use sysinfo::{Pid, ProcessesToUpdate, System}; let mut system = System::new(); @@ -324,20 +338,17 @@ fn remote_wait_action(msg: Option<&ChannelMsg>) -> RemoteWaitAction { fn buffer_remote_wait_output( msg: &ChannelMsg, - pending_output: &mut Vec, - flush_deadline: &mut Option, + policy: &mut OutputFlushPolicy, output_channel: &IpcChannel, generation: u32, ) { match msg { ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. } => { - pending_output.extend_from_slice(data.as_ref()); - if pending_output.len() >= OUTPUT_FLUSH_THRESHOLD { - flush_pending_output(output_channel, generation, pending_output); - *flush_deadline = None; - } else if flush_deadline.is_none() { - *flush_deadline = Some(Instant::now() + Duration::from_millis(OUTPUT_BATCH_MS)); - } + apply_flush_instruction( + output_channel, + generation, + policy.on_bytes(data.as_ref(), Instant::now()), + ); } _ => {} } @@ -673,25 +684,22 @@ impl PtyManager { let term_id_for_exit = term_id.clone(); let reader_handle = tokio::spawn(async move { - let mut pending_output = Vec::new(); - let mut flush_deadline: Option = None; + let mut policy = OutputFlushPolicy::new(); loop { + let deadline = policy.deadline(); tokio::select! { event = output_rx.recv() => { match event { Some(LocalReaderEvent::Data(chunk)) => { - pending_output.extend_from_slice(&chunk); - - if pending_output.len() >= OUTPUT_FLUSH_THRESHOLD { - flush_pending_output(&output_channel_clone, generation, &mut pending_output); - flush_deadline = None; - } else if flush_deadline.is_none() { - flush_deadline = Some(Instant::now() + Duration::from_millis(OUTPUT_BATCH_MS)); - } + apply_flush_instruction( + &output_channel_clone, + generation, + policy.on_bytes(&chunk, Instant::now()), + ); } Some(LocalReaderEvent::Finished { exit_code }) => { - flush_pending_output(&output_channel_clone, generation, &mut pending_output); + flush_policy_tail(&output_channel_clone, generation, &mut policy); if !exit_emitted_clone.swap(true, Ordering::SeqCst) { emit_terminal_exit( &app_handle_clone, @@ -712,17 +720,23 @@ impl PtyManager { } break; } - None => break, + None => { + flush_policy_tail(&output_channel_clone, generation, &mut policy); + break; + } } } _ = async { - if let Some(deadline) = flush_deadline { - tokio::time::sleep_until(deadline).await; + if let Some(d) = deadline { + tokio::time::sleep_until(d).await; } - }, if flush_deadline.is_some() => { - flush_pending_output(&output_channel_clone, generation, &mut pending_output); - flush_deadline = None; + }, if deadline.is_some() => { + apply_flush_instruction( + &output_channel_clone, + generation, + policy.on_timer(Instant::now()), + ); } } } @@ -897,12 +911,12 @@ impl PtyManager { // output/exit events can never arrive before the frontend has seen ready. let task_handle = tokio::task::spawn(async move { let app_handle = app_handle_clone; - let mut pending_output = Vec::new(); - let mut flush_deadline: Option = None; + let mut policy = OutputFlushPolicy::new(); let drop_transport; let exit_code; loop { + let deadline = policy.deadline(); tokio::select! { msg = channel.wait() => { match remote_wait_action(msg.as_ref()) { @@ -910,21 +924,20 @@ impl PtyManager { if let Some(ref msg) = msg { buffer_remote_wait_output( msg, - &mut pending_output, - &mut flush_deadline, + &mut policy, &output_channel_clone, generation, ); } } RemoteWaitAction::PaneExit { exit_code: code } => { - flush_pending_output(&output_channel_clone, generation, &mut pending_output); + flush_policy_tail(&output_channel_clone, generation, &mut policy); drop_transport = false; exit_code = Some(code); break; } RemoteWaitAction::TransportDrop => { - flush_pending_output(&output_channel_clone, generation, &mut pending_output); + flush_policy_tail(&output_channel_clone, generation, &mut policy); exit_code = Some(None); drop_transport = true; break; @@ -935,12 +948,15 @@ impl PtyManager { } _ = async { - if let Some(deadline) = flush_deadline { - tokio::time::sleep_until(deadline).await; + if let Some(d) = deadline { + tokio::time::sleep_until(d).await; } - }, if flush_deadline.is_some() => { - flush_pending_output(&output_channel_clone, generation, &mut pending_output); - flush_deadline = None; + }, if deadline.is_some() => { + apply_flush_instruction( + &output_channel_clone, + generation, + policy.on_timer(Instant::now()), + ); } Some(input) = rx.recv() => { @@ -964,7 +980,7 @@ impl PtyManager { } } - flush_pending_output(&output_channel_clone, generation, &mut pending_output); + flush_policy_tail(&output_channel_clone, generation, &mut policy); let _ = channel.close().await; let mut sessions = sessions_for_exit.lock().await; diff --git a/src-tauri/src/pty_output_flush.rs b/src-tauri/src/pty_output_flush.rs new file mode 100644 index 00000000..3550c4b8 --- /dev/null +++ b/src-tauri/src/pty_output_flush.rs @@ -0,0 +1,335 @@ +//! PTY output flush policy: idle-immediate send, then a fixed burst epoch. +//! +//! Channel I/O stays in `pty.rs`. This module only decides *when* bytes should +//! leave and encodes the generation-prefixed frame. The 12 ms timer is a +//! **fixed epoch** from the idle-first send (or from a threshold re-arm). Later +//! packets in the same epoch do **not** reset it. +//! +//! `OUTPUT_BURST_FLUSH_THRESHOLD` is "flush at least this soon while bursting", +//! not a maximum frame size. An idle-first chunk may exceed the threshold. + +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio::time::{Duration, Instant}; + +/// Fixed burst epoch after an idle-first send or a threshold re-arm. +pub const OUTPUT_BURST_EPOCH_MS: u64 = 12; +/// While bursting, flush pending at least this soon. Not a max frame size. +pub const OUTPUT_BURST_FLUSH_THRESHOLD: usize = 128 * 1024; + +static FLUSH_IDLE_FIRST: AtomicU64 = AtomicU64::new(0); +static FLUSH_TIMER: AtomicU64 = AtomicU64::new(0); +static FLUSH_THRESHOLD: AtomicU64 = AtomicU64::new(0); +static FLUSH_CLOSE: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlushReason { + IdleFirst, + Timer, + Threshold, + Close, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FlushReasonCounts { + pub idle_first: u64, + pub timer: u64, + pub threshold: u64, + pub close: u64, +} + +pub fn record_flush_reason(reason: FlushReason) { + let counter = match reason { + FlushReason::IdleFirst => &FLUSH_IDLE_FIRST, + FlushReason::Timer => &FLUSH_TIMER, + FlushReason::Threshold => &FLUSH_THRESHOLD, + FlushReason::Close => &FLUSH_CLOSE, + }; + counter.fetch_add(1, Ordering::Relaxed); +} + +pub fn flush_reason_snapshot() -> FlushReasonCounts { + FlushReasonCounts { + idle_first: FLUSH_IDLE_FIRST.load(Ordering::Relaxed), + timer: FLUSH_TIMER.load(Ordering::Relaxed), + threshold: FLUSH_THRESHOLD.load(Ordering::Relaxed), + close: FLUSH_CLOSE.load(Ordering::Relaxed), + } +} + +#[cfg(test)] +pub fn reset_flush_reason_counts() { + FLUSH_IDLE_FIRST.store(0, Ordering::Relaxed); + FLUSH_TIMER.store(0, Ordering::Relaxed); + FLUSH_THRESHOLD.store(0, Ordering::Relaxed); + FLUSH_CLOSE.store(0, Ordering::Relaxed); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FlushMode { + Idle, + Bursting, +} + +pub struct OutputFlushPolicy { + pending: Vec, + mode: FlushMode, + burst_deadline: Option, +} + +pub enum FlushInstruction { + None, + /// Caller must send `bytes` on the Channel before the next on_bytes/on_timer. + Flush { + bytes: Vec, + /// True when the policy stayed Bursting (idle-first or threshold). + /// Callers send `bytes` and ignore this; tests assert epoch re-arm. + #[allow(dead_code)] + rearm_burst: bool, + reason: FlushReason, + }, +} + +impl Default for OutputFlushPolicy { + fn default() -> Self { + Self::new() + } +} + +impl OutputFlushPolicy { + pub fn new() -> Self { + Self { + pending: Vec::new(), + mode: FlushMode::Idle, + burst_deadline: None, + } + } + + pub fn deadline(&self) -> Option { + self.burst_deadline + } + + pub fn on_bytes(&mut self, chunk: &[u8], now: Instant) -> FlushInstruction { + if chunk.is_empty() { + return FlushInstruction::None; + } + + match self.mode { + FlushMode::Idle => { + self.mode = FlushMode::Bursting; + self.burst_deadline = Some(now + Duration::from_millis(OUTPUT_BURST_EPOCH_MS)); + FlushInstruction::Flush { + bytes: chunk.to_vec(), + rearm_burst: true, + reason: FlushReason::IdleFirst, + } + } + FlushMode::Bursting => { + self.pending.extend_from_slice(chunk); + if self.pending.len() >= OUTPUT_BURST_FLUSH_THRESHOLD { + self.burst_deadline = Some(now + Duration::from_millis(OUTPUT_BURST_EPOCH_MS)); + FlushInstruction::Flush { + bytes: std::mem::take(&mut self.pending), + rearm_burst: true, + reason: FlushReason::Threshold, + } + } else { + FlushInstruction::None + } + } + } + } + + pub fn on_timer(&mut self, _now: Instant) -> FlushInstruction { + if self.mode != FlushMode::Bursting || self.burst_deadline.is_none() { + return FlushInstruction::None; + } + + if self.pending.is_empty() { + self.mode = FlushMode::Idle; + self.burst_deadline = None; + return FlushInstruction::None; + } + + self.mode = FlushMode::Idle; + self.burst_deadline = None; + FlushInstruction::Flush { + bytes: std::mem::take(&mut self.pending), + rearm_burst: false, + reason: FlushReason::Timer, + } + } + + /// EOF / exit / close / mpsc closed: drain, Idle, deadline None. + pub fn take_tail_on_close(&mut self) -> Vec { + self.mode = FlushMode::Idle; + self.burst_deadline = None; + std::mem::take(&mut self.pending) + } +} + +pub fn encode_output_frame(generation: u32, payload: &[u8]) -> Vec { + let mut frame = Vec::with_capacity(4 + payload.len()); + frame.extend_from_slice(&generation.to_le_bytes()); + frame.extend_from_slice(payload); + frame +} + +#[cfg(test)] +mod tests { + use super::*; + + fn t0() -> Instant { + Instant::now() + } + + fn epoch() -> Duration { + Duration::from_millis(OUTPUT_BURST_EPOCH_MS) + } + + fn flush_bytes(instr: FlushInstruction) -> (Vec, bool, FlushReason) { + match instr { + FlushInstruction::Flush { + bytes, + rearm_burst, + reason, + } => (bytes, rearm_burst, reason), + FlushInstruction::None => panic!("expected Flush"), + } + } + + #[test] + fn idle_first_chunk_flushes_immediately() { + let mut p = OutputFlushPolicy::new(); + let now = t0(); + let (bytes, rearm, reason) = flush_bytes(p.on_bytes(b"a", now)); + assert_eq!(bytes, b"a"); + assert!(rearm); + assert_eq!(reason, FlushReason::IdleFirst); + assert_eq!(p.deadline(), Some(now + epoch())); + } + + #[test] + fn burst_merge_then_timer_flushes_tail() { + let mut p = OutputFlushPolicy::new(); + let now = t0(); + let _ = p.on_bytes(b"lead", now); + assert!(matches!(p.on_bytes(b"one", now), FlushInstruction::None)); + assert!(matches!(p.on_bytes(b"two", now), FlushInstruction::None)); + let (bytes, rearm, reason) = flush_bytes(p.on_timer(now + epoch())); + assert_eq!(bytes, b"onetwo"); + assert!(!rearm); + assert_eq!(reason, FlushReason::Timer); + assert!(p.deadline().is_none()); + } + + #[test] + fn epoch_does_not_reset_on_append() { + let mut p = OutputFlushPolicy::new(); + let now = t0(); + let _ = p.on_bytes(b"lead", now); + let deadline = p.deadline().expect("epoch armed"); + assert!(matches!( + p.on_bytes(b"x", now + Duration::from_millis(3)), + FlushInstruction::None + )); + assert!(matches!( + p.on_bytes(b"y", now + Duration::from_millis(6)), + FlushInstruction::None + )); + assert_eq!(p.deadline(), Some(deadline), "append must not move the epoch"); + let (bytes, rearm, _) = flush_bytes(p.on_timer(deadline)); + assert_eq!(bytes, b"xy"); + assert!(!rearm); + } + + #[test] + fn threshold_flush_stays_bursting_and_rearms_epoch() { + let mut p = OutputFlushPolicy::new(); + let now = t0(); + let _ = p.on_bytes(b"lead", now); + let first_deadline = p.deadline(); + let chunk = vec![b'z'; OUTPUT_BURST_FLUSH_THRESHOLD]; + let later = now + Duration::from_millis(1); + let (bytes, rearm, reason) = flush_bytes(p.on_bytes(&chunk, later)); + assert!(bytes.len() >= OUTPUT_BURST_FLUSH_THRESHOLD); + assert!(rearm); + assert_eq!(reason, FlushReason::Threshold); + let new_deadline = p.deadline().expect("re-armed"); + assert_eq!(new_deadline, later + epoch()); + assert_ne!(Some(new_deadline), first_deadline); + } + + #[test] + fn empty_epoch_returns_to_idle() { + let mut p = OutputFlushPolicy::new(); + let now = t0(); + let _ = p.on_bytes(b"lead", now); + assert!(matches!(p.on_timer(now + epoch()), FlushInstruction::None)); + assert!(p.deadline().is_none()); + let (bytes, rearm, reason) = flush_bytes(p.on_bytes(b"x", now + Duration::from_secs(1))); + assert_eq!(bytes, b"x"); + assert!(rearm); + assert_eq!(reason, FlushReason::IdleFirst); + } + + #[test] + fn take_tail_on_close_drains_pending() { + let mut p = OutputFlushPolicy::new(); + let now = t0(); + let _ = p.on_bytes(b"lead", now); + let _ = p.on_bytes(b"tail", now); + let tail = p.take_tail_on_close(); + assert_eq!(tail, b"tail"); + assert!(p.deadline().is_none()); + assert!(p.take_tail_on_close().is_empty()); + } + + #[test] + fn empty_on_bytes_is_none() { + let mut p = OutputFlushPolicy::new(); + assert!(matches!(p.on_bytes(b"", t0()), FlushInstruction::None)); + assert!(p.deadline().is_none()); + } + + #[test] + fn on_timer_in_idle_is_none() { + let mut p = OutputFlushPolicy::new(); + assert!(matches!(p.on_timer(t0()), FlushInstruction::None)); + } + + #[test] + fn encode_output_frame_is_u32_le_then_payload() { + let frame = encode_output_frame(0x0102_0304, b"AB"); + assert_eq!(frame, [0x04, 0x03, 0x02, 0x01, b'A', b'B']); + assert_eq!(frame.len(), 4 + 2); + } + + #[test] + fn idle_first_chunk_larger_than_threshold_is_one_frame() { + let mut p = OutputFlushPolicy::new(); + let now = t0(); + let chunk = vec![b'q'; OUTPUT_BURST_FLUSH_THRESHOLD + 8]; + let (bytes, rearm, reason) = flush_bytes(p.on_bytes(&chunk, now)); + assert_eq!(bytes.len(), OUTPUT_BURST_FLUSH_THRESHOLD + 8); + assert!(rearm); + assert_eq!(reason, FlushReason::IdleFirst); + } + + #[test] + fn flush_reason_atomics_count_each_kind() { + reset_flush_reason_counts(); + record_flush_reason(FlushReason::IdleFirst); + record_flush_reason(FlushReason::Timer); + record_flush_reason(FlushReason::Threshold); + record_flush_reason(FlushReason::Close); + record_flush_reason(FlushReason::Close); + let snap = flush_reason_snapshot(); + assert_eq!(snap.idle_first, 1); + assert_eq!(snap.timer, 1); + assert_eq!(snap.threshold, 1); + assert_eq!(snap.close, 2); + reset_flush_reason_counts(); + assert_eq!(flush_reason_snapshot(), FlushReasonCounts::default()); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5d264f62..c55ce332 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "zync", - "version": "2.29.0", + "version": "2.30.0", "identifier": "in.thesudoer.zync", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/FileManager.tsx b/src/components/FileManager.tsx index bb49d77a..b33edf4a 100644 --- a/src/components/FileManager.tsx +++ b/src/components/FileManager.tsx @@ -23,6 +23,7 @@ import { isMatch } from '../lib/keyboard'; import { FileEditor } from './FileEditor'; import { CopyToServerModal } from './file-manager/CopyToServerModal'; import { FileGrid } from './file-manager/FileGrid'; +import { sortFileEntries, type FileSortColumn, type FileSortDirection } from './file-manager/fileGridLayout'; import { getCurrentDragSource } from '../lib/dragDrop'; import { FileToolbar } from './file-manager/FileToolbar'; import type { FileEntry } from './file-manager/types'; @@ -40,10 +41,12 @@ import { clearEditorOverlayOpen, markEditorOverlayOpen } from './editor/overlayS import { TerminalDisconnectedView } from './terminal/TerminalDisconnectedView'; import { isFeaturePaneFocused, layoutForTerm } from '../lib/paneLayout'; import type { AppStore } from '../store/useAppStore'; -import { canSplitBesideFiles, openHerePlacementItems, openTerminalHere, pickFilesOpenPath } from './layout/tabDock'; +import { canSplitBesideFiles, isUnresolvedFilesPath, openHerePlacementItems, openTerminalHere, pickFilesOpenPath } from './layout/tabDock'; export type FileManagerSurface = 'overlay' | 'pane'; +const EMPTY_FILES: FileEntry[] = []; + function isFileManagerActive( state: AppStore, connectionId: string | undefined, @@ -142,11 +145,17 @@ export const FileManager = memo(function FileManager({ }); const isFilesSurfaceActive = useAppStore((state) => isFileManagerActive(state, connectionId, surface)); - // Zustand Store Hooks - const filesMap = useAppStore(state => state.files); - const currentPathMap = useAppStore(state => state.currentPath); - const loadingMap = useAppStore(state => state.isLoading); - const errorMap = useAppStore(state => state.error); + // Zustand Store Hooks — subscribe to this connection only so other hosts do not re-paint Files. + const files = useAppStore(state => ( + activeConnectionId ? (state.files[activeConnectionId] ?? EMPTY_FILES) : EMPTY_FILES + )); + const currentPath = useAppStore(state => ( + activeConnectionId ? (state.currentPath[activeConnectionId] ?? '') : '' + )); + const loading = useAppStore(state => Boolean(activeConnectionId && state.isLoading[activeConnectionId])); + const currentError = useAppStore(state => ( + activeConnectionId ? (state.error[activeConnectionId] ?? null) : null + )); const loadFiles = useAppStore(state => state.loadFiles); const refreshFiles = useAppStore(state => state.refreshFiles); const createFolder = useAppStore(state => state.createFolder); @@ -163,21 +172,37 @@ export const FileManager = memo(function FileManager({ const updateFileManagerSettings = useAppStore(state => state.updateFileManagerSettings); // const downloadAction = useAppStore(state => state.downloadFiles); // Not implemented fully yet - // Derived State - const files = activeConnectionId ? (filesMap[activeConnectionId] || []) : []; - const currentPath = activeConnectionId ? (currentPathMap[activeConnectionId] || '') : ''; - const loading = activeConnectionId ? (loadingMap[activeConnectionId] || false) : false; - const currentError = activeConnectionId ? (errorMap[activeConnectionId] || null) : null; const activeHistoryIndex = useAppStore(state => ( activeConnectionId ? (state.historyIndex[activeConnectionId] || 0) : 0 )); const activeHistoryLength = useAppStore(state => ( activeConnectionId ? (state.history[activeConnectionId]?.length || 0) : 0 )); + const filesOpenHint = useAppStore((state) => { + if (!activeConnectionId) return ''; + const path = state.currentPath[activeConnectionId] ?? ''; + const listing = state.files[activeConnectionId]; + const waiting = (!path || path === '/' || path === '~') && (!listing || listing.length === 0); + if (!waiting) return ''; + const activeId = state.activeTerminalIds[activeConnectionId]; + const tabs = state.terminals[activeConnectionId] || []; + const term = tabs.find((tab) => tab.id === activeId) ?? tabs.find((tab) => tab.tabVisible !== false); + const home = state.connections.find((item) => item.id === activeConnectionId)?.homePath; + return `${term?.lastKnownCwd || ''}|${term?.initialPath || ''}|${home || ''}`; + }); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [selectedFiles, setSelectedFiles] = useState([]); const [focusedFile, setFocusedFile] = useState(null); + const [sortColumn, setSortColumn] = useState('name'); + const [sortDirection, setSortDirection] = useState('asc'); + const [gridColumnCount, setGridColumnCount] = useState(1); + const [selectionPath, setSelectionPath] = useState(currentPath); + if (selectionPath !== currentPath) { + setSelectionPath(currentPath); + setSelectedFiles([]); + setFocusedFile(null); + } const [searchTerm, setSearchTerm] = useState(''); const [isSearchOpen, setIsSearchOpen] = useState(false); const [isEditingPath, setIsEditingPath] = useState(false); @@ -186,6 +211,15 @@ export const FileManager = memo(function FileManager({ const [editingFile, setEditingFile] = useState(null); const [editorContent, setEditorContent] = useState(''); const [editorProviderOverride, setEditorProviderOverride] = useState(null); + const [boundConnectionId, setBoundConnectionId] = useState(activeConnectionId); + if (boundConnectionId !== activeConnectionId) { + setBoundConnectionId(activeConnectionId); + setSelectedFiles([]); + setFocusedFile(null); + setEditingFile(null); + setEditorContent(''); + setEditorProviderOverride(null); + } // Modal States const [isNewFolderModalOpen, setIsNewFolderModalOpen] = useState(false); @@ -434,7 +468,7 @@ export const FileManager = memo(function FileManager({ await executeFileOperations(ops); }, [activeConnectionId, clipboard, currentPath, executeFileOperations]); - const handleMoveFiles = async (moves: { source: string; target: string; sourceConnectionId?: string }[]) => { + const handleMoveFiles = useCallback(async (moves: { source: string; target: string; sourceConnectionId?: string }[]) => { if (!activeConnectionId || moves.length === 0) return; const ops = moves.map(m => ({ @@ -449,7 +483,7 @@ export const FileManager = memo(function FileManager({ const targetDir = firstTarget ? firstTarget.substring(0, firstTarget.lastIndexOf('/')) || '/' : undefined; await executeFileOperations(ops, targetDir); - }; + }, [activeConnectionId, executeFileOperations]); const resolveConflict = async (action: ConflictAction, applyToAll = false) => { if (!currentConflict || !activeConnectionId || isProcessing) return; @@ -685,14 +719,18 @@ export const FileManager = memo(function FileManager({ initialPath: term?.initialPath, homePath: connection?.homePath, }); + // `/` from lastKnownCwd / homePath is the connect placeholder, not home. + const fromPick = isUnresolvedFilesPath(picked) ? '' : picked; + const listingIsPlaceholder = isUnresolvedFilesPath(currentPath) && files.length === 0; - if (!currentPath) { + if (listingIsPlaceholder) { try { - const cwd = picked || await window.ipcRenderer.invoke('fs_cwd', { + const cwd = fromPick || await window.ipcRenderer.invoke('fs_cwd', { connectionId: activeConnectionId, }); const path = typeof cwd === 'string' ? cwd.trim() : ''; - if (!path) return; + // Do not paint `/` as home. Retry when lastKnownCwd/homePath updates. + if (!path || isUnresolvedFilesPath(path)) return; loadFiles(activeConnectionId, path); const termId = ensureTerminal(activeConnectionId, path); @@ -708,12 +746,12 @@ export const FileManager = memo(function FileManager({ return; } console.error('Failed to get home dir:', error); - if (picked) loadFiles(activeConnectionId, picked); + if (fromPick) loadFiles(activeConnectionId, fromPick); } } else if (files.length === 0) { loadFiles(activeConnectionId, currentPath); } - }, [activeConnectionId, isConnected, currentPath, files.length, loadFiles, ensureTerminal]); + }, [activeConnectionId, isConnected, currentPath, files.length, filesOpenHint, loadFiles, ensureTerminal]); const handleReconnect = useCallback(async () => { if (!activeConnectionId || isLocal) return; @@ -721,11 +759,14 @@ export const FileManager = memo(function FileManager({ await connect(activeConnectionId); const reconnected = useAppStore.getState().connections.find((c) => c.id === activeConnectionId) as (Connection & { error?: string }) | undefined; if (reconnected?.status === 'connected') { - let nextPath = currentPath; + const listingIsPlaceholder = isUnresolvedFilesPath(currentPath) && files.length === 0; + let nextPath = listingIsPlaceholder ? '' : currentPath; if (!nextPath) { try { const cwd = await window.ipcRenderer.invoke('fs_cwd', { connectionId: activeConnectionId }); - if (typeof cwd === 'string' && cwd.trim()) nextPath = cwd.trim(); + if (typeof cwd === 'string' && cwd.trim() && !isUnresolvedFilesPath(cwd)) { + nextPath = cwd.trim(); + } } catch { // Keep empty; listing `/` as a silent fallback hides the real home. } @@ -738,7 +779,7 @@ export const FileManager = memo(function FileManager({ const message = error instanceof Error ? error.message : String(error); showToast('error', `Failed to reconnect: ${message}`); } - }, [activeConnectionId, connect, currentPath, isLocal, loadFiles, showToast]); + }, [activeConnectionId, connect, currentPath, files.length, isLocal, loadFiles, showToast]); useEffect(() => { if (activeConnectionId && isConnected) { @@ -831,7 +872,7 @@ export const FileManager = memo(function FileManager({ } }, [activeConnectionId, editingFile, currentPath, handleConnectionError, showToast]); - const handleSelect = (filename: string, multi: boolean) => { + const handleSelect = useCallback((filename: string, multi: boolean) => { if (!filename) { setSelectedFiles([]); return; @@ -843,28 +884,38 @@ export const FileManager = memo(function FileManager({ setSelectedFiles([filename]); setFocusedFile(filename); } - }; + }, []); - const handleContextMenu = (e: React.MouseEvent, file?: FileEntry) => { + const handleContextMenu = useCallback((e: React.MouseEvent, file?: FileEntry) => { e.preventDefault(); - e.stopPropagation(); // Just in case + e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, file: file || null }); - // Only select if it's a file context menu if (file) { - if (!selectedFiles.includes(file.name)) { - setSelectedFiles([file.name]); - } - } else { - // Background context menu - maybe clear selection? - // setSelectedFiles([]); // Optional: clear selection on background right-click + setSelectedFiles((prev) => (prev.includes(file.name) ? prev : [file.name])); } - }; + }, []); - const filteredFiles = files.filter((f) => { - if (!settings.fileManager.showHiddenFiles && f.name.startsWith('.')) return false; - return f.name.toLowerCase().includes(searchTerm.toLowerCase()); - }); + const showHiddenFiles = settings.fileManager.showHiddenFiles; + const filteredFiles = useMemo( + () => files.filter((f) => { + if (!showHiddenFiles && f.name.startsWith('.')) return false; + return f.name.toLowerCase().includes(searchTerm.toLowerCase()); + }), + [files, showHiddenFiles, searchTerm], + ); + const paintedFiles = useMemo( + () => sortFileEntries(filteredFiles, sortColumn, sortDirection), + [filteredFiles, sortColumn, sortDirection], + ); + const handleSort = useCallback((column: FileSortColumn) => { + if (column === sortColumn) { + setSortDirection((dir) => (dir === 'asc' ? 'desc' : 'asc')); + return; + } + setSortColumn(column); + setSortDirection('asc'); + }, [sortColumn]); // --- Action Handlers (Create, Rename, Upload, Delete, Download) --- @@ -1371,16 +1422,12 @@ export const FileManager = memo(function FileManager({ } } - const filteredFiles = files.filter((f) => - f.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); - const bindings = settings.keybindings || {}; // Select All if (isMatch(e, bindings.fmSelectAll || 'Mod+A')) { e.preventDefault(); - setSelectedFiles(filteredFiles.map((f) => f.name)); + setSelectedFiles(paintedFiles.map((f) => f.name)); return; } @@ -1509,9 +1556,9 @@ export const FileManager = memo(function FileManager({ return; } - if (filteredFiles.length === 0) return; + if (paintedFiles.length === 0) return; - const currentIndex = focusedFile ? filteredFiles.findIndex((f) => f.name === focusedFile) : -1; + const currentIndex = focusedFile ? paintedFiles.findIndex((f) => f.name === focusedFile) : -1; // Arrow Keys: Navigate // Note: These are standard navigation keys, not strictly "commands" @@ -1521,42 +1568,20 @@ export const FileManager = memo(function FileManager({ let newIndex = currentIndex; if (viewMode === 'grid') { - // Dynamic Grid Column Calculation - // We measure the DOM to find how many items fit in one row - let gridCols = settings.compactMode ? 12 : 6; // Default fallback - - if (filteredFiles.length > 0) { - const firstItem = document.getElementById(`file-item-${filteredFiles[0].name}`); - if (firstItem && firstItem.parentElement) { - const baseTop = firstItem.offsetTop; - let count = 0; - // distinct scan to find row break - for (let i = 0; i < filteredFiles.length; i++) { - const el = document.getElementById(`file-item-${filteredFiles[i].name}`); - if (el && Math.abs(el.offsetTop - baseTop) < 10) { - count++; - } else { - break; // Found the break - } - // Safety break for huge lists if layout is weird (e.g. all horizontal) - if (count > 50) break; - } - if (count > 0) gridCols = count; - } - } + const gridCols = Math.max(1, gridColumnCount); - if (e.key === 'ArrowDown') newIndex = Math.min(currentIndex + gridCols, filteredFiles.length - 1); + if (e.key === 'ArrowDown') newIndex = Math.min(currentIndex + gridCols, paintedFiles.length - 1); else if (e.key === 'ArrowUp') newIndex = Math.max(currentIndex - gridCols, 0); - else if (e.key === 'ArrowRight') newIndex = Math.min(currentIndex + 1, filteredFiles.length - 1); + else if (e.key === 'ArrowRight') newIndex = Math.min(currentIndex + 1, paintedFiles.length - 1); else if (e.key === 'ArrowLeft') newIndex = Math.max(currentIndex - 1, 0); } else { // List view: only up/down - if (e.key === 'ArrowDown') newIndex = Math.min(currentIndex + 1, filteredFiles.length - 1); + if (e.key === 'ArrowDown') newIndex = Math.min(currentIndex + 1, paintedFiles.length - 1); else if (e.key === 'ArrowUp') newIndex = Math.max(currentIndex - 1, 0); } if (newIndex === -1) newIndex = 0; - const newFocused = filteredFiles[newIndex]?.name; + const newFocused = paintedFiles[newIndex]?.name; setFocusedFile(newFocused); // Update selection if Shift is held @@ -1580,14 +1605,14 @@ export const FileManager = memo(function FileManager({ // Home if (e.key === 'Home') { e.preventDefault(); - if (filteredFiles.length > 0) setFocusedFile(filteredFiles[0].name); + if (paintedFiles.length > 0) setFocusedFile(paintedFiles[0].name); return; } // End if (e.key === 'End') { e.preventDefault(); - if (filteredFiles.length > 0) setFocusedFile(filteredFiles[filteredFiles.length - 1].name); + if (paintedFiles.length > 0) setFocusedFile(paintedFiles[paintedFiles.length - 1].name); return; } }; @@ -1595,8 +1620,8 @@ export const FileManager = memo(function FileManager({ window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [ - activeConnectionId, searchTerm, isSearchOpen, files, settings, isNewFolderModalOpen, isNewFileModalOpen, isRenameModalOpen, - editingFile, selectedFiles, focusedFile, handleNavigate, handleCopy, handlePaste, + activeConnectionId, searchTerm, isSearchOpen, files, paintedFiles, settings, isNewFolderModalOpen, isNewFileModalOpen, isRenameModalOpen, + editingFile, selectedFiles, focusedFile, handleNavigate, handleCopy, handlePaste, gridColumnCount, handleDelete, navigateBack, navigateForward, isCopyModalOpen, isPropertiesOpen, viewMode, isConnected, isFilesSurfaceActive, ]); @@ -1629,7 +1654,7 @@ export const FileManager = memo(function FileManager({
{/* biome-ignore lint/a11y/noStaticElementInteractions: interactive div */} -
setContextMenu(null)}> +
setContextMenu(null)}> {(isReconnectPending || currentError === 'DISCONNECTED' || (!isConnected && !isLocal)) ? ( ) : ( )}
diff --git a/src/components/file-manager/FileGrid.tsx b/src/components/file-manager/FileGrid.tsx index c635ab3d..ba586473 100644 --- a/src/components/file-manager/FileGrid.tsx +++ b/src/components/file-manager/FileGrid.tsx @@ -6,15 +6,20 @@ import type React from 'react'; import { cn, formatBytes, formatDate } from '../../lib/utils'; import type { FileEntry } from './types'; import { useAppStore } from '../../store/useAppStore'; -import { useState, useMemo, useEffect, memo } from 'react'; +import { useMemo, useState, useEffect, useLayoutEffect, useCallback, useRef, memo, type CSSProperties } from 'react'; import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; -import { convertFileSrc } from '@tauri-apps/api/core'; - import { setCurrentDragSource } from '../../lib/dragDrop'; import { motion, AnimatePresence } from 'framer-motion'; import { forwardRef } from 'react'; import { buildDragData, startInternalDrag, validateAndBuildMoves } from './dragDropUtils'; -import { Tooltip } from '../ui/Tooltip'; +import { getScrollbarSize, Grid, List, useGridRef, useListRef } from 'react-window'; +import { + computeFileGridMetrics, + FILE_LIST_COLUMNS, + FILE_LIST_ROW_HEIGHT, + type FileSortColumn, + type FileSortDirection, +} from './fileGridLayout'; // Extended Icon Selector with Colors const FileIcon = memo(function FileIcon({ file, size }: { file: FileEntry; size: number }) { @@ -25,7 +30,7 @@ const FileIcon = memo(function FileIcon({ file, size }: { file: FileEntry; size:
@@ -34,10 +39,9 @@ const FileIcon = memo(function FileIcon({ file, size }: { file: FileEntry; size: // Use the new DynamicIcon engine for files return ( - ); }); @@ -48,10 +52,10 @@ const FileGridItem = memo(forwardRef string[]; onSelect: (name: string, multi: boolean) => void; onNavigate: (name: string) => void; onContextMenu: (e: React.MouseEvent, file?: FileEntry) => void; @@ -61,38 +65,25 @@ const FileGridItem = memo(forwardRef { const isFolder = file.type === 'd'; - const [imageError, setImageError] = useState(false); - - const isImage = useMemo(() => { - return false; // DISABLED: Performance issues reported by user - }, [file.name, isFolder]); - - // Reset error state if file changes - useEffect(() => { - setImageError(false); - }, [file.path]); return ( - { if (!connectionId || !currentPath) return; + const selectedFiles = getSelectedFiles(); const dragData = buildDragData(file, isSelected, selectedFiles, connectionId, currentPath); const count = isSelected && selectedFiles.length > 0 ? selectedFiles.length : 1; startInternalDrag(e, dragData, isFolder, count); @@ -152,13 +143,12 @@ const FileGridItem = memo(forwardRef
- {isImage && !imageError && viewMode === 'grid' ? ( - {file.name} setImageError(true)} - loading="lazy" - /> - ) : ( - - )} +
-
- -
- {file.name} -
-
+
+
+ {file.name} +
{viewMode === 'grid' && !compactMode && ( -
+
{formatBytes(file.size)}
)}
- +
); })); -// Memoized File List Item Component -const FileListItem = memo(forwardRef string[]; onSelect: (name: string, multi: boolean) => void; onNavigate: (name: string) => void; onContextMenu: (e: React.MouseEvent, file?: FileEntry) => void; @@ -232,10 +208,10 @@ const FileListItem = memo(forwardRef(({ file, isSelected, - selectedFiles, isFocused, connectionId, currentPath, + getSelectedFiles, onSelect, onNavigate, onContextMenu, @@ -244,16 +220,14 @@ const FileListItem = memo(forwardRef { if (!connectionId || !currentPath) return; + const selectedFiles = getSelectedFiles(); const dragData = buildDragData(file, isSelected, selectedFiles, connectionId, currentPath); const count = isSelected && selectedFiles.length > 0 ? selectedFiles.length : 1; startInternalDrag(e, dragData, isFolder, count); @@ -311,33 +285,36 @@ const FileListItem = memo(forwardRef - -
+
+
- - - {file.name} - - + + {file.name} +
- - +
+
{isFolder ? '—' : formatBytes(file.size)} - - +
+
{isFolder ? 'Folder' : (file.name.split('.').pop()?.toUpperCase() || '—')} - - +
+
{formatDate(file.lastModified)} - - +
+
); })); @@ -356,12 +333,118 @@ interface FileGridProps { currentPath?: string; focusedFile?: string | null; onMove?: (moves: { source: string; target: string; sourceConnectionId?: string }[]) => void; + sortColumn: FileSortColumn; + sortDirection: FileSortDirection; + onSort: (column: FileSortColumn) => void; + onGridColumnCount?: (columnCount: number) => void; } -type SortColumn = 'name' | 'size' | 'type' | 'modified'; -type SortDirection = 'asc' | 'desc'; +type FileListRowExtra = { + files: FileEntry[]; + selectedSet: Set; + focusedFile?: string | null; + connectionId?: string; + currentPath?: string; + getSelectedFiles: () => string[]; + onSelect: (name: string, multi: boolean) => void; + onNavigate: (name: string) => void; + onContextMenu: (e: React.MouseEvent, file?: FileEntry) => void; + onMove?: (moves: { source: string; target: string; sourceConnectionId?: string }[]) => void; +}; -export function FileGrid({ +function FileListRow({ + index, + style, + ariaAttributes, + files, + selectedSet, + focusedFile, + connectionId, + currentPath, + getSelectedFiles, + onSelect, + onNavigate, + onContextMenu, + onMove, +}: { + index: number; + style: CSSProperties; + ariaAttributes: { 'aria-posinset': number; 'aria-setsize': number; role: 'listitem' }; +} & FileListRowExtra) { + const file = files[index]; + if (!file) return null; + return ( +
+ +
+ ); +} + +type FileGridCellExtra = FileListRowExtra & { + columnCount: number; + compactMode: boolean; +}; + +function FileGridCell({ + columnIndex, + rowIndex, + style, + ariaAttributes, + files, + columnCount, + compactMode, + selectedSet, + focusedFile, + connectionId, + currentPath, + getSelectedFiles, + onSelect, + onNavigate, + onContextMenu, + onMove, +}: { + columnIndex: number; + rowIndex: number; + style: CSSProperties; + ariaAttributes: { 'aria-colindex': number; role: 'gridcell' }; +} & FileGridCellExtra) { + const index = rowIndex * columnCount + columnIndex; + const file = files[index]; + if (!file) { + return
; + } + return ( +
+ +
+ ); +} + +export const FileGrid = memo(function FileGrid({ files, selectedFiles, onSelect, @@ -373,55 +456,112 @@ export function FileGrid({ currentPath, focusedFile, onMove, + sortColumn, + sortDirection, + onSort, + onGridColumnCount, }: FileGridProps) { - const settings = useAppStore(state => state.settings); - const compactMode = settings.compactMode; - const [sortColumn, setSortColumn] = useState('name'); - const [sortDirection, setSortDirection] = useState('asc'); + const compactMode = useAppStore(state => state.settings.compactMode); + const listRef = useListRef(null); + const gridRef = useGridRef(null); + const gridColumnCountRef = useRef(1); + const lastReportedColumnCountRef = useRef(null); + const [gridViewportWidth, setGridViewportWidth] = useState(0); + const selectedFilesRef = useRef(selectedFiles); + const getSelectedFiles = useCallback(() => selectedFilesRef.current, []); + useLayoutEffect(() => { + selectedFilesRef.current = selectedFiles; + }, [selectedFiles]); + const selectedSet = useMemo(() => new Set(selectedFiles), [selectedFiles]); + + const reportColumnCount = useCallback((count: number) => { + gridColumnCountRef.current = count; + if (lastReportedColumnCountRef.current === count) return; + lastReportedColumnCountRef.current = count; + onGridColumnCount?.(count); + }, [onGridColumnCount]); + + const scrollFocusedListRow = useCallback(() => { + if (viewMode !== 'list' || !focusedFile) return; + const index = files.findIndex((f) => f.name === focusedFile); + if (index < 0) return; + listRef.current?.scrollToRow({ index, align: 'smart', behavior: 'auto' }); + }, [viewMode, focusedFile, files, listRef]); + + const scrollFocusedGridCell = useCallback((columnCount: number) => { + if (viewMode !== 'grid' || !focusedFile || columnCount < 1) return; + const index = files.findIndex((f) => f.name === focusedFile); + if (index < 0) return; + gridRef.current?.scrollToCell({ + rowIndex: Math.floor(index / columnCount), + columnIndex: index % columnCount, + rowAlign: 'smart', + columnAlign: 'smart', + behavior: 'auto', + }); + }, [viewMode, focusedFile, files, gridRef]); + + useLayoutEffect(() => { + const listEl = listRef.current?.element; + if (listEl) listEl.scrollTop = 0; + const gridEl = gridRef.current?.element; + if (gridEl) { + gridEl.scrollTop = 0; + gridEl.scrollLeft = 0; + } + }, [currentPath, listRef, gridRef]); useEffect(() => { - if (focusedFile) { - const element = document.getElementById(`file-item-${focusedFile}`); - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } + if (!focusedFile) return; + if (viewMode === 'list') { + scrollFocusedListRow(); + return; } - }, [focusedFile]); - - const handleSort = (column: SortColumn) => { - if (sortColumn === column) { - setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); - } else { - setSortColumn(column); - setSortDirection('asc'); + if (viewMode === 'grid') { + scrollFocusedGridCell(gridColumnCountRef.current); } - }; - - const sortedFiles = useMemo(() => { - return [...files].sort((a, b) => { - if (a.type === 'd' && b.type !== 'd') return -1; - if (a.type !== 'd' && b.type === 'd') return 1; - - let comparison = 0; - switch (sortColumn) { - case 'name': - comparison = a.name.localeCompare(b.name); - break; - case 'size': - comparison = a.size - b.size; - break; - case 'type': - const extA = a.name.split('.').pop()?.toLowerCase() || ''; - const extB = b.name.split('.').pop()?.toLowerCase() || ''; - comparison = extA.localeCompare(extB); - break; - case 'modified': - comparison = a.lastModified - b.lastModified; - break; - } - return sortDirection === 'asc' ? comparison : -comparison; - }); - }, [files, sortColumn, sortDirection]); + }, [focusedFile, viewMode, compactMode, scrollFocusedListRow, scrollFocusedGridCell]); + + const listRowProps = useMemo( + () => ({ + files, + selectedSet, + focusedFile, + connectionId, + currentPath, + getSelectedFiles, + onSelect, + onNavigate, + onContextMenu, + onMove, + }), + [ + files, + selectedSet, + focusedFile, + connectionId, + currentPath, + getSelectedFiles, + onSelect, + onNavigate, + onContextMenu, + onMove, + ], + ); + + const gridMetrics = useMemo( + () => computeFileGridMetrics(gridViewportWidth, compactMode), + [gridViewportWidth, compactMode], + ); + const gridColumnWidth = Math.max( + 1, + gridViewportWidth > 0 ? gridViewportWidth / gridMetrics.columnCount : gridMetrics.columnWidth, + ); + const gridRowCount = Math.max(1, Math.ceil(files.length / gridMetrics.columnCount)); + + useEffect(() => { + reportColumnCount(gridMetrics.columnCount); + }, [gridMetrics.columnCount, reportColumnCount]); @@ -429,7 +569,7 @@ export function FileGrid({ return ( // biome-ignore lint/a11y/noStaticElementInteractions:
onSelect('', false)} onContextMenu={(e) => { e.preventDefault(); @@ -483,8 +623,8 @@ export function FileGrid({
{files.length === 0 ? ( ) : viewMode === 'list' ? ( - - - - - - - - - - - - {sortedFiles.map((file) => ( - - ))} - - -
handleSort('name')}> -
- Name - {sortColumn === 'name' && (sortDirection === 'asc' ? : )} - {sortColumn !== 'name' && } -
-
handleSort('size')}> -
- Size - {sortColumn === 'size' && (sortDirection === 'asc' ? : )} - {sortColumn !== 'size' && } -
-
handleSort('type')}> -
- Type - {sortColumn === 'type' && (sortDirection === 'asc' ? : )} - {sortColumn !== 'type' && } -
-
handleSort('modified')}> -
- Modified - {sortColumn === 'modified' && (sortDirection === 'asc' ? : )} - {sortColumn !== 'modified' && } -
-
- ) : ( -
- - {sortedFiles.map((file) => ( - +
+
+ {(['name', 'size', 'type', 'modified'] as const).map((column) => ( + ))} - +
+
+ scrollFocusedListRow()} + /> +
+
+ ) : ( +
+ { + setGridViewportWidth(width); + const next = computeFileGridMetrics(width, compactMode); + gridColumnCountRef.current = next.columnCount; + reportColumnCount(next.columnCount); + const el = gridRef.current?.element; + if (el) el.scrollLeft = 0; + if (!focusedFile) { + if (el) el.scrollTop = 0; + return; + } + scrollFocusedGridCell(next.columnCount); + }} + />
)}
); -} +}); diff --git a/src/components/file-manager/FileToolbar.tsx b/src/components/file-manager/FileToolbar.tsx index 6e37b0c6..56c9cdac 100644 --- a/src/components/file-manager/FileToolbar.tsx +++ b/src/components/file-manager/FileToolbar.tsx @@ -127,7 +127,7 @@ export function FileToolbar({ return (
{/* Minimalist Address Bar */} diff --git a/src/components/file-manager/fileGridLayout.ts b/src/components/file-manager/fileGridLayout.ts new file mode 100644 index 00000000..05b5fff2 --- /dev/null +++ b/src/components/file-manager/fileGridLayout.ts @@ -0,0 +1,61 @@ +import type { FileEntry } from './types'; + +export type FileSortColumn = 'name' | 'size' | 'type' | 'modified'; +export type FileSortDirection = 'asc' | 'desc'; + +/** List-row height from FileListItem (`py-2` + 20px icon + border). Compact does not change list rows. */ +export const FILE_LIST_ROW_HEIGHT = 40; + +/** Shared header/row tracks: Name | Size (w-24) | Type (w-32) | Modified (w-40). */ +export const FILE_LIST_COLUMNS = 'minmax(0, 1fr) 6rem 8rem 10rem'; + +export interface FileGridMetrics { + columnCount: number; + columnWidth: number; + rowHeight: number; + gap: number; +} + +function fileExtension(name: string): string { + const dot = name.lastIndexOf('.'); + if (dot <= 0 || dot === name.length - 1) return ''; + return name.slice(dot + 1).toLowerCase(); +} + +export function sortFileEntries( + files: FileEntry[], + column: FileSortColumn, + direction: FileSortDirection, +): FileEntry[] { + return [...files].sort((a, b) => { + if (a.type === 'd' && b.type !== 'd') return -1; + if (a.type !== 'd' && b.type === 'd') return 1; + + let comparison = 0; + switch (column) { + case 'name': + comparison = a.name.localeCompare(b.name); + break; + case 'size': + comparison = a.size - b.size; + break; + case 'type': + comparison = fileExtension(a.name).localeCompare(fileExtension(b.name)); + break; + case 'modified': + comparison = a.lastModified - b.lastModified; + break; + } + return direction === 'asc' ? comparison : -comparison; + }); +} + +export function computeFileGridMetrics(containerWidth: number, compactMode: boolean): FileGridMetrics { + const minTrack = compactMode ? 100 : 120; + const gap = compactMode ? 8 : 16; + const rowHeight = compactMode ? 120 : 140; + const width = Math.max(0, containerWidth); + const columnCount = Math.max(1, Math.floor((width + gap) / (minTrack + gap))); + const columnWidth = width / columnCount; + return { columnCount, columnWidth, rowHeight, gap }; +} diff --git a/src/components/layout/tabDock/index.ts b/src/components/layout/tabDock/index.ts index 75ec0c7d..ebcf14f9 100644 --- a/src/components/layout/tabDock/index.ts +++ b/src/components/layout/tabDock/index.ts @@ -9,6 +9,7 @@ export { canSplitBesideFiles, filesAlreadyInSplit, directoryFromFileLocation, + isUnresolvedFilesPath, parentDirectory, pickFilesOpenPath, type OpenHereFile, diff --git a/src/components/layout/tabDock/openHere.ts b/src/components/layout/tabDock/openHere.ts index 861769d0..1f8f4dd1 100644 --- a/src/components/layout/tabDock/openHere.ts +++ b/src/components/layout/tabDock/openHere.ts @@ -11,6 +11,7 @@ import { directoryFromFileLocation, pickFilesOpenPath, type OpenHereFile } from export { directoryFromFileLocation, + isUnresolvedFilesPath, parentDirectory, pickFilesOpenPath, type OpenHereFile, diff --git a/src/components/layout/tabDock/openHerePaths.ts b/src/components/layout/tabDock/openHerePaths.ts index 17b9062b..fe962318 100644 --- a/src/components/layout/tabDock/openHerePaths.ts +++ b/src/components/layout/tabDock/openHerePaths.ts @@ -8,6 +8,26 @@ function trimmed(value: string | null | undefined): string { return (value || '').trim(); } +/** + * Empty or `/` — connect/SFTP placeholder, not a resolved Files home. + * A user who actually listed `/` (hash button) has entries loaded; callers + * should only treat `/` as unresolved when the listing is still empty. + */ +export function isUnresolvedFilesPath(path: string | null | undefined): boolean { + const value = trimmed(path); + return value.length === 0 || value === '/' || value === '~'; +} + +/** SFTP cannot list `~`; expand with a real home (`/home/user`). */ +export function expandTildeWithHome(path: string, home: string): string { + const value = trimmed(path); + const homePath = trimmed(home).replace(/\/+$/, ''); + if (!homePath || homePath === '/' || homePath === '~') return value; + if (value === '~') return homePath; + if (value.startsWith('~/')) return `${homePath}/${value.slice(2)}`; + return value; +} + /** * Prefer a live shell cwd. Skip connection.homePath `/` — connect() stores that * before SFTP cwd returns, so it is not a real home. @@ -18,7 +38,7 @@ export function pickFilesOpenPath(input: { homePath?: string | null; }): string { const cwd = trimmed(input.lastKnownCwd); - if (cwd) return cwd; + if (cwd && cwd !== '~') return cwd; const initial = trimmed(input.initialPath); if (initial) return initial; const home = trimmed(input.homePath); diff --git a/src/components/tabs/ReleaseNotesTab.tsx b/src/components/tabs/ReleaseNotesTab.tsx index 232ebf5f..bbd4e7bf 100644 --- a/src/components/tabs/ReleaseNotesTab.tsx +++ b/src/components/tabs/ReleaseNotesTab.tsx @@ -1,531 +1,329 @@ -import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Gift, ExternalLink, ChevronDown, Check, Copy, Tag } from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Gift, ExternalLink, ChevronDown, Tag } from 'lucide-react'; import { useAppStore } from '../../store/useAppStore'; +import { + buildHeadingIdLookup, + extractToc, + headingKey, + headingLookupKey, + slugify, +} from '../../lib/releaseNotes/headings'; +import { getNodeText } from '../../lib/releaseNotes/reactText'; +import { ReleaseNotesMarkdown } from './releaseNotes/ReleaseNotesMarkdown'; interface GithubRelease { - tag_name: string; - name: string; - published_at: string; - body: string; - html_url: string; -} - -interface TocEntry { - id: string; - text: string; - level: 1 | 2 | 3; + tag_name: string; + name: string; + published_at: string; + body: string; + html_url: string; } const SECTION_BADGES: Record = { - added: { label: '+ Added', color: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30' }, - fixed: { label: '! Fixed', color: 'bg-red-500/15 text-red-400 border-red-500/30' }, - changed: { label: '* Changed', color: 'bg-blue-500/15 text-blue-400 border-blue-500/30' }, - security: { label: 'Security', color: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30' }, - enhancements: { label: 'Enhancements', color: 'bg-purple-500/15 text-purple-400 border-purple-500/30' }, - deprecated: { label: 'Deprecated', color: 'bg-orange-500/15 text-orange-400 border-orange-500/30' }, - removed: { label: 'Removed', color: 'bg-red-700/15 text-red-500 border-red-700/30' } + added: { label: '+ Added', color: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30' }, + fixed: { label: '! Fixed', color: 'bg-red-500/15 text-red-400 border-red-500/30' }, + changed: { label: '* Changed', color: 'bg-blue-500/15 text-blue-400 border-blue-500/30' }, + security: { label: 'Security', color: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30' }, + enhancements: { label: 'Enhancements', color: 'bg-purple-500/15 text-purple-400 border-purple-500/30' }, + deprecated: { label: 'Deprecated', color: 'bg-orange-500/15 text-orange-400 border-orange-500/30' }, + removed: { label: 'Removed', color: 'bg-red-700/15 text-red-500 border-red-700/30' }, }; -function normalizeHeadingText(text: string): string { - return text - .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') - .replace(/`([^`]*)`/g, '$1') - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/\*([^*]+)\*/g, '$1') - .replace(/<[^>]+>/g, '') - .replace(/\s+/g, ' ') - .trim(); -} - -function headingKey(text: string): string { - return normalizeHeadingText(text).toLowerCase().replace(/[^a-z]/g, ''); -} - -function slugify(text: string, usedSlugs: Map): string { - const normalized = normalizeHeadingText(text) - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); - const baseSlug = normalized || 'section'; - - if (usedSlugs.has(baseSlug)) { - const count = usedSlugs.get(baseSlug)! + 1; - usedSlugs.set(baseSlug, count); - return `${baseSlug}-${count}`; - } - - usedSlugs.set(baseSlug, 0); - return baseSlug; +function formatDate(iso: string): string { + if (!iso) return ''; + return new Date(iso).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); } -function extractToc(markdown: string): TocEntry[] { - const lines = markdown.split('\n'); - const usedSlugs = new Map(); - const entries: TocEntry[] = []; - let inFence = false; +const ReleaseNotesTab: React.FC = () => { + const [releases, setReleases] = useState([]); + const [selected, setSelected] = useState(null); + const [isLoadingList, setIsLoadingList] = useState(true); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [appVersion, setAppVersion] = useState(''); + const [activeSection, setActiveSection] = useState(''); + + const contentRef = useRef(null); + const dropdownRef = useRef(null); + + const closeTab = useAppStore(state => state.closeTab); + const tabs = useAppStore(state => state.tabs); + const themeSetting = useAppStore(state => state.settings.theme); + const thisTab = tabs.find(t => t.type === 'release-notes'); + + const resolvedTheme = useMemo(() => { + if (themeSetting !== 'system') return themeSetting; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + }, [themeSetting]); + + const isLightTheme = resolvedTheme === 'light' || resolvedTheme === 'light-warm'; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + }; - for (const line of lines) { - if (/^\s*```/.test(line)) { - inFence = !inFence; - continue; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + useEffect(() => { + const controller = new AbortController(); + const signal = controller.signal; + let mounted = true; + + const run = async () => { + setIsLoadingList(true); + try { + const [ver, res] = await Promise.all([ + window.ipcRenderer.invoke('app:getVersion'), + fetch('https://api.github.com/repos/zync-sh/zync/releases?per_page=10', { signal }), + ]); + + if (!mounted) return; + + setAppVersion(ver); + if (!res.ok) throw new Error('GitHub API error'); + + const data: GithubRelease[] = await res.json(); + if (!mounted) return; + + setReleases(data); + const match = data.find(r => r.tag_name === `v${ver}`) || data[0] || null; + setSelected(match); + } catch (err: any) { + if (err.name === 'AbortError') return; + console.error('Failed to fetch releases:', err); + if (mounted) { + setSelected({ + tag_name: '', + name: 'Offline', + published_at: '', + body: 'Could not load release notes. Please check your internet connection or visit the [Releases page](https://github.com/zync-sh/zync/releases) directly.', + html_url: 'https://github.com/zync-sh/zync/releases', + }); } - if (inFence) continue; + } finally { + if (mounted) setIsLoadingList(false); + } + }; - const m = line.match(/^(#{1,3})\s+(.+)/); - if (!m) continue; + run(); - const level = m[1].length as 1 | 2 | 3; - const text = normalizeHeadingText(m[2]); - if (!text) continue; + return () => { + mounted = false; + controller.abort(); + }; + }, []); - entries.push({ - level, - text, - id: slugify(text, usedSlugs) - }); - } + const currentRelease = selected; + const markdownBody = currentRelease?.body || ''; - return entries; -} + const toc = useMemo(() => extractToc(markdownBody), [markdownBody]); -function formatDate(iso: string): string { - if (!iso) return ''; - return new Date(iso).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' - }); -} + useEffect(() => { + setActiveSection(''); + }, [markdownBody]); -function getNodeText(node: ReactNode): string { - if (typeof node === 'string' || typeof node === 'number') return String(node); - if (Array.isArray(node)) return node.map(getNodeText).join(''); - if (React.isValidElement<{ children?: ReactNode }>(node)) return getNodeText(node.props.children); - return ''; -} + useEffect(() => { + if (!contentRef.current) return; -function CodeBlock({ language, children, isLightTheme }: { language?: string; children: string; isLightTheme: boolean }) { - const [copied, setCopied] = useState(false); - const timeoutRef = useRef(null); - - useEffect(() => { - return () => { - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - }; - }, []); - - const copy = async () => { - try { - await navigator.clipboard.writeText(children); - setCopied(true); - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - timeoutRef.current = window.setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy code to clipboard:', err); + const headings = contentRef.current.querySelectorAll('h1, h2, h3'); + const observer = new IntersectionObserver( + entries => { + for (const e of entries) { + if (e.isIntersecting) { + setActiveSection(e.target.id); + break; + } } - }; - - return ( -
-
- - {language || 'code'} - - -
- - 4} - lineNumberStyle={{ color: 'var(--color-app-muted)', minWidth: '2.5em', opacity: 0.6 }} - wrapLongLines - > - {children} - -
+ }, + { rootMargin: '-20px 0px -80% 0px' }, ); -} - -const ReleaseNotesTab: React.FC = () => { - const [releases, setReleases] = useState([]); - const [selected, setSelected] = useState(null); - const [isLoadingList, setIsLoadingList] = useState(true); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [appVersion, setAppVersion] = useState(''); - const [activeSection, setActiveSection] = useState(''); - - const contentRef = useRef(null); - const dropdownRef = useRef(null); - - const closeTab = useAppStore(state => state.closeTab); - const tabs = useAppStore(state => state.tabs); - const themeSetting = useAppStore(state => state.settings.theme); - const thisTab = tabs.find(t => t.type === 'release-notes'); - - const resolvedTheme = useMemo(() => { - if (themeSetting !== 'system') return themeSetting; - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - }, [themeSetting]); - - const isLightTheme = resolvedTheme === 'light' || resolvedTheme === 'light-warm'; - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsDropdownOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - useEffect(() => { - const controller = new AbortController(); - const signal = controller.signal; - let mounted = true; - - const run = async () => { - setIsLoadingList(true); - try { - const [ver, res] = await Promise.all([ - window.ipcRenderer.invoke('app:getVersion'), - fetch('https://api.github.com/repos/zync-sh/zync/releases?per_page=10', { signal }) - ]); - - if (!mounted) return; - - setAppVersion(ver); - if (!res.ok) throw new Error('GitHub API error'); - - const data: GithubRelease[] = await res.json(); - if (!mounted) return; - - setReleases(data); - const match = data.find(r => r.tag_name === `v${ver}`) || data[0] || null; - setSelected(match); - } catch (err: any) { - if (err.name === 'AbortError') return; - console.error('Failed to fetch releases:', err); - if (mounted) { - setSelected({ - tag_name: '', - name: 'Offline', - published_at: '', - body: 'Could not load release notes. Please check your internet connection or visit the [Releases page](https://github.com/zync-sh/zync/releases) directly.', - html_url: 'https://github.com/zync-sh/zync/releases' - }); - } - } finally { - if (mounted) setIsLoadingList(false); - } - }; - - run(); - - return () => { - mounted = false; - controller.abort(); - }; - }, []); - - const currentRelease = selected; - const markdownBody = currentRelease?.body || ''; - - const toc = useMemo(() => extractToc(markdownBody), [markdownBody]); - - useEffect(() => { - setActiveSection(''); - }, [markdownBody]); - - useEffect(() => { - if (!contentRef.current) return; - - const headings = contentRef.current.querySelectorAll('h1, h2, h3'); - const observer = new IntersectionObserver( - entries => { - for (const e of entries) { - if (e.isIntersecting) { - setActiveSection(e.target.id); - break; - } - } - }, - { rootMargin: '-20px 0px -80% 0px' } - ); - - headings.forEach(h => observer.observe(h)); - return () => observer.disconnect(); - }, [markdownBody, toc]); - - const scrollToSection = useCallback((id: string) => { - const target = - contentRef.current?.querySelector(`[id="${id}"]`) ?? - document.getElementById(id); - - target?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }, []); - - let headingRenderIndex = 0; - const fallbackSlugMap = new Map(); - - const resolveHeadingId = (level: 1 | 2 | 3, text: string): string => { - while (headingRenderIndex < toc.length) { - const entry = toc[headingRenderIndex]; - headingRenderIndex += 1; - if (entry.level === level) { - return entry.id; - } - } - - return slugify(text, fallbackSlugMap); - }; - const renderHeading = (level: 1 | 2 | 3, children: ReactNode) => { - const text = getNodeText(children); - const id = resolveHeadingId(level, text); - const badge = SECTION_BADGES[headingKey(text)]; - - const inner = ( - - {badge ? ( - - {badge.label} - - ) : ( - {children} - )} - - # - - - ); - - if (level === 1) return

{inner}

; - if (level === 2) return

{inner}

; - return

{inner}

; - }; - - return ( -
-
-
-
-
- -
-
-

What's New in Zync

- {currentRelease?.published_at && ( -

{formatDate(currentRelease.published_at)}

- )} -
-
- -
-
- - - {isDropdownOpen && ( -
- {releases.map(r => ( - - ))} -
- )} -
- - {currentRelease?.html_url && ( - - - GitHub - - )} + headings.forEach(h => observer.observe(h)); + return () => observer.disconnect(); + }, [markdownBody, toc]); + + const scrollToSection = useCallback((id: string) => { + const target = + contentRef.current?.querySelector(`[id="${id}"]`) ?? + document.getElementById(id); + + target?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, []); + + const headingIdByKey = useMemo(() => buildHeadingIdLookup(toc), [toc]); + const fallbackSlugMapRef = useRef(new Map()); + useEffect(() => { + fallbackSlugMapRef.current = new Map(); + }, [toc]); + + const renderHeading = useCallback((level: 1 | 2 | 3, children: ReactNode) => { + const text = getNodeText(children); + const id = + headingIdByKey.get(headingLookupKey(level, text)) ?? + slugify(text, fallbackSlugMapRef.current); + const badge = SECTION_BADGES[headingKey(text)]; + + const inner = ( + + {badge ? ( + + {badge.label} + + ) : ( + {children} + )} + + # + + + ); - {thisTab && ( - + if (level === 1) return

{inner}

; + if (level === 2) return

{inner}

; + return

{inner}

; + }, [headingIdByKey]); + + return ( +
+
+
+
+
+ +
+
+

What's New in Zync

+ {currentRelease?.published_at && ( +

{formatDate(currentRelease.published_at)}

+ )} +
+
+ +
+
+ + + {isDropdownOpen && ( +
+ {releases.map(r => ( +
+ + ))}
+ )}
-
- {toc.length > 1 && ( - - )} - -
- {isLoadingList ? ( -
-
- Fetching release notes... -
- ) : ( -
-
-
- Release - {currentRelease?.tag_name} -
-

{currentRelease?.name || currentRelease?.tag_name}

- {currentRelease?.published_at && ( -

{formatDate(currentRelease.published_at)}

- )} -
- -
- renderHeading(1, children), - h2: ({ children }) => renderHeading(2, children), - h3: ({ children }) => renderHeading(3, children), - code({ className, children }) { - const language = /language-([\w-]+)/.exec(className || '')?.[1]; - const codeContent = String(children).replace(/\n$/, ''); - const isBlock = Boolean(language) || codeContent.includes('\n'); - - return isBlock ? ( - - {codeContent} - - ) : ( - - {children} - - ); - }, - p: ({ children }) =>

{children}

, - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - li: ({ children }) =>
  • {children}
  • , - a: ({ href, children }) => { - if (!href) return {children}; - const internal = href.startsWith('#'); - return ( - - {children} - - ); - }, - blockquote: ({ children }) => ( -
    - {children} -
    - ), - hr: () =>
    , - table: ({ children }) => ( -
    - {children}
    -
    - ), - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - - {children} - - ) - }} - > - {markdownBody} -
    -
    -
    - )} + {currentRelease?.html_url && ( + + + GitHub + + )} + + {thisTab && ( + + )} +
    +
    +
    + +
    + {toc.length > 1 && ( + + )} + +
    + {isLoadingList ? ( +
    +
    + Fetching release notes... +
    + ) : ( +
    +
    +
    + Release + {currentRelease?.tag_name}
    +

    {currentRelease?.name || currentRelease?.tag_name}

    + {currentRelease?.published_at && ( +

    {formatDate(currentRelease.published_at)}

    + )} +
    + +
    + +
    + )}
    - ); +
    +
    + ); }; -export default ReleaseNotesTab; \ No newline at end of file +export default ReleaseNotesTab; diff --git a/src/components/tabs/releaseNotes/ReleaseNotesMarkdown.tsx b/src/components/tabs/releaseNotes/ReleaseNotesMarkdown.tsx new file mode 100644 index 00000000..0c886083 --- /dev/null +++ b/src/components/tabs/releaseNotes/ReleaseNotesMarkdown.tsx @@ -0,0 +1,370 @@ +import { + Children, + cloneElement, + isValidElement, + memo, + useEffect, + useMemo, + useRef, + useState, + type ComponentProps, + type ReactNode, +} from 'react'; +import ReactMarkdown, { type Components } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import rehypeSanitize from 'rehype-sanitize'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { AlertOctagon, AlertTriangle, Check, Copy, Info, Lightbulb, Megaphone } from 'lucide-react'; +import { KeyboardKey } from '../../ui/KeyboardKey'; +import { matchAlertPrefix, stripAlertPrefixFromParts, type AlertKind } from '../../../lib/releaseNotes/alerts'; +import { rewriteMarkdownLocalMedia } from '../../../lib/releaseNotes/mediaUrls'; +import { getNodeText } from '../../../lib/releaseNotes/reactText'; +import { RELEASE_NOTES_SANITIZE_SCHEMA } from '../../../lib/releaseNotes/sanitizeSchema'; +import { rehypeRewriteLocalMedia, releaseNotesUrlTransform } from '../../../lib/releaseNotes/urlTransform'; +import { ReleaseNotesImage, ReleaseNotesVideo } from './ReleaseNotesMedia'; + +const REMARK_PLUGINS = [remarkGfm]; +const REHYPE_PLUGINS: NonNullable['rehypePlugins']> = [ + rehypeRaw, + rehypeRewriteLocalMedia, + [rehypeSanitize, RELEASE_NOTES_SANITIZE_SCHEMA], +]; + +const ALERT_STYLES: Record = { + note: { + label: 'Note', + icon: Info, + className: 'border-blue-500/30 bg-blue-500/10 text-blue-400', + }, + tip: { + label: 'Tip', + icon: Lightbulb, + className: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', + }, + important: { + label: 'Important', + icon: Megaphone, + className: 'border-purple-500/30 bg-purple-500/10 text-purple-400', + }, + warning: { + label: 'Warning', + icon: AlertTriangle, + className: 'border-amber-500/30 bg-amber-500/10 text-amber-400', + }, + caution: { + label: 'Caution', + icon: AlertOctagon, + className: 'border-red-500/30 bg-red-500/10 text-red-400', + }, +}; + +function AlertBox({ kind, children }: { kind: AlertKind; children: ReactNode }) { + const meta = ALERT_STYLES[kind]; + const Icon = meta.icon; + return ( + + ); +} + +function CodeBlock({ + language, + children, + isLightTheme, +}: { + language?: string; + children: string; + isLightTheme: boolean; +}) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current); + }; + }, []); + + const copy = async () => { + try { + await navigator.clipboard.writeText(children); + setCopied(true); + if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy code to clipboard:', err); + } + }; + + return ( +
    +
    + + {language || 'code'} + + +
    + 4} + lineNumberStyle={{ color: 'var(--color-app-muted)', minWidth: '2.5em', opacity: 0.6 }} + wrapLongLines + > + {children} + +
    + ); +} + +function ReleaseNotesMarkdownImage({ + src, + alt, + title, +}: { + src?: string | Blob; + alt?: string; + title?: string; +}) { + return ; +} + +function ReleaseNotesMarkdownVideo({ + src, + title, + loop, + poster, + children, +}: { + src?: string | Blob; + title?: string; + loop?: unknown; + poster?: string; + children?: ReactNode; +}) { + return ( + + {children} + + ); +} + +function isBareMediaParagraph(children: ReactNode): boolean { + const items = Children.toArray(children).filter((child) => { + if (typeof child === 'string') return child.trim() !== ''; + return true; + }); + if (items.length !== 1 || !isValidElement(items[0])) return false; + return items[0].type === ReleaseNotesMarkdownImage || items[0].type === ReleaseNotesMarkdownVideo; +} + +export const ReleaseNotesMarkdown = memo(function ReleaseNotesMarkdown({ + markdown, + isLightTheme, + renderHeading, +}: { + markdown: string; + isLightTheme: boolean; + renderHeading: (level: 1 | 2 | 3, children: ReactNode) => ReactNode; +}) { + const prepared = useMemo(() => rewriteMarkdownLocalMedia(markdown), [markdown]); + + const components = useMemo((): Components => ({ + h1: ({ children }) => renderHeading(1, children), + h2: ({ children }) => renderHeading(2, children), + h3: ({ children }) => renderHeading(3, children), + h4: ({ children }) => ( +

    {children}

    + ), + h5: ({ children }) => ( +
    {children}
    + ), + h6: ({ children }) => ( +
    + {children} +
    + ), + img: ReleaseNotesMarkdownImage, + video: ReleaseNotesMarkdownVideo, + code({ className, children }) { + const language = /language-([\w-]+)/.exec(className || '')?.[1]; + const codeContent = String(children).replace(/\n$/, ''); + const isBlock = Boolean(language) || codeContent.includes('\n'); + + return isBlock ? ( + + {codeContent} + + ) : ( + + {children} + + ); + }, + pre: ({ children }) => <>{children}, + p: ({ children }) => + isBareMediaParagraph(children) ? ( + <>{children} + ) : ( +

    {children}

    + ), + ul: ({ children, className }) => ( +
      + {children} +
    + ), + ol: ({ children }) => ( +
      {children}
    + ), + li: ({ children, className }) => ( +
  • + {children} +
  • + ), + input: (props) => + props.type === 'checkbox' ? ( + + ) : null, + a: ({ href, children }) => { + if (!href) return {children}; + const internal = href.startsWith('#'); + return ( + + {children} + + ); + }, + blockquote: ({ children }) => { + const items = Children.toArray(children); + const first = items[0]; + const alert = matchAlertPrefix(getNodeText(first)); + if (alert) { + const rest = [...items]; + if (isValidElement<{ children?: ReactNode }>(first)) { + const kept = stripAlertPrefixFromParts(Children.toArray(first.props.children)); + if (kept.length === 0) { + rest.shift(); + } else { + rest[0] = cloneElement(first, undefined, ...(kept as ReactNode[])); + } + } else { + rest.shift(); + } + return {rest}; + } + return ( +
    + {children} +
    + ); + }, + hr: () =>
    , + table: ({ children }) => ( +
    + {children}
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + del: ({ children }) => ( + {children} + ), + kbd: ({ children }) => { + const label = getNodeText(children).trim(); + return label ? {label} : null; + }, + mark: ({ children }) => ( + + {children} + + ), + details: ({ children, open }) => ( +
    + {children} +
    + ), + summary: ({ children }) => ( + + {children} + + ), + section: ({ children, className }) => ( +
    + {children} +
    + ), + }), [isLightTheme, renderHeading]); + + return ( + + {prepared} + + ); +}); diff --git a/src/components/tabs/releaseNotes/ReleaseNotesMedia.tsx b/src/components/tabs/releaseNotes/ReleaseNotesMedia.tsx new file mode 100644 index 00000000..abaccdea --- /dev/null +++ b/src/components/tabs/releaseNotes/ReleaseNotesMedia.tsx @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { X } from 'lucide-react'; +import { + classifyMediaUrl, + coerceHtmlBoolean, + isAllowedMediaUrl, + isGithubAttachmentUrl, + isLocalMediaPath, + toFilesystemPath, +} from '../../../lib/releaseNotes/mediaUrls'; + +function toWebviewSrc(src: string): string { + if (!isLocalMediaPath(src)) return src; + try { + return convertFileSrc(toFilesystemPath(src)); + } catch { + return src; + } +} + +function firstSourceSrc(children: ReactNode): string | undefined { + if (!Array.isArray(children) && !children) return undefined; + const items = Array.isArray(children) ? children : [children]; + for (const child of items) { + if (child && typeof child === 'object' && 'props' in child) { + const src = (child as { props?: { src?: string } }).props?.src; + if (src) return src; + } + } + return undefined; +} + +export function ReleaseNotesVideo({ + src, + title, + loop, + poster, + children, +}: { + src?: string; + title?: string; + loop?: unknown; + poster?: string; + children?: ReactNode; +}) { + const resolved = src || firstSourceSrc(children); + if (!resolved || !isAllowedMediaUrl(resolved)) { + return ( +

    + Video omitted (unsupported source). +

    + ); + } + + const caption = title?.trim(); + const displaySrc = toWebviewSrc(resolved); + const displayPoster = poster && isAllowedMediaUrl(poster) ? toWebviewSrc(poster) : undefined; + + return ( +
    + + {caption ? ( +
    + {caption} +
    + ) : null} +
    + ); +} + +function Lightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) { + useEffect(() => { + const onKey = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return createPortal( +
    + + {alt} event.stopPropagation()} + /> +
    , + document.body, + ); +} + +export function ReleaseNotesImage({ + src, + alt, + title, +}: { + src?: string; + alt?: string; + title?: string; +}) { + const classifiedVideo = + classifyMediaUrl(src) === 'video' || title === 'video' || Boolean(title?.startsWith('zync-video')); + const [mediaSrc, setMediaSrc] = useState(src); + const [failed, setFailed] = useState(false); + const [asVideo, setAsVideo] = useState(classifiedVideo); + const [open, setOpen] = useState(false); + const close = useCallback(() => setOpen(false), []); + + if (src !== mediaSrc) { + setMediaSrc(src); + setFailed(false); + setAsVideo(classifiedVideo); + setOpen(false); + } + + if (!src || !isAllowedMediaUrl(src)) { + return alt ? {alt} : null; + } + + if (asVideo) { + return ; + } + + if (failed) { + if (!isLocalMediaPath(src) && (isGithubAttachmentUrl(src) || classifyMediaUrl(src) === 'unknown')) { + return ; + } + return ( +

    + Couldn’t load image{alt ? `: ${alt}` : ''}. +

    + ); + } + + const caption = (alt || '').trim(); + const displaySrc = toWebviewSrc(src); + + return ( +
    + + {caption ? ( +
    + {caption} +
    + ) : null} + {open ? : null} +
    + ); +} diff --git a/src/components/ui/DynamicIcon.tsx b/src/components/ui/DynamicIcon.tsx index f8dbdf08..3f175442 100644 --- a/src/components/ui/DynamicIcon.tsx +++ b/src/components/ui/DynamicIcon.tsx @@ -163,7 +163,7 @@ export const DynamicIcon = memo(function DynamicIcon({ alt={type} style={{ width: size, height: size }} className={cn( - "shrink-0 select-none transition-opacity duration-200", + "shrink-0 select-none", isLoaded ? "opacity-100" : "opacity-0" )} draggable={false} diff --git a/src/lib/ghostSuggestions/promptCwdSniffer.ts b/src/lib/ghostSuggestions/promptCwdSniffer.ts index ea2c4c2f..9e34a428 100644 --- a/src/lib/ghostSuggestions/promptCwdSniffer.ts +++ b/src/lib/ghostSuggestions/promptCwdSniffer.ts @@ -73,19 +73,25 @@ export function extractCwdFromPromptOutput(text: string): string | null { const sniffBuffers = new Map(); const sniffDecoders = new Map(); +export type CwdSnifferFeedOptions = { resetDecoder?: boolean; resetBuffer?: boolean }; + /** Feed PTY output bytes; invokes onCwd when a prompt path is recognized. */ export function feedPromptCwdSniffer( termId: string, data: Uint8Array, onCwd: (path: string) => void, + options?: CwdSnifferFeedOptions, ): void { if (!data.length) return; let decoder = sniffDecoders.get(termId); - if (!decoder) { + if (options?.resetDecoder || !decoder) { decoder = new TextDecoder('utf-8', { fatal: false }); sniffDecoders.set(termId, decoder); } + if (options?.resetBuffer) { + sniffBuffers.delete(termId); + } const chunk = decoder.decode(data, { stream: true }); const prev = sniffBuffers.get(termId) ?? ''; diff --git a/src/lib/ghostSuggestions/secretInputDetect.ts b/src/lib/ghostSuggestions/secretInputDetect.ts index b9aa7340..9d8e0146 100644 --- a/src/lib/ghostSuggestions/secretInputDetect.ts +++ b/src/lib/ghostSuggestions/secretInputDetect.ts @@ -38,19 +38,25 @@ export function detectSecretPromptInOutput(text: string): boolean { return SECRET_PROMPT_PATTERNS.some((pattern) => pattern.test(tail)); } +export type SnifferFeedOptions = { resetDecoder?: boolean; resetBuffer?: boolean }; + /** Feed PTY output; invokes onSecretPrompt when a hidden-input prompt is recognized. */ export function feedSecretInputSniffer( termId: string, data: Uint8Array, onSecretPrompt: () => void, + options?: SnifferFeedOptions, ): void { if (!data.length) return; let decoder = sniffDecoders.get(termId); - if (!decoder) { + if (options?.resetDecoder || !decoder) { decoder = new TextDecoder('utf-8', { fatal: false }); sniffDecoders.set(termId, decoder); } + if (options?.resetBuffer) { + sniffBuffers.delete(termId); + } const chunk = decoder.decode(data, { stream: true }); const prev = sniffBuffers.get(termId) ?? ''; diff --git a/src/lib/releaseNotes/alerts.ts b/src/lib/releaseNotes/alerts.ts new file mode 100644 index 00000000..4d44a271 --- /dev/null +++ b/src/lib/releaseNotes/alerts.ts @@ -0,0 +1,32 @@ +export const ALERT_KINDS = ['note', 'tip', 'important', 'warning', 'caution'] as const; +export type AlertKind = (typeof ALERT_KINDS)[number]; + +const ALERT_RE = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i; + +export function matchAlertPrefix(text: string): { kind: AlertKind; rest: string } | null { + const trimmed = text.trimStart(); + const match = trimmed.match(ALERT_RE); + if (!match) return null; + return { + kind: match[1].toLowerCase() as AlertKind, + rest: trimmed.slice(match[0].length).trimStart(), + }; +} + +/** Strip `[!NOTE]` from the first string part only; later parts keep their markup. */ +export function stripAlertPrefixFromParts(parts: unknown[]): unknown[] { + let stripped = false; + const out: unknown[] = []; + for (const part of parts) { + if (!stripped && typeof part === 'string') { + const alert = matchAlertPrefix(part); + if (alert) { + stripped = true; + if (alert.rest) out.push(alert.rest); + continue; + } + } + out.push(part); + } + return out; +} diff --git a/src/lib/releaseNotes/headings.ts b/src/lib/releaseNotes/headings.ts new file mode 100644 index 00000000..c9a6e1db --- /dev/null +++ b/src/lib/releaseNotes/headings.ts @@ -0,0 +1,83 @@ +export interface TocEntry { + id: string; + text: string; + level: 1 | 2 | 3; +} + +export function normalizeHeadingText(text: string): string { + return text + .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') + .replace(/`([^`]*)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +export function headingKey(text: string): string { + return normalizeHeadingText(text).toLowerCase().replace(/[^a-z]/g, ''); +} + +export function slugify(text: string, usedSlugs: Map): string { + const normalized = normalizeHeadingText(text) + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + const baseSlug = normalized || 'section'; + + if (usedSlugs.has(baseSlug)) { + const count = usedSlugs.get(baseSlug)! + 1; + usedSlugs.set(baseSlug, count); + return `${baseSlug}-${count}`; + } + + usedSlugs.set(baseSlug, 0); + return baseSlug; +} + +export function extractToc(markdown: string): TocEntry[] { + const lines = markdown.split('\n'); + const usedSlugs = new Map(); + const entries: TocEntry[] = []; + let inFence = false; + + for (const line of lines) { + if (/^\s*```/.test(line)) { + inFence = !inFence; + continue; + } + if (inFence) continue; + + const m = line.match(/^(#{1,3})\s+(.+)/); + if (!m) continue; + + const level = m[1].length as 1 | 2 | 3; + const text = normalizeHeadingText(m[2]); + if (!text) continue; + + entries.push({ + level, + text, + id: slugify(text, usedSlugs), + }); + } + + return entries; +} + +/** First TOC id per level+text; later duplicates keep the first occurrence. */ +export function buildHeadingIdLookup(toc: TocEntry[]): Map { + const map = new Map(); + for (const entry of toc) { + const key = `${entry.level}:${entry.text}`; + if (!map.has(key)) map.set(key, entry.id); + } + return map; +} + +export function headingLookupKey(level: 1 | 2 | 3, text: string): string { + return `${level}:${normalizeHeadingText(text)}`; +} diff --git a/src/lib/releaseNotes/mediaUrls.ts b/src/lib/releaseNotes/mediaUrls.ts new file mode 100644 index 00000000..60d9061d --- /dev/null +++ b/src/lib/releaseNotes/mediaUrls.ts @@ -0,0 +1,238 @@ +const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'ogg', 'ogv', 'mov', 'm4v']); +const IMAGE_EXTENSIONS = new Set([ + 'png', + 'jpg', + 'jpeg', + 'gif', + 'webp', + 'svg', + 'avif', + 'bmp', + 'ico', + 'apng', +]); + +const ALLOWED_HOSTS = new Set([ + 'github.com', + 'www.github.com', + 'raw.githubusercontent.com', + 'user-images.githubusercontent.com', + 'private-user-images.githubusercontent.com', + 'objects.githubusercontent.com', + 'media.githubusercontent.com', + 'camo.githubusercontent.com', + 'img.shields.io', +]); + +export type MediaKind = 'image' | 'video' | 'unknown'; + +function parseHttpUrl(raw: string | undefined | null): URL | null { + if (!raw) return null; + try { + const url = new URL(raw); + if (url.protocol !== 'https:') return null; + return url; + } catch { + return null; + } +} + +function hostAllowed(hostname: string): boolean { + const host = hostname.toLowerCase(); + if (ALLOWED_HOSTS.has(host)) return true; + return host.endsWith('.githubusercontent.com'); +} + +function pathExtension(pathname: string): string { + const last = pathname.split('/').pop() ?? ''; + const dot = last.lastIndexOf('.'); + if (dot <= 0 || dot === last.length - 1) return ''; + return last.slice(dot + 1).toLowerCase(); +} + +function extensionOf(raw: string): string { + let path = raw.trim().split('?')[0].split('#')[0]; + if (/^file:/i.test(path)) { + try { + path = new URL(path).pathname; + } catch { + /* keep */ + } + } + return pathExtension(path.replace(/\\/g, '/')); +} + +function hasMediaExtension(raw: string): boolean { + const ext = extensionOf(raw); + return IMAGE_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext); +} + +export function hasPathTraversal(raw: string): boolean { + if (raw.includes('\0')) return true; + const candidates = [raw]; + try { + candidates.push(decodeURIComponent(raw)); + } catch { + /* malformed percent-encoding */ + } + if (/^file:/i.test(raw)) { + try { + candidates.push(decodeURIComponent(new URL(raw).pathname)); + } catch { + /* ignore */ + } + } + return candidates.some((path) => { + const normalized = path.replace(/\\/g, '/'); + return normalized.split('/').includes('..') || /%2e%2e/i.test(normalized); + }); +} + +/** Absolute Windows/POSIX/`file:` paths with a media extension. */ +export function isLocalMediaPath(raw: string | undefined | null): boolean { + if (!raw) return false; + const trimmed = raw.trim(); + if (!hasMediaExtension(trimmed)) return false; + if (/^file:/i.test(trimmed)) { + try { + return new URL(trimmed).protocol === 'file:'; + } catch { + return false; + } + } + if (/^[a-zA-Z]:[\\/]/.test(trimmed)) return true; + if (trimmed.startsWith('\\\\')) return true; + if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return true; + return false; +} + +export function toFileUrl(raw: string): string { + const trimmed = raw.trim(); + if (/^file:/i.test(trimmed)) return trimmed; + if (/^[a-zA-Z]:[\\/]/.test(trimmed)) { + return new URL(`file:///${trimmed.replace(/\\/g, '/')}`).href; + } + if (trimmed.startsWith('\\\\')) { + return new URL(`file:${trimmed.replace(/\\/g, '/')}`).href; + } + if (trimmed.startsWith('/')) { + return new URL(`file://${trimmed}`).href; + } + return trimmed; +} + +export function toFilesystemPath(raw: string): string { + const trimmed = raw.trim(); + if (!/^file:/i.test(trimmed)) return trimmed; + const url = new URL(trimmed); + let path = decodeURIComponent(url.pathname); + if (/^\/[a-zA-Z]:\//.test(path)) path = path.slice(1); + if (url.hostname) { + const rest = path.replace(/^\//, '').replace(/\//g, '\\'); + return `\\\\${url.hostname}${rest ? `\\${rest}` : ''}`; + } + return path; +} + +/** Rewrite a Windows/POSIX path to `file:` so markdown/HTML sanitizers keep it. */ +export function rewriteLocalMediaSrc(src: string): string | null { + if (!isLocalMediaPath(src) || hasPathTraversal(src)) return null; + return toFileUrl(src); +} + +export function isAllowedMediaUrl(raw: string | undefined | null): boolean { + if (!raw) return false; + if (isLocalMediaPath(raw) && !hasPathTraversal(raw)) return true; + const url = parseHttpUrl(raw); + if (!url) return false; + if (hostAllowed(url.hostname)) return true; + // Release notes often hotlink CDNs (R2, etc.). Require a media extension. + return hasMediaExtension(raw); +} + +export function isGithubAttachmentUrl(raw: string | undefined | null): boolean { + const url = parseHttpUrl(raw); + if (!url || !hostAllowed(url.hostname)) return false; + const host = url.hostname.toLowerCase(); + if (host === 'github.com' || host === 'www.github.com') { + return url.pathname.startsWith('/user-attachments/assets/'); + } + return host === 'private-user-images.githubusercontent.com'; +} + +export function classifyMediaUrl(raw: string | undefined | null): MediaKind { + if (!isAllowedMediaUrl(raw) || !raw) return 'unknown'; + const ext = extensionOf(raw); + if (VIDEO_EXTENSIONS.has(ext)) return 'video'; + if (IMAGE_EXTENSIONS.has(ext)) return 'image'; + return 'unknown'; +} + +export function coerceHtmlBoolean(value: unknown): boolean { + if (value === true || value === '' || value === 'true' || value === 'loop') return true; + if (typeof value === 'string' && value.toLowerCase() === 'loop') return true; + return false; +} + +function unwrapAngle(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.startsWith('<') && trimmed.endsWith('>')) return trimmed.slice(1, -1).trim(); + return trimmed; +} + +export function shouldEmbedAsMedia(raw: string): boolean { + const path = unwrapAngle(raw); + if (!isAllowedMediaUrl(path) || hasPathTraversal(path)) return false; + if (isLocalMediaPath(path) || isGithubAttachmentUrl(path)) return true; + const kind = classifyMediaUrl(path); + return kind === 'image' || kind === 'video'; +} + +function mapOutsideFences(markdown: string, fn: (chunk: string) => string): string { + return markdown + .split(/(```[\s\S]*?```|~~~[\s\S]*?~~~)/g) + .map((part) => (part.startsWith('```') || part.startsWith('~~~') ? part : fn(part))) + .join(''); +} + +function toMarkdownImageDest(path: string): string { + const local = rewriteLocalMediaSrc(path); + if (local) return `<${local}>`; + if (/[()\s]/.test(path)) return `<${path}>`; + return path; +} + +/** + * GitHub/WaveTerm often paste a bare path, CDN GIF URL, or `![](C:\…)`. + * Rewrite those to image markdown before the parser treats them as links/`C:` protocols. + */ +export function rewriteMarkdownLocalMedia(markdown: string): string { + return mapOutsideFences(markdown, (chunk) => { + const withImages = chunk.replace( + /!\[([^\]]*)\]\(\s*\n)]+)>?\s*\)/g, + (full, alt: string, dest: string) => { + const path = dest.trim(); + if (!isLocalMediaPath(path)) return full; + return `![${alt}](${toMarkdownImageDest(path)})`; + }, + ); + return withImages.replace(/^[ \t]*\S[^\n]*$/gm, (line) => { + const trimmed = line.trim(); + if (trimmed.startsWith('![') || /^<\/?[a-zA-Z]/.test(trimmed)) return line; + const linked = trimmed.match(/^\[([^\]]*)\]\(\s*\n)]+)>?\s*\)$/); + if (linked) { + const dest = linked[2].trim(); + const label = linked[1].trim(); + if (shouldEmbedAsMedia(dest) && (label === dest || label === '' || shouldEmbedAsMedia(label))) { + const indent = line.match(/^[ \t]*/)?.[0] ?? ''; + return `${indent}![](${toMarkdownImageDest(dest)})`; + } + return line; + } + const candidate = unwrapAngle(trimmed); + if (!shouldEmbedAsMedia(candidate)) return line; + const indent = line.match(/^[ \t]*/)?.[0] ?? ''; + return `${indent}![](${toMarkdownImageDest(candidate)})`; + }); + }); +} diff --git a/src/lib/releaseNotes/reactText.ts b/src/lib/releaseNotes/reactText.ts new file mode 100644 index 00000000..4ba52e73 --- /dev/null +++ b/src/lib/releaseNotes/reactText.ts @@ -0,0 +1,8 @@ +import { isValidElement, type ReactNode } from 'react'; + +export function getNodeText(node: ReactNode): string { + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(getNodeText).join(''); + if (isValidElement<{ children?: ReactNode }>(node)) return getNodeText(node.props.children); + return ''; +} diff --git a/src/lib/releaseNotes/sanitizeSchema.ts b/src/lib/releaseNotes/sanitizeSchema.ts new file mode 100644 index 00000000..8bcddb1b --- /dev/null +++ b/src/lib/releaseNotes/sanitizeSchema.ts @@ -0,0 +1,31 @@ +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; + +type SanitizeSchema = NonNullable[0]>; + +/** GitHub-style markdown HTML, plus `