Compare commits

...

15 Commits

Author SHA1 Message Date
陈大猫
3c6d888ca9 fix(icons): use a tight-crop source for Windows/Linux to unshrink the app icon (#816)
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Closes #813.

#803 enlarged public/icon.svg's squircle to ~88% of the canvas so the
macOS dock icon would match third-party apps that don't leave Apple's
HIG grid margin. That fix is right for macOS — the dock already
rounds / shadows its own icons and the grid margin lines Netcatty up
with neighbors. But every non-mac launcher (Windows taskbar, Start
menu, desktop shortcuts, KDE / GNOME launchers, AppImage integrations)
renders icons full-bleed into a fixed-size slot, so that ~12% padding
shows up as visible empty space around the squircle — the reporter's
"taskbar icon looks smaller and blurrier than other apps".

Split the icon sources by platform:

- public/icon.svg / public/icon.png — unchanged, keeps the #803 88%
  fill. mac.icon (implicit via top-level) still uses it.
- public/icon-win.svg — new source with viewBox="100 100 824 824"
  (tight-cropped to the squircle) and the faint white outline stroke
  disabled. Rendered at 1024×1024 into public/icon-win.png.
- electron-builder.config.cjs wires win.icon and linux.icon to the
  new tight-crop source. Top-level icon: stays the padded version so
  the mac path is unchanged.

electron-builder generates a multi-size .ico from a ≥256px PNG on
Windows and scales PNG variants for Linux, so a single
1024×1024 source covers both platforms without new build steps.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:20:09 +08:00
陈大猫
73b27ad7c4 fix(autocomplete): sync ghost text to live input on every keystroke (#815)
* fix(autocomplete): sync ghost text to live input on every keystroke

Ghost text was displayed based on whatever input was passed to
GhostTextAddon.show() at fetch time. Between a user's keystroke and
the next debounced fetchSuggestions firing (~100ms), the on-screen
line had already advanced one character but ghost.getGhostText() still
returned the pre-update tail. Pressing → during that window pasted the
stale tail on top of the new char — e.g. type "do", suggestion shows
"cker ls"; type "c", accept immediately → "doc" + "cker ls" lands as
"doccker ls" instead of the expected "docker ls".

Two-layer fix:

1. New GhostTextAddon.adjustToInput(newInput) that re-renders the ghost
   against a fresh input without waiting for a new fetch: shrinks /
   grows the tail if the suggestion still prefix-matches, hides
   otherwise. Called from handleInput after every buffer mutation
   (printable, backspace, Ctrl-W, paste tail) when the buffer is
   reliable. Unreliable-buffer paths skip the call to avoid making the
   ghost lie.

2. Defense-in-depth at both ghost-accept sites (→ and Ctrl-→):
   recompute the tail against the live typed buffer instead of trusting
   getGhostText's show()-time state. If the suggestion no longer
   prefixes the live buffer, hide without writing. Ctrl-→ additionally
   resyncs ghost.show() to the live buffer before picking the next word
   so getNextWord operates on an up-to-date tail.

* fix(autocomplete): defer ghost text updates to the next xterm render

The previous pass made adjustToInput re-show the ghost synchronously on
every keystroke, but xterm hasn't echoed the triggering char yet at
that moment — cursorX is still the pre-keystroke position. Painting
the shrunken tail there left it visibly overlapping with the char
xterm was about to draw, and the ghost only snapped to the right
column on the next onRender tick. That one-frame overlap is the
"jitter" the reporter still saw.

Switch adjustToInput to a defer-and-reapply pattern:

- On every keystroke that should re-align the ghost, stash the desired
  input in pendingInput and hide the element immediately. The
  transient blank frame is preferable to an overlap glyph.
- The existing term.onRender listener now checks for a pending update
  first: by that tick xterm has processed the echo, cursorX has
  advanced, and we can paint the new tail at the correct column via
  applyInputUpdate.
- New isActive() exposes "has a live suggestion even if hidden waiting
  for render" so a fast "type + →" / "type + Ctrl-→" sequence in the
  hide-until-render gap still hits the accept branch and grabs the
  recomputed tail from the live buffer.

show() and hide() clear pendingInput so an explicit state change
supersedes any queued adjust.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): restore ghost text, predict-anchor-shift on each keystroke

The previous refactor broke inline completion entirely:

1. useTerminalAutocomplete force-disabled showGhostText whenever
   showPopupMenu was on — and both are true by default, so ghost
   never rendered.
2. GhostTextAddon put its overlay container *under* xterm's screen
   via insertBefore + no z-index. xterm's default renderer paints
   theme.background across every cell including empty ones, so the
   ghost was fully occluded by the canvas even when the hook *did*
   call show().

Fixes both issues and lands the correct per-keystroke strategy the
jitter report was asking for:

- Drop the showGhostText-vs-showPopupMenu gate; respect user settings.
- Put the ghost container back on top of the screen (appendChild +
  z-index 1).
- Track anchorInputLength at show() time. adjustToInput now advances
  the ghost's left by (newInput.length - anchorInputLength) cells
  *synchronously* — i.e. it predicts where xterm's cursor will land
  once the echo arrives, instead of re-reading the live cursorX that
  hasn't advanced yet. textContent is trimmed in the same call, so
  ghost + real-input stay aligned across SSH echo latency with no
  one-frame overlap or blank gap.
- Updated GhostTextAddon.test.ts expectations for the new behavior
  (and cast the fake-document through unknown to fix the pre-existing
  TS error).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): address ghost text review feedback

Follow-ups on the predict-anchor-shift from the previous commit,
based on a code-reviewer pass:

- Backspace / Ctrl-W de-sync: updatePosition's Math.max(0, ...) was
  clamping the delta to zero when newInput shrank below the show-time
  input length. The ghost then stayed pinned at the original anchor
  column while the real cursor walked back left, leaving a gap
  between the cursor and the ghost. Let the delta go negative so the
  ghost tracks the cursor backwards; clamp the resulting left at 0
  instead of clamping the delta.
- Resize staleness: onResize now also resets lastLeft/lastTop and
  re-renders, so the dedup cache in updatePosition doesn't hide a
  now-stale pixel coordinate after xterm recomputes cell dims.
- Added a regression test for the backspace path covering both the
  step-back-below-anchor case and the clamp-at-0-on-overshoot case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): don't accept whole suggestion when buffer is unreliable

Codex flagged (#815 P1 ×2) that the live-buffer recompute on → and
Ctrl-→ falls into a degenerate path when typedBufferReliableRef is
false. My previous cut used live = "" as the fallback, but
fullSuggestion.startsWith("") is always true — so:

- → would write the entire suggestion over whatever is on the line
  (post history-recall ↑, Ctrl-R reverse search, etc.).
- Ctrl-→ would reanchor the ghost at the start and getNextWord would
  hand back the first token, duplicating leading content on top of
  the recalled command.

When the buffer is unreliable, empty buffer ≠ empty line — the line
has content we're not tracking. Fall back to the ghost's own cached
state instead of recomputing:

- → reliable: recompute tail vs live buffer, flip buffer to the
  accepted suggestion, reliability back on.
- → unreliable: use ghost.getGhostText() (shown-at-show-time tail)
  and don't touch the buffer/reliability flag.
- Ctrl-→ reliable: resync ghost to live, then proceed as before.
- Ctrl-→ unreliable: skip the resync, derive the shrink baseline from
  fullSuggestion - current-ghost-tail so the next-word logic still
  works off whatever the ghost was actually showing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): hide ghost on single-byte cursor/recall control chars

Reviewer caught that Ctrl-P / Ctrl-N / Ctrl-R / Ctrl-A / Ctrl-E and
friends flip typedBufferReliableRef to false but don't hide the
ghost — leaving it rendering a tail tied to the pre-recall line. The
previous commit's unreliable-→ fallback then reads that stale tail
via ghost.getGhostText() and writes it onto the recalled line,
reproducing the very duplication class the fallback was meant to
prevent (just triggered by Ctrl-P instead of ↑).

Mirror what the escape-sequence branch already does: clearState() +
return. Once the ghost is hidden, ghost.isActive() is false at the →
and Ctrl-→ gates, so the accept-path doesn't fire at all until a
fresh fetchSuggestions re-anchors it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): drop accepted-command cache on cursor/recall keys

Reviewer pointed out that the early returns in the single-byte
ctrl-char and escape-sequence branches leave lastAcceptedCommandRef
untouched. If the user accepts a suggestion via → and then immediately
hits Ctrl-R or ↑ to pick a different command, the fast Enter path
(lines ~611-612) still reads the cached accepted command and records
it — logging the old suggestion instead of whichever command the
reverse-search or history-recall actually ran.

Null lastAcceptedCommandRef at the top of both branches (same place
we hide the ghost and flip reliability off) so accept + recall + Enter
records the recalled command, not the stale accept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): also null accepted-command cache on Ctrl-C / Ctrl-U

Reviewer flagged this class of bug is still reachable via Ctrl-C /
Ctrl-U. The branch handling those kills the zle line, but the early
return leaves lastAcceptedCommandRef pointing at a command that is
no longer on the line: accept "git status" via → → Ctrl-C to abandon
→ type "ls" → Enter logs "git status" via the fast path instead of
"ls".

Same one-liner as the other early-return branches: null the cache
alongside clearState(). Now the cache's lifetime truly ends at any
event that invalidates the accept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): null accepted-command cache on bracketed paste too

Fifth-pass reviewer caught the last symmetric gap: the bracketed-paste
branch appends pasted bytes to the buffer but leaves lastAcceptedCommandRef
set. Accept "git status" via → then bracketed-paste " --short" (no
embedded newline), press Enter — the fast path at line 611 still reads
"git status" and logs that instead of "git status --short".

Mirror the non-bracketed paste branch: null the cache before clearState()
returns. All handleInput paths that extend or invalidate the line now
consistently end the cache's lifetime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): predict ghost column by cell width + wrap at EOL

Review caught two geometry bugs in GhostTextAddon.updatePosition that
only surfaced outside the ASCII happy path:

- CJK / fullwidth / emoji glyphs occupy two xterm cells but the
  predictor advanced by one char-length per code unit, so ghost
  drifted one cell left for every wide char typed and visibly
  overlapped the user's glyph.
- When the predicted column crossed term.cols the real cursor wrapped
  to the next row, but the predictor just piled more pixels onto
  `left` — ghost walked off the right edge instead of following
  onto the next line.

Fix both by switching from code-unit count to a small EAW-style
width classifier, then applying row wrapping via
  col = (anchorX + cellDelta) % cols
  rowOffset = Math.floor((anchorX + cellDelta) / cols)
against the current term.cols. Fake terminal in the test suite now
exposes cols/rows so the unit tests can exercise both invariants:

- "advances the anchor by two cells when a CJK glyph is typed"
- "wraps the ghost to the next row when the predicted column crosses cols"

Known limitation the review already flagged: on backspace-after-wide
we don't have per-grapheme widths to reverse exactly, so the negative
delta falls back to code-unit width on the deleted slice. The slice
is `currentSuggestion[currentInput.length..anchorInputLength]` which
is the same text the user would have typed, so it's correct when
only ASCII edits; wide-char backspace can still drift by one cell.
Fixing this cleanly needs a per-grapheme buffer and is out of scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): honor showGhostText toggle while a ghost is on screen

Codex flagged (#815 P2) that fetchSuggestions gates new ghost shows
on settingsRef.current.showGhostText, but handleInput's adjustToInput
call had no such guard. A ghost that was already active at the moment
the user turned showGhostText off would keep tracking the typed
buffer via adjustToInput on every keystroke, so the "disabled" setting
only took hold after some unrelated path called clearState().

Two-part fix:

- Add a useEffect watching settings.showGhostText. When it flips false,
  hide the active ghost immediately so the disabled setting applies to
  whatever was already on screen.
- Gate the adjustToInput call in handleInput behind
  settingsRef.current.showGhostText too, so subsequent keystrokes under
  the disabled setting don't try to move or re-show a ghost.

Codex's earlier P2 about wrap-at-EOL on line 236 is already resolved
by e61f0e8b (predict-column-with-wrap + CJK width); that comment is
against an older commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): self-heal stale anchor + handle backward-wrap on delete

Codex flagged two real geometry gaps in the predict-anchor-shift math:

1. Stale anchor on high-latency shells. show() captures cursorX from
   xterm at debounce-fire time, but under SSH round-trip latency the
   user's latest keystroke may not have echoed yet — cursorX is still
   the pre-echo column. With updatePosition now purely anchor-based
   (no longer reading live cursorX on every render), that stale anchor
   becomes frozen; the ghost stays one-plus cells off for the whole
   suggestion session until another show() rebuilds it.
2. Backspace crossing a wrapped row boundary. Math.max(0, ...) clamped
   targetCol at zero, so deletions past column 0 stayed pinned to the
   current row instead of wrapping back to the previous row — exactly
   the symmetric case the forward wrap added in e61f0e8b handles.

Fixes:

- Self-heal in updatePosition: while no adjustToInput has moved us
  from the show-time baseline (currentInput.length === anchorInputLength),
  re-read live cursorX/Y each render tick. Once the user starts typing
  the anchor is frozen and delta math takes over.
- Normalize the wrap for negative targetCol: `col = targetCol % cols`
  plus `if (col < 0) col += cols`, `rowOffset = Math.floor(targetCol/cols)`
  naturally yielding -1 on underflow. Clamp `top` at row 0 so a
  runaway negative doesn't render above the terminal.

Two new tests cover both invariants:
- "self-heals a stale anchor on render while no adjustToInput has fired"
- "wraps the ghost to the previous row when deletion crosses a row boundary"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): restore ghost/popup mutual-exclusivity guard in hook

Codex flagged (#815 P2) that dropping the popup-wins-over-ghost
normalization inside useTerminalAutocomplete weakens the hook's own
defensive invariant. The repo enforces mutual exclusivity in two
places already — SettingsTerminalTab toggles one off when the other
turns on, and domain/models.ts normalizes stored settings so
autocompletePopupMenu === true forces autocompleteGhostText to false
— so on the normal Terminal.tsx → store path only one of the two
arrives as true. But the hook's own defaults (DEFAULT_AUTOCOMPLETE_SETTINGS)
have both flags true, and any caller that builds settings directly
from those defaults (tests, future embedders) would end up rendering
popup + inline ghost simultaneously against the repo-wide contract.

Restore the guard, comment it as defensive rather than load-bearing
so future readers don't mistake it for the hiding-invisible-ghost
bug I was fixing last time (that was really the insertBefore /
z-index issue in GhostTextAddon.ts, not this normalization).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:06:26 +08:00
libalpm64
4090483738 Fix Security Issues (#799)
* chore(deps): bump fast-xml-parser and @aws-sdk/xml-builder

Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) and [@aws-sdk/xml-builder](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/xml-builder). These dependencies needed to be updated together.

Updates `fast-xml-parser` from 5.3.4 to 5.5.8
- [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases)
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.3.4...v5.5.8)

Updates `@aws-sdk/xml-builder` from 3.972.4 to 3.972.18
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/xml-builder/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/xml-builder)

---
updated-dependencies:
- dependency-name: fast-xml-parser
  dependency-version: 5.5.8
  dependency-type: indirect
- dependency-name: "@aws-sdk/xml-builder"
  dependency-version: 3.972.18
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump follow-redirects from 1.15.11 to 1.16.0

Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump hono from 4.12.7 to 4.12.14

Bumps [hono](https://github.com/honojs/hono) from 4.12.7 to 4.12.14.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.7...v4.12.14)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump vite from 7.3.1 to 7.3.2

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 7.3.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump flatted from 3.3.3 to 3.4.2

Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump minimatch from 3.1.2 to 3.1.5

Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump lodash from 4.17.23 to 4.18.1

Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump @hono/node-server from 1.19.11 to 1.19.14

Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.11 to 1.19.14.
- [Release notes](https://github.com/honojs/node-server/releases)
- [Commits](https://github.com/honojs/node-server/compare/v1.19.11...v1.19.14)

---
updated-dependencies:
- dependency-name: "@hono/node-server"
  dependency-version: 1.19.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump rollup from 4.57.1 to 4.60.2

Bumps [rollup](https://github.com/rollup/rollup) from 4.57.1 to 4.60.2.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.60.2)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.60.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump electron from 40.1.0 to 40.8.5

Bumps [electron](https://github.com/electron/electron) from 40.1.0 to 40.8.5.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v40.1.0...v40.8.5)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 40.8.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump path-to-regexp from 8.3.0 to 8.4.2

Bumps [path-to-regexp](https://github.com/pillarjs/path-to-regexp) from 8.3.0 to 8.4.2.
- [Release notes](https://github.com/pillarjs/path-to-regexp/releases)
- [Changelog](https://github.com/pillarjs/path-to-regexp/blob/master/History.md)
- [Commits](https://github.com/pillarjs/path-to-regexp/compare/v8.3.0...v8.4.2)

---
updated-dependencies:
- dependency-name: path-to-regexp
  dependency-version: 8.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump picomatch from 2.3.1 to 2.3.2

Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps): bump yaml from 2.8.2 to 2.8.3

Bumps [yaml](https://github.com/eemeli/yaml) from 2.8.2 to 2.8.3.
- [Release notes](https://github.com/eemeli/yaml/releases)
- [Commits](https://github.com/eemeli/yaml/compare/v2.8.2...v2.8.3)

---
updated-dependencies:
- dependency-name: yaml
  dependency-version: 2.8.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump @xmldom/xmldom from 0.8.11 to 0.8.13

Bumps [@xmldom/xmldom](https://github.com/xmldom/xmldom) from 0.8.11 to 0.8.13.
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.8.11...0.8.13)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-version: 0.8.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump brace-expansion from 1.1.12 to 1.1.14

Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.14.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.14)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore(deps-dev): bump tar from 7.5.7 to 7.5.13

Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.7 to 7.5.13.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.7...v7.5.13)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Security Fixes

Security fixes:
Added input validation for uncontrolled command lines.
Added Proper Shell Escaping for useTerminalAutocomplete
Fixed 4 race condition alerts by atomic stat+read(s) without following symlinks.

Misc:
Use Crypto randomness instead of Math.random() (Not a security issue but convenient)

* Fix OS quirk fallbacks

* Review fix

- use lstat before open to skip FIFO/devices early to prevent blocks
- SFTP skip UUID tag could be dubiously long

* allow symlinks alongside regular files.

* Use acutal target size for reading

* Fix Destructed import / fix to use full shellEscape charset

- Destructed import
- Guard now matches full shellEscape charset

* Supress Codex complaints

Replaced manual fd.read with fs.promises.readFile(fd) to ensure complete file reads to EOF.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 01:41:26 +08:00
陈大猫
9bf4aed44f fix(autocomplete): stop prepending theme cwd ("~ ") to completed commands (closes #806) (#814)
* fix(autocomplete): honor typed keystrokes when the prompt parser over-captures

Closes #806.

## Root cause

findPromptBoundary stops at the first "PROMPT_CHAR + space" it sees on
the current line. Themes that render additional content after the
prompt char — most notably oh-my-zsh robbyrussell's "➜  ~ " where "~"
is the cwd — trip it: promptText becomes "➜ ", userInput becomes
"~ sudo id". Every consumer downstream treats the theme's cwd marker
as part of the user's command, so:

  1. recordCommand logs entries like "~ sudo id" into history.
  2. fuzzyQueryHistory later returns those polluted entries as
     suggestions.
  3. When the user hits Tab, insertSuggestion compares
     suggestion.text ("~ ls") against userInput ("~ lo"), falls into
     the Ctrl-U-plus-rewrite path, and the phantom "~ " ends up on
     the real command line.

The reporter hit this right after `sudo` because sudo's password
interaction gave history enough polluted entries to start winning
fuzzy matches; without sudo the popup stays empty so the Ctrl-U
rewrite path never fires and the bug is invisible.

## Fix

Track what the user actually typed in an independent keystroke buffer
(typedInputBufferRef) inside the autocomplete hook:

- Append every printable char / paste chunk.
- Pop on backspace, word-kill on Ctrl+W.
- Clear on Enter, Ctrl+C, Ctrl+U, and any escape sequence / unhandled
  control char (cursor moves we can't follow invalidate the buffer).

Introduce reconcilePromptWithTypedInput: if detectPrompt's userInput
ends with the typed buffer and is longer, the parser over-captured —
move the excess back to promptText so userInput matches what was
actually typed. Apply at every detectPrompt call site
(fetchSuggestions, the stale-result recheck, insertSuggestion).

For Enter-record the typed buffer wins outright when present, but
only after a live detectPrompt confirms we're at a shell prompt —
otherwise a password-entry Enter would log the password as a
command.

insertSuggestion / ghost-text accept update the typed buffer to the
accepted text so a subsequent Enter records the right command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): track keystroke-buffer reliability, skip it after cursor moves

Codex flagged (#814 P1) that clearing typedInputBufferRef on escape /
control sequences and then re-appending printable keys leaves the
buffer holding only the post-navigation suffix of the real line.
A classic Up-arrow-recall workflow — ↑ to pull "git commit -m fix"
out of history, append one char, Enter — would record just that one
char as the command, polluting history and skewing future fuzzy
matches.

Add typedBufferReliableRef as a companion flag:

- Reset (reliable=true) on Enter / Ctrl-C / Ctrl-U (zle wipes the
  line, our buffer is a true view of the empty line again).
- Also reset by insertSuggestion and ghost-text right-arrow accept
  once they write the full accepted text and we re-align the buffer
  to it.
- Cleared (reliable=false) when any escape sequence, unhandled
  control char (Ctrl-P / Ctrl-N / Ctrl-R / Ctrl-A / Ctrl-E / ...)
  arrives — those can move the cursor or swap the zle line in ways
  an append-only buffer can't follow.

All four call sites now gate on the flag:

- reconcilePromptWithTypedInput receives the buffer only when
  reliable, so an unreliable buffer never trims the detector's
  userInput (avoids a symmetric flavor of the original bug where
  the detector is right and the buffer is wrong).
- Enter-record prefers the buffer only when reliable; otherwise it
  falls straight through to detectPrompt.
- The Ctrl+Right (next-word ghost accept) append is skipped when
  unreliable so we don't seed the buffer with just that word.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): resync typed buffer when sub-dir select rewrites the line

Codex flagged (#814 P2) that handleSubDirSelect rewrites the command
line via writeToTerminal(Ctrl-U + cmdPrefix + fullPath) but never
touches typedInputBufferRef. After the rewrite the buffer still holds
whatever was typed before, so pressing Enter records that stale partial
input as the executed command — polluting history and steering later
suggestions off course.

Same commit also routes handleSubDirSelect through
reconcilePromptWithTypedInput. The raw detectPrompt would include the
robbyrussell "~ " cwd marker in the command prefix it reconstructs,
which is the original symmetric #806 bug leaking into this path too.

After the rewrite, set the buffer to the newly written command string
and flip reliability back on — the terminal line content now matches
it exactly, so the next Enter-record does the right thing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): reset typed buffer when a paste chunk carries a newline

Codex flagged (#814 P2) that multi-character paste payloads skip the
top-of-handleInput Enter guard (which compares data === "\r" exactly),
so a paste like "cmd\r" goes through the paste branch and the "\r" gets
appended to typedInputBufferRef verbatim. The shell executes "cmd", but
our buffer is left holding "cmd\r...", still marked reliable. The next
Enter then records whatever combined stale string lives there.

Detect line terminators inside multi-char paste chunks: slice from the
last \r or \n onward and keep only that tail as the new buffer content
(and flip reliability back on, since the tail now matches the shell's
zle line). Skip synthesizing recordCommand entries for the flushed
intermediate lines — onCommandExecuted in createXTermRuntime already
tracks pasted multi-line input independently, so duplicating the logic
here would risk double-counting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): clear lastAcceptedCommandRef on paste-with-newline early return

Codex flagged (#814 P2) that the multi-line-paste branch clears the
keystroke buffer and bails out before the rest of handleInput runs —
including the line that resets lastAcceptedCommandRef. If the user had
just accepted a suggestion (Tab / → / popup click), the embedded
newline still flushes it in the shell, but our fast-path cache keeps
holding it. The next Enter then takes the lastAcceptedCommandRef
shortcut and logs that old suggestion as the executed command,
polluting history with something the user didn't actually run.

Null lastAcceptedCommandRef.current at the same point we reset the
typed buffer so the fast path stays aligned with the shell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): require typed buffer to align with live line before recording

Codex flagged (#814 P1) that paste paths which bypass handleInput —
the createXTermRuntime hotkey / context-menu / middle-click handlers
all call writeToSession(...) directly — leave typedInputBufferRef
stale while still marked reliable. A "type prefix → paste remainder →
Enter" flow would then record just the keyboard-typed prefix, feeding
garbage back into autocomplete ranking.

Require alignment: livePrompt.userInput must end with the typed buffer
before we trust it. reconcilePromptWithTypedInput already snaps the two
together when they *are* aligned — if its endsWith check fails, the
buffer is stale (or mid-navigation) and we fall back to
livePrompt.userInput instead. That drops the #806 fix for this one
paste-bypass case, but the same flow would have hit the same pollution
before this PR, so it's a no-regression fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): route out-of-band paste writes through handleInput

Codex flagged (#814 P1) that the reconcile path in fetchSuggestions
has the same stale-buffer failure mode the Enter-record path now
guards against: snippet / keyboard-paste / selection-paste /
middle-click-paste handlers in createXTermRuntime call
writeToSession directly, so typedInputBufferRef only holds whatever
was typed *after* the paste. reconcilePromptWithTypedInput then
treats the pasted prefix as prompt text and trims it, completions
fetch on the truncated input, and accepting a suggestion rewrites
the command incorrectly.

Fix at the source: notify the autocomplete hook with the raw
(pre-bracket-wrap) bytes at every paste site so its keystroke
buffer absorbs them through the same handleInput path keyboard
input uses. handleInput's multi-char paste branch already resets /
aligns the buffer (and invalidates on embedded escape sequences),
so this single extra call per paste site is enough — no new hook
API needed. The existing onData-driven notification at line 684
already covers the non-paste keyboard path, and the snippet /
paste / pasteSelection / middle-click handlers are the only
remaining paths that bypass it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): preserve inner newlines of bracketed-paste input

Codex flagged (#814 P2) that the multi-char-paste branch in
handleInput drops everything before the last newline, but when
bracketed paste is active those newlines are literal input staying on
the zle line — not command terminators. A multi-line paste like
"cmd1\ncmd2" then left only "cmd2" in typedInputBufferRef and the
next Enter recorded / trusted just the tail.

Teach handleInput to recognize the bracketed-paste wrapper
"\x1b[200~...\x1b[201~" and append the enclosed content verbatim
(reliability flag stays on — we know exactly what was added).

Matching change in createXTermRuntime: pass the final (possibly
bracket-wrapped) bytes to ctx.onAutocompleteInput instead of the raw
pre-wrap text so the handle sees the markers when applicable.
Non-bracketed pastes still hit the existing newline-split branch so
each "\n" resets the buffer to the post-terminator tail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(autocomplete): route every prompt consumer through getAlignedPrompt

Each Codex round on #814 surfaced one more code path that needed the
"consume the keystroke buffer only when it's aligned with the live
line" gate: Enter-record, fetchSuggestions (×2), insertSuggestion,
handleSubDirSelect, fetchSubDirForIndex. The fixes were correct but
the guard ended up spelled three different ways across the file:

  reconcilePromptWithTypedInput(detectPrompt(term), reliable ? buf : "")

plus a separate `userInput.endsWith(buf)` check in the Enter branch.
That scatter is exactly how the next out-of-band writer gets missed
and regresses #806.

Collapse all six sites onto one helper:

  getAlignedPrompt(term, buffer, reliable) → { prompt, alignedTyped }

The helper owns the policy — reliability + endsWith alignment — in one
place. Non-aligned buffers fall through as raw detector output (same
pre-PR behavior, so the worst case for any future forgotten path is
a degrade, not a pollution). Enter-record additionally consumes
alignedTyped, which is only non-null when the buffer truly matches
the tail, so it can record the clean typed command directly without
redoing the endsWith check.

No behavior change from the previous commit; this is purely
deduplication of the alignment guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autocomplete): inherit reliability on bracketed paste instead of resetting

Codex flagged (#814 P1 follow-up) that the bracketed-paste branch
unconditionally flipped typedBufferReliableRef back to true. A
history-recall-then-paste flow (↑ marks the buffer unreliable, then
bracketed paste arrives) would then set reliable=true even though
the buffer only contains the pasted tail, not the recalled head.
getAlignedPrompt's endsWith check can pass trivially for a short
paste tail that happens to equal the last N chars of the recalled
line, and Enter would record just the pasted fragment.

Reliability is now inherited across a bracketed paste rather than
reset: if the buffer was already aligned, appending the paste keeps
it aligned; if the buffer was unreliable (post-recall / post-cursor-
move), it stays unreliable and the alignment guard in getAlignedPrompt
falls through to the raw detector result the way it should.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:40:29 +08:00
陈大猫
a5b5f15343 feat(terminal): quick encoding switch for telnet & serial (closes #804) (#812)
* feat(terminal): extend quick encoding switcher to telnet and serial sessions

Closes #804.

TerminalToolbar only showed the UTF-8 / GB18030 encoding menu for SSH
sessions. Telnet and serial sessions had no runtime control — their
decoder was fixed at session start via charsetToNodeEncoding + Node's
StringDecoder, which only knows utf8/latin1/ascii/utf16le. Users
connecting to legacy telnet daemons or MCU consoles emitting GBK were
stuck with the encoding chosen at connect time and could not switch to
read non-latin text correctly.

Main side (terminalBridge.cjs):
- Swap StringDecoder for iconv-lite on the telnet + serial paths so
  GB18030 actually decodes. Local PTY and mosh keep StringDecoder —
  local follows the OS locale and mosh frames its own UTF-8, neither
  needs a runtime swap.
- Store the decoder through a mutable decoderRef on the session object
  so the onData closures stay untouched while a new IPC handler can
  swap in a fresh decoder mid-session.
- Add normalizeTerminalEncoding that resolves user-facing charset
  names (utf-8/gbk/gb2312/gb18030) into iconv identifiers.
- Register netcatty:terminal:setEncoding, which updates the session's
  encoding + decoderRef (and mirrors to serialEncoding for aiBridge /
  mcpServerBridge exec calls that still read the legacy field).

Renderer + preload:
- preload.setSessionEncoding now tries the SSH handler first and falls
  through to the new terminal handler when the SSH side reports ok:
  false (non-SSH sessions don't have session.stream). Single preload
  method, one extra IPC round-trip only for telnet/serial, which only
  happens on explicit user click.
- Drop the isSSHSession gate in TerminalToolbar; replace with
  encodingSwitchSupported = not local, not mosh, not localhost-PTY.
- Terminal.tsx onSessionAttached now syncs the initial encoding for
  every protocol that supports it (same gate as the toolbar), not
  only SSH.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): decode serial exec output with iconv for non-Buffer encodings

Codex flagged (#812 P1) that session.serialEncoding can now be an
iconv-only label like gb18030 after a user switches encoding via the
new terminal toolbar menu. execViaRawPty then called
data.toString(encoding) on the raw Buffer, which throws
"TypeError: Unknown encoding" for anything outside Node's
utf8/latin1/ascii/utf16le set. The throw landed inside the data
listener so Catty Agent / MCP serial exec calls failed and, worse,
the uncaught path could destabilize the process.

Route the decode through a small decodeBufferAs helper: Node encoding
labels still use Buffer.toString for speed; anything else falls back
to iconv-lite (which already handles the toolbar's GB18030). A last-
resort utf8 fallback keeps the listener from throwing even if iconv
itself rejects an unrecognized label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): don't overwrite telnet/serial charset on session attach

Codex flagged (#812 P1) that extending onSessionAttached to sync the
UI encoding for telnet and serial sessions corrupts any host charset
outside the toolbar's two values. terminalEncodingRef is derived from
a useState that only ever resolves to 'utf-8' or 'gb18030', so a host
configured with latin1 / shift_jis had its correct decoder immediately
clobbered with one of those two as soon as the session attached.

SSH is the only protocol that actually needs this sync: its backend
starts in utf-8 regardless of host.charset. startTelnetSession and
startSerialSession already apply options.charset through
normalizeTerminalEncoding, so leaving them alone keeps arbitrary
iconv labels intact; the toolbar's runtime switch remains the path
for users who do want to flip to UTF-8 / GB18030 mid-session.

Restore the SSH-only gate on the sync and document why the new
protocols are intentionally excluded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(terminal): align encoding menu rows with the rest of the popover

The encoding section used a different template from every other row in
the overflow menu: an uppercase "TERMINAL ENCODING" section header,
then two indented rows with a leading check mark instead of a leading
icon. Next to Open SFTP / Scripts / Terminal settings it read as a
different component and made the popover feel disjointed.

Drop the section header and render both encoding options as plain
menuItemClass rows — Languages icon on the left to match the Zap /
Palette leading-icon pattern, label in the flex-1 slot, and the active
row gets a trailing Check in place of a right-side accessory. A single
divider above them still groups the choice visually without the
uppercase label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(terminal): collapse encoding picker into a proper submenu

The previous pass put UTF-8 and GB18030 as flat rows under a separator
inside the main overflow popover. It matched the top rows better but
still looked like a disjoint block of two choices stuck at the bottom.

Turn the encoding picker into a nested submenu so the parent popover
stays a flat list of actions and the choice lives behind a single row
that mirrors the other menu items exactly: Languages icon on the left,
t("terminal.toolbar.encoding") label in the flex slot, the current
value as a muted caption, and a ChevronRight to signal the submenu.

The submenu itself is a second Popover anchored to the right of the
parent. Both popovers are now controlled so picking a value closes
the whole chain in one click, and the parent's onInteractOutside
ignores clicks that land in the submenu portal — otherwise Radix
would treat the submenu click as "outside" the parent and dismiss it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): drop hostname gate, simplify encoding row label

Two issues in one pass:

1. Codex P2 (#812): encodingSwitchSupported still hard-disabled the
   menu when host.hostname === 'localhost'. That was a leftover from
   when the only "local" escape hatch was hostname-based, but it
   incorrectly blocks telnet / SSH sessions aimed at localhost (test
   daemons, forwarded endpoints) which do have a real backend decoder
   we can drive. The isLocalTerminal / isMoshSession gates already
   cover the true local PTY and mosh cases — drop the hostname check.

2. UI: the submenu trigger carried the current value as a muted
   caption next to the label. At w-48 the row ran out of room and
   truncated "Terminal Encoding" to "Terminal Enc...". Since the
   submenu already marks the active choice with a check, the caption
   is redundant. Remove it so the full label fits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): stream-decode serial output with a stateful per-command decoder

Codex flagged (#812 P2) that decoding each serial data event with a
stateless decodeBufferAs call corrupts multi-byte characters on
GBK/GB18030 consoles: serial ports deliver chunks at arbitrary byte
boundaries, so the leading half of a 2-byte char in one event gets
emitted as replacement bytes before the trailing half ever arrives.

Build a stateful decoder once per execViaRawPty call (StringDecoder
for Node-native encodings, iconv.getDecoder for iconv-only labels
like gb18030) and feed every chunk through decoder.write(). On
finish, decoder.end() flushes any partial bytes the decoder is still
holding into the final output before it's handed back to the caller.
Strings pass through untouched, same as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): sync SSH encoding on localhost sessions too

Codex flagged (#812 P2) that dropping the 'localhost' check from the
toolbar's encodingSwitchSupported gate left an inconsistency:
Terminal.tsx onSessionAttached still skipped setSessionEncoding when
host.hostname === 'localhost', so a user could pick GB18030, reconnect
a localhost SSH tab, and the backend would restart in utf-8 while the
UI still showed GB18030 — mojibake until manually toggled again.

Drop the hostname clause from the isSSH check here as well. SSH to
localhost is still a real SSH session whose backend starts in utf-8;
the sync is what keeps the UI's picked encoding aligned across
reconnects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(terminal): re-sync telnet/serial encoding after user opt-in

Codex flagged (#812 P2) that the SSH-only sync left telnet/serial with
a silent UI/backend mismatch across reconnects: a user picks GB18030,
the tab disconnects and retries, startTelnetSession/startSerialSession
re-apply host.charset, and the UI still shows GB18030 — garbled output
until the user toggles again.

An unconditional sync isn't right either (earlier review: it would
clobber arbitrary host.charset values like latin1 / shift_jis that
the UI's two-value state can't represent). Track whether the user
has actually clicked the toolbar menu this session via
userPickedEncodingRef — once set, any subsequent onSessionAttached
for telnet/serial re-applies the picked value; on first attach with
no user action the backend's configured charset stays intact.

SSH keeps the unconditional sync (its backend always starts in utf-8,
so there's no configured charset to preserve).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:28:05 +08:00
陈大猫
5b26a4a447 fix(sftp): download all selected files instead of only the right-clicked one (#811)
Closes #805.

The SFTP file-list context menu's Download action only passed the
right-clicked entry to the single-file handler, so selecting N files
and hitting Download still downloaded only one — matching copy/move/
delete, which already iterate selectedFiles, this is the odd one out.

Add onDownloadFiles through the SftpContext → pane callbacks → file-
list chain. In the context menu, if the right-clicked row is part of
pane.selectedFiles and the selection has >1 entry, fall into the new
multi-file path; single selection stays on the existing handler so
its save-dialog UX is unchanged.

The new handleDownloadFilesForSide iterates local selections with the
existing blob path (browser auto-saves each file). For remote panes
it prompts for a target directory once via selectDirectory and streams
every selected file into it — avoids the N-save-dialog prompt storm
that a naive loop would trigger. Mirrors the existing directory-
download branch.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:30:13 +08:00
陈大猫
6565e984b4 fix(ssh): include legacy HMACs for very old servers (closes #807) (#810)
* fix(ssh): include legacy HMAC algorithms when legacy toggle is enabled

buildAlgorithms() adds legacy kex, cipher, and host-key algorithms when
the user enables "allow legacy algorithms", but never specified hmac at
all — so ssh2's built-in modern HMAC defaults applied even in legacy
mode. Very old servers (FreeBSD 6.1's OpenSSH circa 2006, per issue #807)
only speak hmac-sha1 / hmac-md5, so MAC negotiation silently settled on
something the server couldn't actually compute. The resulting wrong
exchange-hash MAC then failed host-key signature verification, surfacing
as "Handshake failed: signature verification failed" which misleadingly
looks like a host-key algorithm problem.

Add an explicit algorithms.hmac list in the legacy branch that keeps
modern MACs at the top and appends hmac-sha1 / hmac-md5. Modern servers
will still prefer SHA-2; only servers that literally can't do SHA-2 will
fall back to SHA-1/MD5.

Closes #807.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): skip hmac-md5 when OpenSSL build disables MD5 (FIPS)

Codex flagged (#810 review) that ssh2 validates exact algorithm lists
strictly and FIPS-enabled Node/OpenSSL builds disable MD5. With an
unconditional 'hmac-md5' entry in algorithms.hmac, those builds would
throw "Unsupported algorithm" before the SSH handshake even begins,
turning the legacy toggle into a hard failure even for servers that
only needed hmac-sha1.

Feature-detect MD5 via crypto.getHashes() at module load and only append
'hmac-md5' when it's actually available. hmac-sha1 stays unconditional
— FIPS 140-2 permits HMAC-SHA1 even where SHA-1 is disallowed for other
uses, and ssh2 ships with it in its defaults anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ssh): preserve EtM SHA-1 MAC in legacy algorithm list

Codex flagged (#810 P2) that replacing ssh2's default MAC set with an
exact list omitted 'hmac-sha1-etm@openssh.com', which is present in
ssh2's DEFAULT_MAC. Hosts that only offer EtM SHA-1 MACs would then
fail legacy-mode negotiation with "no matching C->S MAC" even though
they negotiated successfully before the legacy HMAC list was introduced.

Insert 'hmac-sha1-etm@openssh.com' between the SHA-2 EtM entries and
plain hmac-sha1 so modern MACs still take priority and the fallback
chain matches ssh2's own default ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:15:27 +08:00
bincxz
587071cfea chore: ignore .worktrees/** in ESLint config
Running `eslint .` from the repo root traversed into local git worktrees
under .worktrees/ and linted their source copies, which don't match the
relative ignore patterns like `electron/**` and `scripts/**`. Result: a
thousand no-undef errors from Node/browser globals in worktree-mirrored
.cjs / .mjs files.

Add .worktrees/** to the global ignores list so worktrees are skipped
regardless of whether node_modules is symlinked or fresh-installed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:24:37 +08:00
陈大猫
08f00ed143 fix(editor): address Codex review feedback on PR #808 (#809)
* fix(editor): address Codex review feedback on PR #808

Three issues raised on the merged editor-tab-form PR:

P1 — Host-picker switch ignored onDisconnect cancellation
SftpPaneDialogs' onSelectLocal / onSelectHost awaited onDisconnect() and
unconditionally called onConnect() regardless of the dirty-editor prompt
outcome. A user who hit Cancel on the "unsaved changes" dialog would still
end up switched to the new host, stranding the editor tabs on a now-stale
connection. Change onDisconnect to return Promise<boolean> (true when the
disconnect actually ran, false on prompt cancel) and gate onConnect on it.
Propagate the new signature through SftpPaneCallbacks, the pane-actions
hook result, and both left/right implementations.

P2 — setIsQuitting leaked across canceled quits
electron/main.cjs called windowManager.setIsQuitting(true) at the top of
before-quit, before the dirty-editor check returned. If the renderer
reported hasDirty=true and the quit was canceled, isQuitting stayed true,
changing later window-close behavior (close-to-tray paths gated on
!isQuitting would stop firing). Move the setIsQuitting call into a
commitQuit() helper that only runs once we've decided to actually proceed
— on hasDirty=true we leave state untouched.

P2 — SftpSidePanel unmount only cleaned active-pane connections
The cleanup effect inspected only leftPane / rightPane (the active tab
per side), missing editor tabs tied to inactive tabs in the same side
panel. On unmount those tabs would survive with a dead save bridge.
Iterate leftTabs.tabs and rightTabs.tabs and collect every connection id
before calling forceCloseBySessions.

npm test — 212/212 pass, tsc error count unchanged from main, lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(editor): stabilize bridge registration effect and memoize filename dedup

Two perf concerns from a focused leak/perf audit of PR #808:

1. Bridge writer effect re-ran on every SFTP state change.
   SftpView / SftpSidePanel registered their bridge writer in an effect
   with `[sftp]` deps. The `sftp` object identity changes on every SFTP
   state update — transfer progress, directory listing, pane updates,
   tab switches — so the effect would unregister+reregister constantly
   during routine SFTP use. Not a leak (React runs cleanup before each
   re-effect), just high-frequency churn on the hot path.
   Route through sftpRef and run the effect once; writeTextFileByConnection
   is a methodsRef-backed dispatcher that stays valid across sftp re-renders.

2. O(n²) filename disambiguation scan in TopTabs render.
   Each editor tab ran `editorTabs.filter(same fileName)` inside the per-tab
   render branch. Negligible at ~20 tabs but trivially fixable: build a
   fileName→count map in a useMemo keyed on editorTabs and look up in O(1).

Separately noted but NOT fixed here (needs a store refactor and deserves
its own PR): App.tsx subscribing to useEditorTabs() means every keystroke
in an editor tab re-renders the App root. Would need a useEditorTabIds()
selector that only notifies on add/remove.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:17:28 +08:00
陈大猫
b9e9a0d59c feat(editor): promote SFTP text editor into top-level tabs (#631) (#808)
* chore: ignore local .worktrees/ directory

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): editorTabStore scaffold with single-tab ops

Implements the EditorTabStore class singleton (matching activeTabStore pattern)
with updateContent, markSaved, setWordWrap, setSavingState, close, and subscribe.
Includes useSyncExternalStore hooks and 6 passing unit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): editorTabStore promoteFromModal with per-session path dedup

* feat(editor): confirmCloseBySession for session teardown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sftp): writeTextFileByConnection for pane-agnostic saves

Adds a new `writeTextFileByConnection(connectionId, expectedHostId, filePath, content, filenameEncoding?)` method to `useSftpExternalOperations` that looks up the SFTP pane by connection ID (with a hostId safety check) instead of the left/right-side coupling used by `writeTextFile`. Threads the existing `getPaneByConnectionId` callback through the call site and re-exports the new method via `SftpStateApi`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(editor): editorSftpBridge singleton for out-of-React saves

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(editor): extract TextEditorPane from TextEditorModal

Lift Monaco editor body + toolbar + theme sync + paste fallback into a
pure TextEditorPane component. Adds sftp.editor.maximize i18n key to
en.ts and zh-CN.ts locale files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(editor): drop unused getLanguageId import in TextEditorPane

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(editor): TextEditorModal delegates to TextEditorPane

Replace the monolithic modal (560 lines including full Monaco setup)
with a thin Dialog shell (~150 lines) that owns content/saving/saveError/
languageId state, save orchestration, and dirty-check on close, then
delegates all editor chrome to <TextEditorPane chrome="modal" />.

Exports TextEditorModalSnapshot for the optional onPromoteToTab callback
so callers can later wire tab promotion (Task 12) without breaking the
existing interface — the new prop is optional and existing callers
(SftpOverlays.tsx) are source-compatible with zero changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editor): include fileName and wordWrap in TextEditorModalSnapshot

Task 12 will populate the promoted tab with these fields, so the snapshot
must carry them from the modal at maximize time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): UnsavedChangesDialog three-button confirm

* fix(editor): resolve UnsavedChangesDialog re-entrance and unmount leaks

- Re-entrance: if prompt() is called while a prior prompt is still pending,
  cancel the prior one so its caller doesn't hang forever.
- Unmount: resolve any in-flight prompt as "cancel" in the effect cleanup
  so awaiters don't leak when the provider unmounts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): TextEditorTabView tab-form shell

Add TextEditorTabView component that binds an editorTabStore entry to
TextEditorPane, with CSS display:none toggling for inactive tabs so the
Monaco instance persists across tab switches.  Also adds setLanguage
public method to EditorTabStore (lands Task 15's intent early — Task 15
can be a no-op).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editor): read live store state in TextEditorTabView handlers

React state snapshot lags the store by a microtask. Closing over `tab`
meant a keystroke between Monaco's onChange and a Ctrl+S would write
stale content and mark a stale baseline. Read via editorTabStore.getTab
at call time instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): dispatch editor:* tab ids in App and activeTabStore

- Add EDITOR_PREFIX, isEditorTabId, toEditorTabId, fromEditorTabId helpers
- Add useIsEditorTabActive hook to activeTabStore
- Update useIsTerminalLayerVisible to exclude editor tabs
- Import useEditorTabs and TextEditorTabView into App.tsx
- Append editor tab ids (editor:<id>) to allTabs in hotkey handler
- Mount TextEditorTabView per editorTab with CSS visibility toggling
- Add editorTabs to executeHotkeyAction useCallback dependency array

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(editor): render editor tabs in TopTabs with icon/dirty/tooltip

- Add `fromEditorTabId`, `isEditorTabId` imports to TopTabs.tsx
- Add `FileCode`, `FileText` icons; use FileCode for code-like extensions
- Extend `TopTabsProps` with `editorTabs`, `onRequestCloseEditorTab`, `hostById`
- Build `editorTabMap` for O(1) lookup; add `editor` branch in `orderedTabItems`
- Render editor tab chrome matching terminal tab style: file icon, dirty dot (●),
  filename with disambiguation suffix for duplicate filenames, close button
- In App.tsx: add stub `handleRequestCloseEditorTab`, `orderedTabsWithEditors`,
  pass new props to `<TopTabs>`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(editor): hoist editor-tab code-extension regex and use onSelectTab

- Move CODE_EXTENSIONS_RE to module scope so it isn't recompiled per render.
- Call onSelectTab(tabId) for consistency with other tab types, instead of
  reaching into activeTabStore directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): maximize modal to tab and dirty-confirm tab close

Wire onPromoteToTab from TextEditorModal through SftpOverlays and
useSftpViewFileOps so clicking the maximize button snapshots editor
state into editorTabStore and activates the new editor tab.

Replace the stub handleRequestCloseEditorTab in App.tsx with a real
dirty-confirm flow using UnsavedChangesProvider render-prop: clean tabs
close immediately, dirty tabs prompt save/discard/cancel, and save
routes through editorSftpBridge with markSaved on success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(editor): register SFTP bridge and gate session close on dirty editor tabs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editor): make onDisconnect async so host-picker waits for dirty check

The session-close dirty gate added in Task 13 made onDisconnect async, but
the host-picker in SftpPaneDialogs still called it synchronously before
kicking off onConnect — a fire-and-forget that raced past the dirty prompt
and let unsaved editor tabs slip through. Propagate the Promise return type
through SftpPaneCallbacks / SftpPaneDialogs / useSftpViewPaneActionsResult
and await it at the host-picker call sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): block app quit while editor tabs are dirty

Add a before-quit IPC guard that asks the renderer whether any editor
tab has unsaved changes. If dirty tabs exist, preventDefault() blocks
the quit and a warning toast is shown. The app quits normally once
editors are clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editor): add 5s timeout fallback to quit-guard IPC check

If the renderer crashes or throws before reporting back, the quitGuard
would stay busy forever and the app could not be quit. Fall back to
force-quit after 5 s if no reply arrives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): quit-guard uses quitConfirmed flag to prevent re-entry loop

The prior flow reset quitGuardChannelBusy before calling app.quit(), which
on macOS re-fires before-quit and re-entered the dirty check with the flag
cleared — creating an infinite IPC loop. Introduce a separate quitConfirmed
flag that commits to quitting before app.quit() fires, so the re-entry takes
the fast path.

Also extract QUIT_GUARD_TIMEOUT_MS and clarify that a concurrent quit while
a check is in flight is swallowed (preventDefault) rather than letting the
second event through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): use absolute inset-0 for tab panel and add sr-only DialogTitle

Two bugs surfaced during the first dev-server smoke test:

1. Editor tab content was blank because TextEditorTabView used only
   className="h-full", while its sibling panels (VaultView, SftpView,
   TerminalLayerMount, LogView) all fill their flex-1 parent via
   `absolute inset-0`. In normal flow the editor tab collapsed to zero
   height. Match the sibling convention.

2. Radix printed an accessibility warning because the Task 7 refactor
   pulled the DialogTitle out of DialogContent and into the Pane header
   (now a plain span). Add a visually hidden DialogTitle that mirrors the
   filename, so screen readers have a title without showing it twice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): raise tab panel z-index to 20 so it sits above TerminalLayer

TerminalLayer's root is visibility:hidden when the active tab is an editor
tab, but its inner panels set `absolute inset-0 z-10` on their own and those
still paint. Without an explicit z on the editor tab panel, TerminalLayer's
inner bg-background div was covering the Monaco content, producing a blank
screen.

Also add bg-background to the wrapper so the editor tab paints an opaque
surface (matches the pattern VaultViewContainer / TerminalLayer follow).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): show host label and remote path next to filename in tab header

The editor tab form previously only showed the bare filename in its header,
which is ambiguous when the same filename is open against multiple hosts.
Add an optional subtitle prop on TextEditorPane and populate it from the
tab form with `<hostLabel>:<remotePath>` rendered in muted text beside the
filename. The modal keeps its existing filename-only header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): bridge supports multiple useSftpState instances

useSftpState is instantiated in both the top-level SftpView and the
terminal's SftpSidePanel, each owning its own pane registry. The editor
bridge previously stored only one writer, so maximizing a file opened from
the terminal side panel registered nothing (bridge was owned by SftpView
which may never have mounted) and save failed with "bridge not registered".

Change the bridge to track a Set of writers and dispatch by trying each
until one owns the connectionId (signalled by its specific "connection no
longer available" error). Add registerEditorSftpWriterScoped that returns
an unregister fn so each instance's cleanup removes only its own entry.
Register in both SftpView and SftpSidePanel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(editor): Cmd+W closes editor tab + terminal close forces tab close

Two behaviors added after user feedback from dev-server smoke-test:

1. Cmd/Ctrl+W (the closeTab hotkey) previously did nothing on editor tabs
   because executeHotkeyAction had no branch for editor:* ids. Add one that
   reaches into the UnsavedChangesProvider render-prop's close flow via a
   ref, routing through the existing dirty-confirm path.

2. Closing a terminal tab unmounts its SftpSidePanel which destroys the
   useSftpState instance that owned the connection. Any editor tab promoted
   from that panel would then be stuck — bridge gone, save channel dead.
   On SftpSidePanel unmount, gather the connection ids it owned and call a
   new editorTabStore.forceCloseBySessions to drop matching editor tabs.
   Dirty state is dropped because the user closed the terminal knowing the
   file was open — there is no save channel left anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): Cmd/Ctrl+W works when focus is inside Monaco

Monaco's internal key-event dispatcher swallows keydown before the
capture-phase handler on the Pane's root div can see it, so the global
hotkey dispatcher never got the chance to close the editor tab when the
editor had focus. Register a Monaco editor command for the close-tab
keybinding and route it through a handleCloseRef — mirrors the same
pattern used for Cmd/Ctrl+S. Also drop the modal-only guard in the
capture-phase handler so the outer-chrome path works in tab mode too.

TextEditorTabView now receives an onRequestClose(tabId) prop that App.tsx
wires via the render-prop-exposed handleRequestCloseEditorTabRef, same
mechanism as the hotkey-dispatcher path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(editor): fall back to Vaults when forceCloseBySessions removes the active tab

Closing a terminal tab triggers SftpSidePanel unmount which force-closes its
editor tabs. If the editor tab being removed happened to be the active tab
(user maximized → then closed the owning terminal from another path), the
app ended up on a stale activeTabId with no selected tab and blank content.

Inside forceCloseBySessions, if the active tab was one of the removed
editor ids, redirect to 'vault'. Picking a more sophisticated neighbor
would need the full orderedTabs list which isn't reachable from this layer;
Vaults is always valid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:03:38 +08:00
陈大猫
d02e91a14d Enlarge app icon squircle to match other macOS dock apps (#803)
* Enlarge app icon squircle so it matches other macOS dock apps

public/icon.png was generated from logo.svg which keeps the Apple HIG
grid margin (~100px all around the 824x824 squircle in a 1024 canvas).
Most third-party macOS apps (WeChat, Office, Messages, etc.) enlarge
their squircle to fill ~90% of the canvas, so Netcatty's icon looks
visibly smaller than its neighbors in the dock.

Introduce public/icon.svg as a dedicated app-icon source that tightens
the viewBox to 68 68 888 888 so the squircle renders at ~93% fill, then
regenerate public/icon.png from it. logo.svg stays untouched since it
is shared with the splash screen and tray template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Dial back icon squircle fill from 93% to 88%

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:07:52 +08:00
陈大猫
f38afd8bfc Align snippet row icons with package row icons in tree (#802)
Snippet rows used a padding-based offset to account for the chevron
column in package rows, but the flex gap between chevron and icon
wasn't being compensated so the FileCode icon sat 4-6px to the left of
the Package icon above it. Mirror the package row's flex layout
literally by rendering an invisible chevron placeholder, so both row
types share the same column structure.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:03:06 +08:00
陈大猫
c3dabbfef2 Render snippets sidebar as an expandable tree (#800) (#801)
* Render snippets sidebar as an expandable tree (#800)

The terminal sidebar used breadcrumb navigation, so switching between
packages meant clicking out and back in. Replace that with a single
tree view where each package row has a chevron to expand/collapse
(SFTP-style), so snippets across multiple packages stay visible and
reachable without drilling.

- All discovered packages default to expanded, so the tree matches the
  user's expectation of seeing everything at once.
- Search flattens to a list of matching snippets regardless of nesting,
  each annotated with its package path so the origin is still clear.
- Implicit ancestor packages (e.g. "a/b/c" implies "a" and "a/b") are
  materialized so deeply nested snippets aren't orphaned when a parent
  package isn't explicitly listed.
- Depth-based left padding + chevron rotation mirror the SFTP tree
  view's affordances.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Unify snippet row typography with tree + move command to tooltip

Snippet rows were rendered as two-line blocks (label + inline command
preview), which made them visually taller and heavier than the
single-line package rows in the tree, and long commands overflowed the
container. Collapse them to single-line rows that match the package row
layout exactly (same text size, same padding, aligned icon column) and
surface the full label + command text in a tooltip on hover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Preserve collapsed packages across snippet refreshes (codex)

The auto-expand effect compared prev.size to normalizedPackages.size to
decide whether to repopulate, but collapsed rows shrink prev.size, so any
later snippet/package change would trip the condition and overwrite the
user's collapse state with a bulk re-expand.

Track the set of packages ever observed in a ref and only auto-expand
paths that are new since the previous render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:56:14 +08:00
陈大猫
d5c937b7a9 Redesign macOS tray template icon from app icon (#798)
The previous template icon was a tiny solid silhouette that didn't fill
the menu bar slot. Rebuild it by extracting the cat head, ears, paws,
squinty eyes and nose/mouth paths directly from public/logo.svg so the
tray icon matches the app icon character, then tighten the viewBox so
the cat fills the canvas.

Windows/Linux tray-icon.png is unchanged.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:10:57 +08:00
陈大猫
c32a8e603f Fix blurry Windows/Linux tray icon on high-DPI displays (#794) (#797)
The tray icon was force-resized to 16x16 on all non-macOS platforms, so
Windows had to upscale it at every DPI scale above 100%. Attach the
existing @2x asset as a HiDPI representation instead and let the OS pick
the right pixel size per scale factor.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:45:16 +08:00
65 changed files with 4155 additions and 1060 deletions

3
.gitignore vendored
View File

@@ -55,6 +55,9 @@ coverage
# Serena MCP project config (local only)
/.serena/
# Git worktrees (local isolated workspaces)
/.worktrees/
# Windows VS Build environment scripts (local dev only)
Directory.Build.props
Directory.Build.targets

112
App.tsx
View File

@@ -1,5 +1,5 @@
import React, { Suspense, lazy, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
import { useAutoSync } from './application/state/useAutoSync';
import { useImmersiveMode } from './application/state/useImmersiveMode';
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
@@ -10,6 +10,7 @@ import { useSettingsState } from './application/state/useSettingsState';
import { useUpdateCheck } from './application/state/useUpdateCheck';
import { useVaultState } from './application/state/useVaultState';
import { useWindowControls } from './application/state/useWindowControls';
import { useEditorTabs, editorTabStore } from './application/state/editorTabStore';
import { initializeFonts } from './application/state/fontStore';
import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
@@ -54,6 +55,9 @@ import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, Termi
import { LogView as LogViewType } from './application/state/useSessionState';
import type { SftpView as SftpViewComponent } from './components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
import { TextEditorTabView } from './components/editor/TextEditorTabView';
import { UnsavedChangesProvider } from './components/editor/UnsavedChangesDialog';
import { editorSftpWrite } from './application/state/editorSftpBridge';
// Initialize fonts eagerly at app startup
initializeFonts();
@@ -330,6 +334,7 @@ function App({ settings }: { settings: SettingsState }) {
// ---------------------------------------------------------------------------
const activeTabId = useActiveTabId();
const customThemes = useCustomThemes();
const editorTabs = useEditorTabs();
useEffect(() => {
if (!settings.showSftpTab && activeTabId === 'sftp') {
@@ -869,6 +874,19 @@ function App({ settings }: { settings: SettingsState }) {
};
}, []);
// Quit guard: block app exit while any editor tab has unsaved changes.
// Main process sends "app:query-dirty-editors"; we respond with the result.
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return;
const unsub = bridge.onCheckDirtyEditors(() => {
const hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
bridge.reportDirtyEditorsResult?.(hasDirty);
});
return unsub;
}, [t]);
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -1009,6 +1027,10 @@ function App({ settings }: { settings: SettingsState }) {
const closeSidePanelRef = useRef<(() => void) | null>(null);
const activeSidePanelTabRef = useRef<string | null>(null);
const closeTabInFlightRef = useRef(false);
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
// dispatcher (defined outside that scope) can still reach the dirty-confirm
// close flow.
const handleRequestCloseEditorTabRef = useRef<(id: string) => void>(() => {});
const createLocalTerminalWithCurrentShell = useCallback(() => {
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
@@ -1127,13 +1149,13 @@ function App({ settings }: { settings: SettingsState }) {
// Shared hotkey action handler - used by both global handler and terminal callback
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces + editor tabs.
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
// doesn't land on a hidden tab (which would get redirected back) and so
// number shortcuts don't shift.
const allTabs = settings.showSftpTab
? ['vault', 'sftp', ...orderedTabs]
: ['vault', ...orderedTabs];
? ['vault', 'sftp', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))]
: ['vault', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))];
switch (action) {
case 'switchToTab': {
// Get the number key pressed (1-9)
@@ -1172,6 +1194,13 @@ function App({ settings }: { settings: SettingsState }) {
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
if (closeTabInFlightRef.current) break;
// Editor tabs route through their own dirty-confirm close flow.
if (isEditorTabId(currentId)) {
const editorId = fromEditorTabId(currentId);
if (editorId) handleRequestCloseEditorTabRef.current(editorId);
break;
}
const session = sessions.find((s) => s.id === currentId) ?? null;
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
@@ -1333,7 +1362,7 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
}
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab, confirmIfBusyLocalTerminal]);
}, [orderedTabs, editorTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab, confirmIfBusyLocalTerminal]);
// Callback for terminal to invoke app-level hotkey actions
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
@@ -1687,7 +1716,59 @@ function App({ settings }: { settings: SettingsState }) {
e.preventDefault();
}, []);
// Combined ordered tab list including editor tab ids (for TopTabs scrollable area)
const orderedTabsWithEditors = useMemo(
() => [...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))],
[orderedTabs, editorTabs],
);
return (
<UnsavedChangesProvider>
{({ prompt }) => {
// Helper: close an editor tab and activate the neighbor (left-preference), or vault.
const closeEditorAndActivateNeighbor = (id: string) => {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditors;
const idx = list.indexOf(closingTabId);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() !== closingTabId) return;
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
};
// Real dirty-confirm close handler.
const handleRequestCloseEditorTab = async (id: string) => {
const tab = editorTabStore.getTab(id);
if (!tab) return;
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
closeEditorAndActivateNeighbor(id);
return;
}
const choice = await prompt(tab.fileName);
if (choice === 'cancel') return;
if (choice === 'discard') {
closeEditorAndActivateNeighbor(id);
return;
}
if (choice === 'save') {
try {
editorTabStore.setSavingState(id, 'saving');
await editorSftpWrite(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
editorTabStore.markSaved(id, tab.content);
closeEditorAndActivateNeighbor(id);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Save failed';
editorTabStore.setSavingState(id, 'error', msg);
toast.error(msg, 'SFTP');
}
}
};
// Expose to the hotkey dispatcher (Cmd/Ctrl+W).
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
return (
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
@@ -1697,7 +1778,7 @@ function App({ settings }: { settings: SettingsState }) {
orphanSessions={orphanSessions}
workspaces={workspaces}
logViews={logViews}
orderedTabs={orderedTabs}
orderedTabs={orderedTabsWithEditors}
draggingSessionId={draggingSessionId}
isMacClient={isMacClient}
onCloseSession={closeSession}
@@ -1716,6 +1797,9 @@ function App({ settings }: { settings: SettingsState }) {
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderTabs}
showSftpTab={settings.showSftpTab}
editorTabs={editorTabs}
onRequestCloseEditorTab={handleRequestCloseEditorTab}
hostById={hostById}
/>
<div className="flex-1 relative min-h-0">
@@ -1860,6 +1944,19 @@ function App({ settings }: { settings: SettingsState }) {
/>
);
})}
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
{editorTabs.map((tab) => (
<TextEditorTabView
key={tab.id}
tabId={tab.id}
isVisible={activeTabId === toEditorTabId(tab.id)}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
hostById={hostById}
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
/>
))}
</div>
{/* Global "quick add / edit snippet" dialog, triggered by the
@@ -2106,6 +2203,9 @@ function App({ settings }: { settings: SettingsState }) {
</DialogContent>
</Dialog>
</div>
);
}}
</UnsavedChangesProvider>
);
}

View File

@@ -1780,6 +1780,12 @@ const en: Messages = {
// Text Editor
'sftp.editor.wordWrap': 'Word Wrap',
'sftp.editor.maximize': 'Maximize',
'sftp.editor.unsavedTitle': 'Unsaved changes',
'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?',
'sftp.editor.discardChanges': 'Discard',
'sftp.editor.saveAndClose': 'Save and close',
'sftp.editor.quitBlockedByDirty': 'Unsaved editors — please save or discard before quitting',
// AI Settings
'ai.agentSettings': 'Agent Settings',

View File

@@ -1789,6 +1789,12 @@ const zhCN: Messages = {
// Text Editor
'sftp.editor.wordWrap': '自动换行',
'sftp.editor.maximize': '最大化',
'sftp.editor.unsavedTitle': '未保存的修改',
'sftp.editor.unsavedMessage': '{fileName} 有未保存的修改,是否保存后关闭?',
'sftp.editor.discardChanges': '不保存',
'sftp.editor.saveAndClose': '保存并关闭',
'sftp.editor.quitBlockedByDirty': '存在未保存的编辑器,请先处理后再退出',
// AI Settings
'ai.agentSettings': 'Agent 设置',

View File

@@ -3,6 +3,18 @@ import { useCallback,useSyncExternalStore } from 'react';
// Simple store for active tab that allows fine-grained subscriptions
type Listener = () => void;
// ----- Editor tab id helpers -----
export const EDITOR_PREFIX = 'editor:';
/** Returns true when `id` is an editor tab id (starts with "editor:"). */
export const isEditorTabId = (id: string): boolean => id.startsWith(EDITOR_PREFIX);
/** Convert an editorTab's internal id to a top-tab id understood by the tab bar. */
export const toEditorTabId = (editorId: string): string => `${EDITOR_PREFIX}${editorId}`;
/** Strip the "editor:" prefix to recover the internal editorTab id. */
export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PREFIX.length);
class ActiveTabStore {
private activeTabId: string = 'vault';
private listeners = new Set<Listener>();
@@ -70,9 +82,17 @@ export const useIsSftpActive = () => {
);
};
// Check if a specific editor tab is currently active
export const useIsEditorTabActive = (tabId: string): boolean => {
const editorTopId = toEditorTabId(tabId);
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
};
// Check if terminal layer should be visible
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
const activeTabId = useActiveTabId();
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp';
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
return isTerminalTab || !!draggingSessionId;
};

View File

@@ -0,0 +1,69 @@
import type { SftpFilenameEncoding } from "../../types";
export interface EditorSftpWrite {
(
connectionId: string,
expectedHostId: string,
filePath: string,
content: string,
filenameEncoding?: SftpFilenameEncoding,
): Promise<void>;
}
// `useSftpState` is instantiated in at least two places (the top-level SftpView
// and the per-terminal SftpSidePanel), each owning its own pane registry. An
// editor tab opened from either path must be saved via the matching instance,
// so the bridge tracks all currently-mounted writers and dispatches by
// attempting each in turn until one succeeds.
//
// Each writer throws synchronously (or rejects) if the connectionId isn't in
// its pane registry; we use "connection no longer available" text as the
// signal to fall through to the next writer. Any other error is re-thrown
// immediately because it represents a real save failure the user must see.
const writers = new Set<EditorSftpWrite>();
const NOT_MY_CONNECTION_RE = /SFTP connection is no longer available/i;
export const registerEditorSftpWriter = (fn: EditorSftpWrite | null) => {
// Pass `null` on cleanup — but cleanup also needs to know WHICH writer to
// remove. Callers who register once per mount should instead use
// `registerEditorSftpWriterScoped` below, which returns an unregister fn.
// This legacy signature is preserved for callers that prefer the
// register/unregister-with-null pattern: we clear ALL writers on null.
if (fn === null) {
writers.clear();
return;
}
writers.add(fn);
};
export const registerEditorSftpWriterScoped = (fn: EditorSftpWrite): (() => void) => {
writers.add(fn);
return () => {
writers.delete(fn);
};
};
export const editorSftpWrite: EditorSftpWrite = async (...args) => {
if (writers.size === 0) {
throw new Error("SFTP editor bridge not registered — cannot save (no SFTP view mounted)");
}
let lastNotMine: Error | null = null;
for (const fn of writers) {
try {
await fn(...args);
return;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (NOT_MY_CONNECTION_RE.test(msg)) {
// This writer doesn't own the connectionId — try the next one.
lastNotMine = err instanceof Error ? err : new Error(msg);
continue;
}
// Real save error — surface it.
throw err;
}
}
// No writer owned the connectionId.
throw lastNotMine ?? new Error("SFTP connection is no longer available");
};

View File

@@ -0,0 +1,198 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EditorTabStore, type EditorTab } from "./editorTabStore.ts";
const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
id: "edt_1",
kind: "editor",
sessionId: "conn_1",
hostId: "host_1",
remotePath: "/etc/nginx/nginx.conf",
fileName: "nginx.conf",
languageId: "ini",
content: "worker_processes auto;",
baselineContent: "worker_processes auto;",
wordWrap: false,
viewState: null,
savingState: "idle",
saveError: null,
...overrides,
});
test("updateContent stores content and viewState; dirty flag derives from baseline", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
store.updateContent("edt_1", "worker_processes 4;", null);
const tab = store.getTab("edt_1")!;
assert.equal(tab.content, "worker_processes 4;");
assert.equal(store.isDirty("edt_1"), true);
});
test("markSaved moves baseline to current content and clears dirty", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ content: "changed", baselineContent: "orig" }));
assert.equal(store.isDirty("edt_1"), true);
store.markSaved("edt_1", "changed");
assert.equal(store.isDirty("edt_1"), false);
assert.equal(store.getTab("edt_1")!.baselineContent, "changed");
});
test("setWordWrap updates only that tab", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_1" }));
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt" }));
store.setWordWrap("edt_1", true);
assert.equal(store.getTab("edt_1")!.wordWrap, true);
assert.equal(store.getTab("edt_2")!.wordWrap, false);
});
test("setSavingState transitions and clears error on idle", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
store.setSavingState("edt_1", "saving");
assert.equal(store.getTab("edt_1")!.savingState, "saving");
store.setSavingState("edt_1", "error", "EACCES");
assert.equal(store.getTab("edt_1")!.saveError, "EACCES");
store.setSavingState("edt_1", "idle");
assert.equal(store.getTab("edt_1")!.saveError, null);
});
test("close removes the tab and returns remaining ids in order", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_1" }));
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt" }));
store.close("edt_1");
assert.equal(store.getTab("edt_1"), undefined);
assert.deepEqual(store.getTabs().map((t) => t.id), ["edt_2"]);
});
test("subscribers fire on change and not on read", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
let count = 0;
const unsub = store.subscribe(() => { count++; });
store.getTab("edt_1");
store.getTabs();
assert.equal(count, 0);
store.updateContent("edt_1", "x", null);
// notifications are microtask-deferred, flush via awaiting a resolved promise
return Promise.resolve().then(() => {
assert.equal(count, 1);
unsub();
});
});
test("promoteFromModal creates a new tab and returns its id", () => {
const store = new EditorTabStore();
const id = store.promoteFromModal({
sessionId: "conn_1",
hostId: "host_1",
remotePath: "/etc/nginx/nginx.conf",
fileName: "nginx.conf",
languageId: "ini",
content: "x",
baselineContent: "x",
wordWrap: false,
viewState: null,
});
const tab = store.getTab(id)!;
assert.equal(tab.remotePath, "/etc/nginx/nginx.conf");
assert.equal(tab.fileName, "nginx.conf");
assert.equal(tab.kind, "editor");
});
test("promoteFromModal focuses existing tab for same sessionId+normalized path and overrides content", () => {
const store = new EditorTabStore();
const first = store.promoteFromModal({
sessionId: "conn_1",
hostId: "host_1",
remotePath: "/etc/nginx/./nginx.conf",
fileName: "nginx.conf",
languageId: "ini",
content: "v1",
baselineContent: "v1",
wordWrap: false,
viewState: null,
});
const second = store.promoteFromModal({
sessionId: "conn_1",
hostId: "host_1",
remotePath: "/etc/nginx/nginx.conf",
fileName: "nginx.conf",
languageId: "ini",
content: "v2",
baselineContent: "v1",
wordWrap: false,
viewState: null,
});
assert.equal(second, first);
assert.equal(store.getTab(first)!.content, "v2");
assert.equal(store.getTabs().length, 1);
});
test("dedup scope is per-sessionId — same path on different sessions are distinct tabs", () => {
const store = new EditorTabStore();
const a = store.promoteFromModal({
sessionId: "conn_A",
hostId: "host_1",
remotePath: "/etc/hosts",
fileName: "hosts",
languageId: "plaintext",
content: "", baselineContent: "", wordWrap: false, viewState: null,
});
const b = store.promoteFromModal({
sessionId: "conn_B",
hostId: "host_2",
remotePath: "/etc/hosts",
fileName: "hosts",
languageId: "plaintext",
content: "", baselineContent: "", wordWrap: false, viewState: null,
});
assert.notEqual(a, b);
assert.equal(store.getTabs().length, 2);
});
test("confirmCloseBySession returns true when no tabs match", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
const ok = await store.confirmCloseBySession("other_conn", async () => "discard");
assert.equal(ok, true);
assert.equal(store.getTabs().length, 1);
});
test("confirmCloseBySession discards all dirty matching tabs when prompt returns 'discard'", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_1", content: "x", baselineContent: "y" }));
store._debugInsert(makeTab({ id: "edt_2", remotePath: "/b.txt", fileName: "b.txt", content: "x", baselineContent: "y" }));
const ok = await store.confirmCloseBySession("conn_1", async () => "discard");
assert.equal(ok, true);
assert.equal(store.getTabs().length, 0);
});
test("confirmCloseBySession closes clean tabs without prompting; aborts on cancel", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_clean" })); // content == baseline
store._debugInsert(makeTab({ id: "edt_dirty", remotePath: "/b.txt", fileName: "b.txt", content: "x", baselineContent: "y" }));
let prompts = 0;
const ok = await store.confirmCloseBySession("conn_1", async () => { prompts++; return "cancel"; });
assert.equal(ok, false);
assert.equal(prompts, 1, "prompt fires only for dirty tab");
// clean tab was closed before the dirty cancel aborted the batch
assert.equal(store.getTab("edt_clean"), undefined);
assert.ok(store.getTab("edt_dirty"));
});
test("confirmCloseBySession invokes save callback for 'save' choice and only closes on save success", async () => {
const store = new EditorTabStore();
store._debugInsert(makeTab({ id: "edt_1", content: "new", baselineContent: "old" }));
let saved = false;
const ok = await store.confirmCloseBySession("conn_1", async () => "save", async (id) => {
assert.equal(id, "edt_1");
saved = true;
store.markSaved(id, "new");
});
assert.equal(saved, true);
assert.equal(ok, true);
assert.equal(store.getTab("edt_1"), undefined);
});

View File

@@ -0,0 +1,252 @@
import { useCallback, useSyncExternalStore } from "react";
import type * as Monaco from "monaco-editor";
import { activeTabStore, fromEditorTabId, isEditorTabId } from "./activeTabStore";
// POSIX-style normalization: collapse "/./" and duplicate slashes, not ".." (remote paths
// may contain semantic ".." segments we don't want to resolve client-side).
const normalizePath = (p: string): string => {
const collapsed = p.replace(/\/+/g, "/").replace(/\/\.(?=\/|$)/g, "");
return collapsed.length > 1 && collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
};
export type EditorTabId = string;
export type EditorSavingState = "idle" | "saving" | "error";
export interface EditorTab {
id: EditorTabId;
kind: "editor";
/** SFTP connection id (matches SftpConnection.id). Session lookup key. */
sessionId: string;
/** Stable endpoint id; used to verify the session is still the one we opened against. */
hostId: string;
remotePath: string;
fileName: string;
languageId: string;
content: string;
baselineContent: string;
wordWrap: boolean;
viewState: Monaco.editor.ICodeEditorViewState | null;
savingState: EditorSavingState;
saveError: string | null;
}
type Listener = () => void;
let idCounter = 0;
const genId = (): EditorTabId => `edt_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
export class EditorTabStore {
private tabs: EditorTab[] = [];
private listeners = new Set<Listener>();
private pendingNotify = false;
getTabs = (): readonly EditorTab[] => this.tabs;
getTab = (id: EditorTabId): EditorTab | undefined => this.tabs.find((t) => t.id === id);
isDirty = (id: EditorTabId): boolean => {
const t = this.getTab(id);
return !!t && t.content !== t.baselineContent;
};
updateContent = (
id: EditorTabId,
content: string,
viewState: Monaco.editor.ICodeEditorViewState | null,
) => {
this.patch(id, { content, viewState });
};
markSaved = (id: EditorTabId, newBaseline: string) => {
this.patch(id, { baselineContent: newBaseline, savingState: "idle", saveError: null });
};
setWordWrap = (id: EditorTabId, value: boolean) => {
this.patch(id, { wordWrap: value });
};
setLanguage = (id: EditorTabId, languageId: string) => {
this.patch(id, { languageId });
};
setSavingState = (id: EditorTabId, state: EditorSavingState, error: string | null = null) => {
const patch: Partial<EditorTab> = { savingState: state };
if (state === "idle") patch.saveError = null;
else if (state === "error") patch.saveError = error;
this.patch(id, patch);
};
close = (id: EditorTabId) => {
const next = this.tabs.filter((t) => t.id !== id);
if (next.length !== this.tabs.length) {
this.tabs = next;
this.notify();
}
};
/**
* Force-close every tab bound to any of the given sessionIds, with no dirty
* prompt. Intended for cases where the owning SFTP instance has gone away
* entirely (e.g. the hosting terminal tab was closed) and there is no
* realistic save channel anyway. Returns the closed tab ids.
*/
forceCloseBySessions = (sessionIds: readonly string[]): EditorTabId[] => {
if (sessionIds.length === 0) return [];
const idSet = new Set(sessionIds);
const removed = this.tabs.filter((t) => idSet.has(t.sessionId)).map((t) => t.id);
if (removed.length === 0) return [];
this.tabs = this.tabs.filter((t) => !idSet.has(t.sessionId));
this.notify();
// If the current active tab was one of the editor tabs we just removed,
// fall back to 'vault' so the user doesn't end up on a stale id (empty
// chrome + no content). Any better neighbor choice would need the full
// orderedTabs list, which isn't available here; 'vault' is always valid.
const activeId = activeTabStore.getActiveTabId();
if (isEditorTabId(activeId)) {
const activeEditorId = fromEditorTabId(activeId);
if (activeEditorId && removed.includes(activeEditorId)) {
activeTabStore.setActiveTabId('vault');
}
}
return removed;
};
promoteFromModal = (snapshot: {
sessionId: string;
hostId: string;
remotePath: string;
fileName: string;
languageId: string;
content: string;
baselineContent: string;
wordWrap: boolean;
viewState: Monaco.editor.ICodeEditorViewState | null;
}): EditorTabId => {
const normalized = normalizePath(snapshot.remotePath);
const existing = this.tabs.find(
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
);
if (existing) {
this.patch(existing.id, {
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: snapshot.viewState,
// keep languageId/hostId/fileName stable; they shouldn't change for the same path
});
return existing.id;
}
const tab: EditorTab = {
id: this.makeId(),
kind: "editor",
sessionId: snapshot.sessionId,
hostId: snapshot.hostId,
remotePath: snapshot.remotePath,
fileName: snapshot.fileName,
languageId: snapshot.languageId,
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: snapshot.viewState,
savingState: "idle",
saveError: null,
};
this.tabs = [...this.tabs, tab];
this.notify();
return tab.id;
};
/**
* Walk all editor tabs bound to `sessionId`. Clean tabs close silently; dirty tabs
* prompt via `promptChoice`. 'save' invokes `saveTab` and closes only on its success.
* Any 'cancel' aborts the batch (subsequent dirty tabs are preserved) and returns false.
*/
confirmCloseBySession = async (
sessionId: string,
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
saveTab?: (tabId: EditorTabId) => Promise<void>,
): Promise<boolean> => {
const matching = this.tabs.filter((t) => t.sessionId === sessionId);
for (const tab of matching) {
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
this.close(tab.id);
continue;
}
const choice = await promptChoice(tab);
if (choice === "cancel") return false;
if (choice === "discard") { this.close(tab.id); continue; }
if (choice === "save") {
if (!saveTab) throw new Error("saveTab callback required when 'save' choice is possible");
try {
await saveTab(tab.id);
} catch {
// Save failed — treat like cancel (keep tab open, abort batch so the user sees the error)
return false;
}
this.close(tab.id);
}
}
return true;
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
};
/** TEST-ONLY: seed a tab without going through promote/openOrFocus. */
_debugInsert = (tab: EditorTab) => {
this.tabs = [...this.tabs, tab];
this.notify();
};
protected makeId = genId;
protected patch = (id: EditorTabId, patch: Partial<EditorTab>) => {
let changed = false;
this.tabs = this.tabs.map((t) => {
if (t.id !== id) return t;
changed = true;
return { ...t, ...patch };
});
if (changed) this.notify();
};
protected notify = () => {
if (this.pendingNotify) return;
this.pendingNotify = true;
Promise.resolve().then(() => {
this.pendingNotify = false;
this.listeners.forEach((l) => l());
});
};
}
export const editorTabStore = new EditorTabStore();
// Hooks
const getTabsSnapshot = () => editorTabStore.getTabs();
export const useEditorTabs = (): readonly EditorTab[] =>
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot);
export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};
export const useEditorDirty = (id: EditorTabId): boolean => {
const getSnapshot = useCallback(() => editorTabStore.isDirty(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};
export const useAnyEditorDirty = (): boolean => {
const getSnapshot = useCallback(
() => editorTabStore.getTabs().some((t) => t.content !== t.baselineContent),
[],
);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useRef, useMemo } from "react";
import { TransferTask, TransferStatus } from "../../../domain/models";
import { TransferTask, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
@@ -20,6 +20,7 @@ export type { UploadResult };
interface UseSftpExternalOperationsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
getPaneByConnectionId: (connectionId: string) => SftpPane | null;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
@@ -35,6 +36,13 @@ interface SftpExternalOperationsResult {
readTextFile: (side: "left" | "right", filePath: string) => Promise<string>;
readBinaryFile: (side: "left" | "right", filePath: string) => Promise<ArrayBuffer>;
writeTextFile: (side: "left" | "right", filePath: string, content: string) => Promise<void>;
writeTextFileByConnection: (
connectionId: string,
expectedHostId: string,
filePath: string,
content: string,
filenameEncoding?: SftpFilenameEncoding,
) => Promise<void>;
downloadToTempAndOpen: (
side: "left" | "right",
remotePath: string,
@@ -62,6 +70,7 @@ export const useSftpExternalOperations = (
): SftpExternalOperationsResult => {
const {
getActivePane,
getPaneByConnectionId,
refresh,
sftpSessionsRef,
connectionCacheKeyMapRef,
@@ -173,6 +182,41 @@ export const useSftpExternalOperations = (
[getActivePane, sftpSessionsRef],
);
const writeTextFileByConnection = useCallback(
async (
connectionId: string,
expectedHostId: string,
filePath: string,
content: string,
filenameEncoding?: SftpFilenameEncoding,
): Promise<void> => {
const pane = getPaneByConnectionId(connectionId);
if (!pane?.connection) {
throw new Error("SFTP connection is no longer available");
}
if (pane.connection.hostId !== expectedHostId) {
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
}
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (!bridge?.writeLocalFile) throw new Error("Local file writing not supported");
const data = new TextEncoder().encode(content);
await bridge.writeLocalFile(filePath, data.buffer);
return;
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) throw new Error("SFTP session not found");
const bridge = netcattyBridge.get();
if (!bridge) throw new Error("Bridge not available");
await bridge.writeSftp(sftpId, filePath, content, filenameEncoding ?? pane.filenameEncoding);
},
[getPaneByConnectionId, sftpSessionsRef],
);
const downloadToTempAndOpen = useCallback(
async (
side: "left" | "right",
@@ -693,6 +737,7 @@ export const useSftpExternalOperations = (
readTextFile,
readBinaryFile,
writeTextFile,
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,

View File

@@ -301,6 +301,7 @@ export const useSftpState = (
readTextFile,
readBinaryFile,
writeTextFile,
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
@@ -309,6 +310,7 @@ export const useSftpState = (
activeFileWatchCountRef,
} = useSftpExternalOperations({
getActivePane,
getPaneByConnectionId,
refresh,
sftpSessionsRef,
connectionCacheKeyMapRef,
@@ -359,6 +361,7 @@ export const useSftpState = (
readTextFile,
readBinaryFile,
writeTextFile,
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
@@ -413,6 +416,7 @@ export const useSftpState = (
readTextFile,
readBinaryFile,
writeTextFile,
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
@@ -476,6 +480,8 @@ export const useSftpState = (
readTextFile: (...args: Parameters<typeof readTextFile>) => methodsRef.current.readTextFile(...args),
readBinaryFile: (...args: Parameters<typeof readBinaryFile>) => methodsRef.current.readBinaryFile(...args),
writeTextFile: (...args: Parameters<typeof writeTextFile>) => methodsRef.current.writeTextFile(...args),
writeTextFileByConnection: (...args: Parameters<typeof writeTextFileByConnection>) =>
methodsRef.current.writeTextFileByConnection(...args),
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>

View File

@@ -1,12 +1,14 @@
/**
* ScriptsSidePanel - Lightweight scripts browser for the terminal side panel
*
* Shows snippets organized by package hierarchy with breadcrumb navigation.
* Clicking a snippet executes it in the focused terminal session.
* Shows snippets organized by package hierarchy as a single tree view.
* Packages expand / collapse via a chevron; clicking a snippet executes it
* in the focused terminal session. Typing in the search box flattens to a
* list of matching snippets regardless of package nesting.
*/
import { ChevronRight, Edit2, Package, Plus, Search, Trash2, Zap } from 'lucide-react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { ChevronRight, Edit2, FileCode, Package, Plus, Search, Trash2, Zap } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { Snippet } from '../types';
@@ -18,6 +20,7 @@ import {
} from './ui/context-menu';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
interface ScriptsSidePanelProps {
snippets: Snippet[];
@@ -26,6 +29,33 @@ interface ScriptsSidePanelProps {
isVisible?: boolean;
}
type TreeRow =
| {
type: 'package';
id: string;
path: string;
name: string;
depth: number;
count: number;
hasChildren: boolean;
isExpanded: boolean;
}
| {
type: 'snippet';
id: string;
depth: number;
snippet: Snippet;
packagePath: string;
};
const pkgDisplayName = (path: string) => {
const clean = path.startsWith('/') ? path.slice(1) : path;
const last = clean.split('/').filter(Boolean).pop() ?? clean;
// Preserve the leading slash on absolute root packages so they stay
// distinguishable from relative ones (matches the previous breadcrumb UI).
return path.startsWith('/') && !clean.includes('/') ? `/${last}` : last;
};
const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
snippets,
packages,
@@ -33,97 +63,151 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
isVisible = true,
}) => {
const { t } = useI18n();
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const displayedPackages = useMemo(() => {
if (!selectedPackage) {
const absolutePaths = packages.filter(p => p.startsWith('/'));
const relativePaths = packages.filter(p => !p.startsWith('/'));
// Normalize the package list + derive ancestor packages implied by each path
// (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed).
const normalizedPackages = useMemo(() => {
const set = new Set<string>();
const addWithAncestors = (raw: string) => {
const path = raw.trim();
if (!path) return;
const isAbs = path.startsWith('/');
const body = isAbs ? path.slice(1) : path;
const parts = body.split('/').filter(Boolean);
for (let i = 1; i <= parts.length; i++) {
const sub = parts.slice(0, i).join('/');
set.add(isAbs ? `/${sub}` : sub);
}
};
packages.forEach(addWithAncestors);
// A snippet may reference a package path that's not in `packages` yet.
snippets.forEach((s) => {
if (s.package) addWithAncestors(s.package);
});
return set;
}, [packages, snippets]);
const results: { name: string; path: string; count: number }[] = [];
// Track every package we've ever observed so we can tell "new" from
// "previously-seen-but-user-collapsed". Without this, any unrelated refresh
// that reduced prev.size (because the user collapsed a row) would
// incorrectly trip a bulk re-expand.
const seenPackagesRef = useRef<Set<string>>(new Set());
const relativeRoots = relativePaths
.map((p) => p.split('/')[0])
.filter((name): name is string => Boolean(name) && name.length > 0);
// Default: auto-expand packages the first time they appear, so the user sees
// everything without drilling in. After that, respect the user's collapse
// choices across unrelated refreshes.
useEffect(() => {
const seen = seenPackagesRef.current;
const newlySeen: string[] = [];
normalizedPackages.forEach((p) => {
if (!seen.has(p)) {
seen.add(p);
newlySeen.push(p);
}
});
if (newlySeen.length === 0) return;
setExpandedPaths((prev) => {
const next = new Set(prev);
newlySeen.forEach((p) => next.add(p));
return next;
});
}, [normalizedPackages]);
Array.from(new Set(relativeRoots)).forEach((name: string) => {
const path: string = name;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
results.push({ name, path, count });
});
const togglePackage = useCallback((path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
}, []);
const absoluteRoots = absolutePaths
.map((p) => {
const cleanPath = p.substring(1);
return cleanPath.split('/')[0];
// When search is active, flatten everything (no tree, no packages).
const searchMatches = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return null;
return snippets.filter(
(s) =>
s.label.toLowerCase().includes(q) ||
s.command.toLowerCase().includes(q),
);
}, [snippets, search]);
const rows = useMemo<TreeRow[]>(() => {
if (searchMatches !== null) return [];
const out: TreeRow[] = [];
const paths: string[] = [];
normalizedPackages.forEach((p) => paths.push(p));
const childPackagesOf = (parent: string | null): string[] => {
const prefix = parent === null ? '' : parent + '/';
return paths
.filter((p) => {
if (parent === null) {
// Root-level: no "/" inside the body
const body = p.startsWith('/') ? p.slice(1) : p;
return !body.includes('/');
}
if (!p.startsWith(prefix)) return false;
const rest = p.slice(prefix.length);
return rest.length > 0 && !rest.includes('/');
})
.filter((name): name is string => Boolean(name) && name.length > 0);
.sort((a, b) => pkgDisplayName(a).localeCompare(pkgDisplayName(b)));
};
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
const path: string = `/${name}`;
const displayName: string = `/${name}`;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
results.push({ name: displayName, path, count });
const snippetsIn = (pkg: string | null): Snippet[] =>
snippets
.filter((s) => (s.package || '') === (pkg ?? ''))
.sort((a, b) => a.label.localeCompare(b.label));
const countDescendants = (pkg: string): number =>
snippets.filter((s) => {
const sp = s.package || '';
return sp === pkg || sp.startsWith(pkg + '/');
}).length;
const walk = (pkg: string, depth: number) => {
const children = childPackagesOf(pkg);
const localSnippets = snippetsIn(pkg);
const hasChildren = children.length > 0 || localSnippets.length > 0;
const isExpanded = expandedPaths.has(pkg);
out.push({
type: 'package',
id: pkg,
path: pkg,
name: pkgDisplayName(pkg),
depth,
count: countDescendants(pkg),
hasChildren,
isExpanded,
});
return results;
}
const prefix = selectedPackage + '/';
const children = packages
.filter((p) => p.startsWith(prefix))
.map((p) => p.replace(prefix, '').split('/')[0])
.filter((name): name is string => Boolean(name) && name.length > 0);
return Array.from(new Set(children)).map((name) => {
const path = `${selectedPackage}/${name}`;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
return { name, path, count };
});
}, [packages, selectedPackage, snippets]);
const displayedSnippets = useMemo(() => {
let result = snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
if (search.trim()) {
const s = search.toLowerCase();
result = result.filter(sn =>
sn.label.toLowerCase().includes(s) ||
sn.command.toLowerCase().includes(s)
if (!isExpanded) return;
children.forEach((c) => walk(c, depth + 1));
localSnippets.forEach((s) =>
out.push({ type: 'snippet', id: s.id, depth: depth + 1, snippet: s, packagePath: pkg }),
);
}
return result;
}, [snippets, selectedPackage, search]);
};
// Also filter packages by search when at root level
const filteredPackages = useMemo(() => {
if (!search.trim()) return displayedPackages;
const s = search.toLowerCase();
return displayedPackages.filter(pkg => pkg.name.toLowerCase().includes(s));
}, [displayedPackages, search]);
// Orphan / uncategorized snippets first (package === '')
snippetsIn(null).forEach((s) =>
out.push({ type: 'snippet', id: s.id, depth: 0, snippet: s, packagePath: '' }),
);
childPackagesOf(null).forEach((root) => walk(root, 0));
const breadcrumb = useMemo(() => {
if (!selectedPackage) return [];
const isAbsolute = selectedPackage.startsWith('/');
const parts = selectedPackage.split('/').filter(Boolean);
return parts.map((name, idx) => {
const pathSegments = parts.slice(0, idx + 1);
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
return { name, path };
});
}, [selectedPackage]);
return out;
}, [normalizedPackages, snippets, expandedPaths, searchMatches]);
const handleSnippetClick = useCallback((command: string, noAutoRun?: boolean) => {
onSnippetClick(command, noAutoRun);
}, [onSnippetClick]);
const handleSnippetClick = useCallback(
(command: string, noAutoRun?: boolean) => {
onSnippetClick(command, noAutoRun);
},
[onSnippetClick],
);
const handleAddSnippet = useCallback(() => {
// Let the App shell listen and navigate to the Snippets section with
@@ -149,6 +233,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
const hasAnyContent = snippets.length > 0 || packages.length > 0;
return (
<TooltipProvider delayDuration={300}>
<div
className="h-full flex flex-col bg-background overflow-hidden"
data-section="snippets-panel"
@@ -175,30 +260,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
</button>
</div>
{/* Breadcrumb */}
<div className="shrink-0 flex items-center gap-1 px-3 py-1.5 text-[11px] border-b border-border/30 min-h-[28px]">
<button
className={cn(
"hover:text-primary transition-colors truncate",
!selectedPackage ? "text-foreground font-medium" : "text-muted-foreground"
)}
onClick={() => setSelectedPackage(null)}
>
{t('terminal.toolbar.library')}
</button>
{breadcrumb.map((b) => (
<React.Fragment key={b.path}>
<ChevronRight size={10} className="text-muted-foreground shrink-0" />
<button
className="text-muted-foreground hover:text-primary transition-colors truncate"
onClick={() => setSelectedPackage(b.path)}
>
{b.name}
</button>
</React.Fragment>
))}
</div>
{/* Content */}
<ScrollArea className="flex-1">
<div className="py-1">
@@ -209,55 +270,47 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
</div>
)}
{/* Packages */}
{filteredPackages.map((pkg) => (
<button
key={pkg.path}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
onClick={() => { setSelectedPackage(pkg.path); setSearch(''); }}
>
<div className="w-6 h-6 rounded-md bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Package size={12} />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{pkg.name}</div>
<div className="text-[10px] text-muted-foreground">
{t('snippets.package.count', { count: pkg.count })}
</div>
</div>
<ChevronRight size={12} className="text-muted-foreground shrink-0" />
</button>
))}
{/* Search flat list */}
{searchMatches !== null && searchMatches.length > 0 &&
searchMatches.map((s) => (
<SnippetRow
key={s.id}
snippet={s}
depth={0}
subtitle={s.package || t('terminal.toolbar.library')}
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
onEdit={() => handleEditSnippet(s)}
onDelete={() => handleDeleteSnippet(s.id)}
editLabel={t('action.edit')}
deleteLabel={t('action.delete')}
/>
))}
{/* Snippets */}
{displayedSnippets.map((s) => (
<ContextMenu key={s.id}>
<ContextMenuTrigger asChild>
<button
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors flex flex-col gap-0.5"
>
<span className="text-xs font-medium truncate">{s.label}</span>
<span className="text-muted-foreground truncate font-mono text-[10px] max-w-full">
{s.command}
</span>
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => handleEditSnippet(s)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => handleDeleteSnippet(s.id)}
>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
{/* Tree */}
{searchMatches === null &&
rows.map((row) =>
row.type === 'package' ? (
<PackageRow
key={`pkg:${row.id}`}
row={row}
countLabel={t('snippets.package.count', { count: row.count })}
onToggle={() => togglePackage(row.path)}
/>
) : (
<SnippetRow
key={`snip:${row.id}`}
snippet={row.snippet}
depth={row.depth}
onClick={() => handleSnippetClick(row.snippet.command, row.snippet.noAutoRun)}
onEdit={() => handleEditSnippet(row.snippet)}
onDelete={() => handleDeleteSnippet(row.snippet.id)}
editLabel={t('action.edit')}
deleteLabel={t('action.delete')}
/>
),
)}
{hasAnyContent && displayedSnippets.length === 0 && filteredPackages.length === 0 && search.trim() && (
{hasAnyContent && searchMatches !== null && searchMatches.length === 0 && (
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
{t('common.noResultsFound')}
</div>
@@ -265,8 +318,100 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
</div>
</ScrollArea>
</div>
</TooltipProvider>
);
};
interface PackageRowProps {
row: Extract<TreeRow, { type: 'package' }>;
countLabel: string;
onToggle: () => void;
}
const PackageRow: React.FC<PackageRowProps> = ({ row, countLabel, onToggle }) => (
<button
type="button"
onClick={onToggle}
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors"
style={{ paddingLeft: 8 + row.depth * 14 }}
>
<ChevronRight
size={12}
className={cn(
'shrink-0 text-muted-foreground transition-transform',
row.isExpanded && 'rotate-90',
!row.hasChildren && 'opacity-0',
)}
/>
<Package size={12} className="shrink-0 text-primary/80" />
<span className="flex-1 min-w-0 truncate text-xs font-medium">{row.name}</span>
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">{countLabel}</span>
</button>
);
interface SnippetRowProps {
snippet: Snippet;
depth: number;
subtitle?: string;
onClick: () => void;
onEdit: () => void;
onDelete: () => void;
editLabel: string;
deleteLabel: string;
}
const SnippetRow: React.FC<SnippetRowProps> = ({
snippet,
depth,
subtitle,
onClick,
onEdit,
onDelete,
editLabel,
deleteLabel,
}) => (
<ContextMenu>
<ContextMenuTrigger asChild>
<div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors overflow-hidden"
style={{ paddingLeft: 8 + depth * 14 }}
>
{/* Hidden chevron column mirrors PackageRow's layout so the
snippet icon lines up exactly with the package icon above. */}
<ChevronRight size={12} className="shrink-0 opacity-0" aria-hidden />
<FileCode size={12} className="shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 truncate text-xs font-medium">{snippet.label}</span>
{subtitle && (
<span className="shrink-0 max-w-[40%] truncate text-[10px] text-muted-foreground">
{subtitle}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" align="start" className="max-w-[480px]">
<div className="font-medium text-xs mb-1 break-all">{snippet.label}</div>
<pre className="font-mono text-[11px] whitespace-pre-wrap break-all leading-snug opacity-90">
{snippet.command}
</pre>
</TooltipContent>
</Tooltip>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onEdit}>
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={onDelete}>
<Trash2 className="mr-2 h-4 w-4" /> {deleteLabel}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
ScriptsSidePanel.displayName = 'ScriptsSidePanel';

View File

@@ -14,6 +14,8 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
import { formatHostPort } from "../domain/host";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
import { editorTabStore } from "../application/state/editorTabStore";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { getParentPath } from "../application/state/sftp/utils";
@@ -125,6 +127,46 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
// Register this instance's writeTextFileByConnection with the editor bridge
// so editor tabs promoted from SFTP files opened in a terminal side panel
// can still route saves through this useSftpState.
//
// Intentionally no deps — go through sftpRef so SFTP state churn (transfers,
// tab switches, listings) doesn't make this unregister+reregister on every
// re-render.
useEffect(() => {
return registerEditorSftpWriterScoped((connectionId, expectedHostId, filePath, content, encoding) =>
sftpRef.current.writeTextFileByConnection(connectionId, expectedHostId, filePath, content, encoding),
);
}, []);
// When this side panel unmounts (its hosting terminal tab was closed) we
// force-close any editor tabs bound to connections this panel owned — the
// save channel is gone with the SFTP session and there's no way to recover
// it. Dirty state is dropped intentionally; the user closed the terminal
// knowing the file was open.
//
// Collect every connection id across all left/right tabs — the panel can
// host multiple SFTP tabs per side, and an editor tab promoted from an
// inactive-pane tab would otherwise be stranded by the unmount.
useEffect(() => {
return () => {
const s = sftpRef.current;
if (!s) return;
const owned = new Set<string>();
for (const tab of s.leftTabs?.tabs ?? []) {
const id = tab.connection?.id;
if (id) owned.add(id);
}
for (const tab of s.rightTabs?.tabs ?? []) {
const id = tab.connection?.id;
if (id) owned.add(id);
}
if (owned.size === 0) return;
editorTabStore.forceCloseBySessions([...owned]);
};
}, []);
const behaviorRef = useRef(sftpDoubleClickBehavior);
behaviorRef.current = sftpDoubleClickBehavior;
@@ -224,6 +266,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
fileOpenerTarget,
setFileOpenerTarget,
handleSaveTextFile,
onPromoteToTab,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpViewPaneCallbacks({
@@ -679,6 +722,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
setFileOpenerTarget={setFileOpenerTarget}
handleFileOpenerSelect={handleFileOpenerSelect}
handleSelectSystemApp={handleSelectSystemApp}
onPromoteToTab={onPromoteToTab}
t={t}
/>
)}

View File

@@ -14,7 +14,7 @@
* - components/sftp/SftpHostPicker.tsx - Host selection dialog
*/
import React, { memo, useCallback, useLayoutEffect, useMemo, useRef } from "react";
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useIsSftpActive } from "../application/state/activeTabStore";
import { useSftpState } from "../application/state/useSftpState";
@@ -27,6 +27,7 @@ import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import { Host, Identity, SSHKey } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
import { toast } from "./ui/toast";
// Import extracted components
@@ -135,6 +136,23 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
// Register this useSftpState's writeTextFileByConnection with the bridge so
// the editor tab's save path can reach the active SFTP session. The bridge
// supports multiple simultaneous writers (SftpSidePanel inside terminals
// also registers its own instance) and dispatches by trying each until one
// owns the target connectionId.
//
// Intentionally no deps: `sftp` identity churns on every SFTP state change
// (transfers, pane updates, tab switches), which would make this effect
// unregister+reregister constantly. Route through sftpRef so the closure
// always reads the latest writeTextFileByConnection; that method is stable
// across sftp re-renders (it's a methodsRef-backed dispatcher).
useEffect(() => {
return registerEditorSftpWriterScoped((connectionId, expectedHostId, filePath, content, encoding) =>
sftpRef.current.writeTextFileByConnection(connectionId, expectedHostId, filePath, content, encoding),
);
}, []);
// Store behavior setting in ref for stable callbacks
const behaviorRef = useRef(sftpDoubleClickBehavior);
behaviorRef.current = sftpDoubleClickBehavior;
@@ -219,6 +237,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
fileOpenerTarget,
setFileOpenerTarget,
handleSaveTextFile,
onPromoteToTab,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpViewPaneCallbacks({
@@ -475,6 +494,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
setFileOpenerTarget={setFileOpenerTarget}
handleFileOpenerSelect={handleFileOpenerSelect}
handleSelectSystemApp={handleSelectSystemApp}
onPromoteToTab={onPromoteToTab}
t={t}
/>
</div>

View File

@@ -374,6 +374,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
const terminalEncodingRef = useRef(terminalEncoding);
terminalEncodingRef.current = terminalEncoding;
// True only after the user actively picks an encoding from the toolbar.
// onSessionAttached uses this to decide whether to override the backend's
// initial charset for telnet/serial reconnects — on a first attach we
// must not overwrite arbitrary host.charset values (latin1/shift_jis/...)
// that the UI's two-value state can't represent.
const userPickedEncodingRef = useRef(false);
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
const {
@@ -740,10 +746,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setChainProgress,
t,
onSessionAttached: (id: string) => {
// Sync terminal encoding to SSH backend before first data arrives
const isSSH = host.protocol !== 'local' && host.protocol !== 'serial' && host.protocol !== 'telnet' && host.protocol !== 'mosh' && !host.moshEnabled && !host.id?.startsWith('local-') && !host.id?.startsWith('serial-') && host.hostname !== 'localhost';
// SSH: always sync. Its backend starts in utf-8 regardless of
// host.charset, so the push is what keeps the UI state aligned
// across reconnects — including localhost SSH targets, hence
// hostname isn't in the gate.
const isLocal = host.protocol === 'local' || host.id?.startsWith('local-');
const isSerial = host.protocol === 'serial' || host.id?.startsWith('serial-');
const isTelnet = host.protocol === 'telnet';
const isMosh = host.protocol === 'mosh' || host.moshEnabled;
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh;
if (isSSH) {
setSessionEncoding(id, terminalEncodingRef.current);
return;
}
// Telnet / serial: the backend already applied host.charset
// (including arbitrary iconv labels like latin1 / shift_jis that
// the UI's two-value state can't represent) through start*Session
// options, so don't clobber it on first attach. Only re-sync once
// the user has explicitly picked from the toolbar menu — that's
// the signal they want the UI choice to win on reconnect.
if ((isTelnet || isSerial) && userPickedEncodingRef.current) {
setSessionEncoding(id, terminalEncodingRef.current);
}
},
onSessionExit,
@@ -1387,6 +1410,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
setTerminalEncoding(encoding);
userPickedEncodingRef.current = true;
if (sessionRef.current) {
setSessionEncoding(sessionRef.current, encoding);
}

View File

@@ -1,31 +1,32 @@
/**
* TextEditorModal - Modal for editing text files in SFTP with syntax highlighting
* TextEditorModal - Dialog shell for editing text files in SFTP.
* Delegates all editor chrome to TextEditorPane.
*/
import {
CloudUpload,
Loader2,
Search,
WrapText,
X,
} from 'lucide-react';
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
import type * as Monaco from 'monaco-editor';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
// Configure Monaco to use local files instead of CDN
const monacoBasePath = import.meta.env.DEV
? './node_modules/monaco-editor/min/vs'
: `${import.meta.env.BASE_URL}monaco/vs`;
loader.config({ paths: { vs: monacoBasePath } });
import { useI18n } from '../application/i18n/I18nProvider';
import { useClipboardBackend } from '../application/state/useClipboardBackend';
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../domain/models';
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Combobox } from './ui/combobox';
import { getLanguageId } from '../lib/sftpFileUtils';
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
import { toast } from './ui/toast';
import { TextEditorPane } from './editor/TextEditorPane';
import { useI18n } from '../application/i18n/I18nProvider';
import type { HotkeyScheme, KeyBinding } from '../domain/models';
/** Snapshot passed to `onPromoteToTab` when the user clicks the maximize button. */
export interface TextEditorModalSnapshot {
/** The file name at the time of promotion (modal's fileName prop). */
fileName: string;
/** The clean baseline content at the time the modal was opened. */
baselineContent: string;
/** The current (possibly-dirty) editor content. */
content: string;
/** The current language ID selected by the user (may differ from file-detected default). */
languageId: string;
/** The current word-wrap state (carried over so the tab opens with the same setting). */
wordWrap: boolean;
/** The latest Monaco view state (scroll position, cursor, etc.) — may be null before first edit. */
viewState: Monaco.editor.ICodeEditorViewState | null;
}
interface TextEditorModalProps {
open: boolean;
@@ -37,128 +38,10 @@ interface TextEditorModalProps {
onToggleWordWrap: () => void;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
/** If provided, a maximize button is shown in the Pane header. */
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
}
// Map our language IDs to Monaco language IDs
const languageIdToMonaco = (langId: string): string => {
const mapping: Record<string, string> = {
'javascript': 'javascript',
'typescript': 'typescript',
'python': 'python',
'shell': 'shell',
'batch': 'bat',
'powershell': 'powershell',
'c': 'c',
'cpp': 'cpp',
'java': 'java',
'kotlin': 'kotlin',
'go': 'go',
'rust': 'rust',
'ruby': 'ruby',
'php': 'php',
'perl': 'perl',
'lua': 'lua',
'r': 'r',
'swift': 'swift',
'dart': 'dart',
'csharp': 'csharp',
'fsharp': 'fsharp',
'vb': 'vb',
'html': 'html',
'css': 'css',
'scss': 'scss',
'sass': 'sass',
'less': 'less',
'json': 'json',
'jsonc': 'json',
'json5': 'json',
'xml': 'xml',
'yaml': 'yaml',
'toml': 'ini',
'ini': 'ini',
'sql': 'sql',
'graphql': 'graphql',
'markdown': 'markdown',
'plaintext': 'plaintext',
'vue': 'html',
'svelte': 'html',
'dockerfile': 'dockerfile',
'makefile': 'makefile',
'diff': 'diff',
};
return mapping[langId] || 'plaintext';
};
// Convert HSL string "h s% l%" to hex color
const hslToHex = (hslString: string): string => {
const parts = hslString.trim().split(/\s+/);
if (parts.length < 3) return '#1e1e1e';
const h = parseFloat(parts[0]) / 360;
const s = parseFloat(parts[1].replace('%', '')) / 100;
const l = parseFloat(parts[2].replace('%', '')) / 100;
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
let r: number, g: number, b: number;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
const toHex = (x: number) => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
// Read a CSS custom-property and convert from HSL to hex
const getCssColor = (varName: string, fallback: string): string => {
const value = getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
return value ? hslToHex(value) : fallback;
};
interface EditorColors {
bg: string;
fg: string;
primary: string;
card: string;
mutedFg: string;
border: string;
}
/** Read all UI CSS variables that matter for the Monaco theme. */
const getEditorColors = (isDark: boolean): EditorColors => ({
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
});
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
const getThemeSignal = (): string => {
const root = document.documentElement;
return root.dataset.immersiveTheme
?? getComputedStyle(root).getPropertyValue('--background').trim();
};
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
open,
onClose,
@@ -169,182 +52,45 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
onToggleWordWrap,
hotkeyScheme,
keyBindings,
onPromoteToTab,
}) => {
const { t } = useI18n();
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
const monaco = useMonaco();
const [content, setContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
// Latest view state captured from Pane's onContentChange — used by handlePromote
const viewStateRef = useRef<Monaco.editor.ICodeEditorViewState | null>(null);
// Track theme from document.documentElement class (syncs with app theme)
const [isDarkTheme, setIsDarkTheme] = useState(() =>
document.documentElement.classList.contains('dark')
);
// Derived: whether the current content differs from the clean baseline
const hasChanges = content !== initialContent;
// Track a signal that changes whenever immersive-mode or base theme colors change
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
// Custom theme name
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
useEffect(() => {
if (!monaco) return;
const colors = getEditorColors(isDarkTheme);
const themeColors: Record<string, string> = {
'editor.background': colors.bg,
'editor.foreground': colors.fg,
'editorCursor.foreground': colors.primary,
'editor.selectionBackground': colors.primary + '40',
'editor.inactiveSelectionBackground': colors.primary + '25',
'editorLineNumber.foreground': colors.mutedFg,
'editorLineNumber.activeForeground': colors.fg,
'editor.lineHighlightBackground': colors.fg + '08',
'editorWidget.background': colors.card,
'editorWidget.foreground': colors.fg,
'editorWidget.border': colors.border,
'input.background': colors.card,
'input.foreground': colors.fg,
'input.border': colors.border,
};
monaco.editor.defineTheme('netcatty-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: themeColors,
});
monaco.editor.defineTheme('netcatty-light', {
base: 'vs',
inherit: true,
rules: [],
colors: themeColors,
});
monaco.editor.setTheme(customThemeName);
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
useEffect(() => {
const root = document.documentElement;
const updateTheme = () => {
setIsDarkTheme(root.classList.contains('dark'));
setThemeSignal(getThemeSignal());
};
const observer = new MutationObserver(updateTheme);
observer.observe(root, {
attributes: true,
attributeFilter: ['class', 'style', 'data-immersive-theme'],
});
return () => observer.disconnect();
}, []);
// Reset content when file changes
// Reset all state when a new file is opened
useEffect(() => {
setContent(initialContent);
setHasChanges(false);
setSaveError(null);
setLanguageId(getLanguageId(fileName));
viewStateRef.current = null;
}, [initialContent, fileName]);
// Track changes
useEffect(() => {
setHasChanges(content !== initialContent);
}, [content, initialContent]);
const closeTabBinding = useMemo(
() => keyBindings.find((binding) => binding.action === 'closeTab'),
[keyBindings],
);
const handleSave = useCallback(async () => {
if (saving) return;
setSaving(true);
setSaveError(null);
try {
await onSave(content);
setHasChanges(false);
toast.success(t('sftp.editor.saved'), 'SFTP');
} catch (e) {
toast.error(
e instanceof Error ? e.message : t('sftp.editor.saveFailed'),
'SFTP'
);
const msg = e instanceof Error ? e.message : t('sftp.editor.saveFailed');
setSaveError(msg);
toast.error(msg, 'SFTP');
} finally {
setSaving(false);
}
}, [content, onSave, saving, t]);
// Keep the ref updated with the latest handleSave function
useEffect(() => {
handleSaveRef.current = handleSave;
}, [handleSave]);
const readClipboardText = useCallback(async (): Promise<string | null> => {
try {
if (navigator.clipboard?.readText) {
return await navigator.clipboard.readText();
}
} catch {
// Fall through to Electron bridge
}
try {
return await readClipboardTextFromBridge();
} catch {
// Both clipboard APIs unavailable; signal failure so caller can fall back.
return null;
}
}, [readClipboardTextFromBridge]);
useEffect(() => {
readClipboardTextRef.current = readClipboardText;
}, [readClipboardText]);
const handlePaste = useCallback(async () => {
const editor = editorRef.current;
if (!editor) return;
const text = await readClipboardText();
if (text === null) {
// Clipboard read unavailable; fall back to Monaco's native paste.
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
return;
}
if (!text) return;
const selections = editor.getSelections();
if (!selections || selections.length === 0) return;
// Match Monaco's default multicursorPaste:'spread' behavior:
// distribute one line per cursor when line count equals cursor count.
const lines = text.split(/\r\n|\n/);
const distribute = selections.length > 1 && lines.length === selections.length;
editor.executeEdits(
'netcatty-paste',
selections.map((selection, i) => ({
range: selection,
text: distribute ? lines[i] : text,
forceMoveMarkers: true,
})),
);
editor.focus();
}, [readClipboardText]);
useEffect(() => {
handlePasteRef.current = handlePaste;
}, [handlePaste]);
const handleClose = useCallback(() => {
if (hasChanges) {
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
@@ -353,222 +99,53 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
onClose();
}, [hasChanges, onClose, t]);
const handleEditorChange = useCallback((value: string | undefined) => {
setContent(value || '');
}, []);
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
editorRef.current = editor;
// Add save shortcut - use ref to avoid stale closure
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSaveRef.current();
});
// Add find shortcut (Ctrl+F / Cmd+F)
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
// Trigger Monaco's built-in find widget
editor.trigger('keyboard', 'actions.find', null);
});
// Fallback paste path for Electron environments where Monaco paste can fail.
// Skip custom paste when focus is inside the find/replace widget so that
// its input fields receive the pasted text via default browser behavior.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
const active = document.activeElement;
if (active?.closest('.find-widget')) {
// Read clipboard and insert into the find/replace input field.
void (async () => {
try {
const text = await readClipboardTextRef.current();
if (!text) return;
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
const start = active.selectionStart ?? active.value.length;
const end = active.selectionEnd ?? active.value.length;
active.focus();
active.setSelectionRange(start, end);
document.execCommand('insertText', false, text);
}
} catch {
// Ignore paste simply won't work
}
})();
return;
}
void handlePasteRef.current();
});
editor.focus();
}, []);
useEffect(() => {
if (!open) return;
const frame = window.requestAnimationFrame(() => {
editorRef.current?.focus();
});
return () => window.cancelAnimationFrame(frame);
}, [open]);
const handleDialogKeyDownCapture = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (hotkeyScheme === 'disabled' || !closeTabBinding) return;
const isMac = hotkeyScheme === 'mac';
const keyStr = isMac ? closeTabBinding.mac : closeTabBinding.pc;
if (!matchesKeyBinding(e.nativeEvent, keyStr, isMac)) return;
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopPropagation();
handleClose();
}, [closeTabBinding, handleClose, hotkeyScheme]);
// Trigger search dialog
const handleSearch = useCallback(() => {
if (editorRef.current) {
editorRef.current.trigger('keyboard', 'actions.find', null);
editorRef.current.focus();
}
}, []);
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
const languageOptions = useMemo(
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
[supportedLanguages],
const handleContentChange = useCallback(
(nextContent: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
setContent(nextContent);
viewStateRef.current = viewState;
},
[],
);
const handleLanguageChange = useCallback((nextValue: string) => {
setLanguageId(nextValue || 'plaintext');
}, []);
const handlePromote = useCallback(() => {
if (!onPromoteToTab) return;
onPromoteToTab({
fileName,
baselineContent: initialContent,
content,
languageId,
wordWrap: editorWordWrap,
viewState: viewStateRef.current,
});
}, [onPromoteToTab, fileName, initialContent, content, languageId, editorWordWrap]);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent
className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0"
hideCloseButton
data-hotkey-close-tab="true"
onKeyDownCapture={handleDialogKeyDownCapture}
>
{/* Header */}
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<DialogTitle className="text-sm font-semibold truncate">
{fileName}
{hasChanges && <span className="text-primary ml-1">*</span>}
</DialogTitle>
</div>
<div className="flex items-center gap-2 min-w-0">
{/* Search button */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSearch}
title={t('common.search')}
>
<Search size={14} />
</Button>
{/* Word wrap toggle */}
<Button
variant={editorWordWrap ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7"
onClick={onToggleWordWrap}
title={t('sftp.editor.wordWrap')}
>
<WrapText size={14} />
</Button>
{/* Language selector */}
<Combobox
options={languageOptions}
value={languageId}
onValueChange={handleLanguageChange}
placeholder={t('sftp.editor.syntaxHighlight')}
triggerClassName="h-7 max-w-[180px] min-w-[120px] text-xs"
/>
{/* Save button */}
<Button
variant="default"
size="sm"
className="h-7"
onClick={handleSave}
disabled={saving || !hasChanges}
>
{saving ? (
<Loader2 size={14} className="mr-1.5 animate-spin" />
) : (
<CloudUpload size={14} className="mr-1.5" />
)}
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
</Button>
{/* Close button */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleClose}
>
<X size={14} />
</Button>
</div>
</div>
</DialogHeader>
{/* Monaco Editor */}
<div className="flex-1 min-h-0 relative">
<Editor
height="100%"
language={monacoLanguage}
value={content}
onChange={handleEditorChange}
onMount={handleEditorMount}
theme={customThemeName}
loading={
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Loader2 size={32} className="animate-spin text-muted-foreground" />
</div>
}
options={{
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
contextmenu: false,
minimap: { enabled: true },
fontSize: 14,
lineNumbers: 'on',
roundedSelection: false,
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
insertSpaces: true,
wordWrap: editorWordWrap ? 'on' : 'off',
folding: true,
renderWhitespace: 'selection',
bracketPairColorization: { enabled: true },
find: {
addExtraSpaceOnTop: false,
autoFindInSelection: 'never',
seedSearchStringFromSelection: 'selection',
},
}}
/>
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
<span>
{getLanguageName(languageId)}
</span>
<span>
{content.split('\n').length} lines {content.length} characters
</span>
</div>
{/* Radix requires a DialogTitle inside every DialogContent for a11y.
The Pane's own header already shows the filename visually, so we
mirror it here inside an sr-only DialogTitle for screen readers. */}
<DialogTitle className="sr-only">{fileName}</DialogTitle>
<TextEditorPane
chrome="modal"
fileName={`${fileName}${hasChanges ? ' *' : ''}`}
content={content}
languageId={languageId}
wordWrap={editorWordWrap}
saving={saving}
saveError={saveError}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onContentChange={handleContentChange}
onLanguageChange={setLanguageId}
onToggleWordWrap={onToggleWordWrap}
onSave={handleSave}
onRequestClose={handleClose}
onPromoteToTab={onPromoteToTab ? handlePromote : undefined}
/>
</DialogContent>
</Dialog>
);

View File

@@ -1,6 +1,7 @@
import { Bell, Copy, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
import { Bell, Copy, FileCode, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
import { activeTabStore, fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
import type { EditorTab } from '../application/state/editorTabStore';
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
import { LogView } from '../application/state/useSessionState';
@@ -19,6 +20,9 @@ import { SyncStatusButton } from './SyncStatusButton';
const dragRegionStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties;
const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as React.CSSProperties;
// File extensions that render the code-file icon instead of the plain text icon.
const CODE_EXTENSIONS_RE = /\.(js|jsx|ts|tsx|py|rb|go|rs|c|cpp|cs|java|php|sh|bash|zsh|fish|lua|r|scala|swift|kt|html|css|scss|less|json|yaml|yml|toml|xml|sql|graphql|gql|md|mdx|conf|ini|env|tf|hcl|dockerfile)$/i;
interface TopTabsProps {
theme: 'dark' | 'light';
followAppTerminalTheme?: boolean;
@@ -46,6 +50,9 @@ interface TopTabsProps {
onEndSessionDrag: () => void;
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
showSftpTab: boolean;
editorTabs: readonly EditorTab[];
onRequestCloseEditorTab: (editorTabId: string) => void;
hostById: Map<string, Host>;
}
// Detect local OS for local terminal tab icons
@@ -255,6 +262,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onEndSessionDrag,
onReorderTabs,
showSftpTab,
editorTabs,
onRequestCloseEditorTab,
hostById,
}) => {
const { t } = useI18n();
// Subscribe to activeTabId from external store
@@ -477,9 +487,30 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return styles;
}, [dropIndicator, isDraggingForReorder, orderedTabs]);
// Pre-compute editor tab map for O(1) access
const editorTabMap = useMemo(() => {
const map = new Map<string, EditorTab>();
for (const t of editorTabs) map.set(t.id, t);
return map;
}, [editorTabs]);
// fileName → count, for the rename-disambiguation suffix in the render loop.
// Memoed so we don't do a per-tab O(n) filter on every render (was O(n²)).
const editorTabFileNameCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const t of editorTabs) counts.set(t.fileName, (counts.get(t.fileName) ?? 0) + 1);
return counts;
}, [editorTabs]);
// Build ordered tab items using pre-computed maps for O(1) lookups
const orderedTabItems = useMemo(() => {
return orderedTabs.map((tabId) => {
if (isEditorTabId(tabId)) {
const editorId = fromEditorTabId(tabId);
const editorTab = editorTabMap.get(editorId);
if (!editorTab) return null;
return { type: 'editor' as const, id: tabId, editorTab };
}
const session = orphanSessionMap.get(tabId);
const workspace = workspaceMap.get(tabId);
const logView = logViewMap.get(tabId);
@@ -494,7 +525,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
}
return null;
}).filter(Boolean);
}, [orderedTabs, orphanSessionMap, workspaceMap, logViewMap, workspacePaneCounts]);
}, [orderedTabs, editorTabMap, orphanSessionMap, workspaceMap, logViewMap, workspacePaneCounts]);
// Bulk-close menu items shared by session and workspace context menus.
// Anchor is the tab the user right-clicked on (matches VSCode/JetBrains UX).
@@ -532,6 +563,77 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return orderedTabItems.map((item) => {
if (!item) return null;
if (item.type === 'editor') {
const { editorTab } = item;
const tabId = item.id;
const isActive = activeTabId === tabId;
const host = hostById.get(editorTab.hostId);
const dirty = editorTab.content !== editorTab.baselineContent;
const tooltip = `${host?.label ?? editorTab.hostId}@${host?.hostname ?? ''}:${editorTab.remotePath}`;
// Disambiguate duplicate filenames using the memoed counts map.
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
: '';
const FileIcon = CODE_EXTENSIONS_RE.test(editorTab.fileName) ? FileCode : FileText;
return (
<div
key={tabId}
data-tab-id={tabId}
data-tab-type="editor"
data-state={isActive ? 'active' : 'inactive'}
onClick={() => onSelectTab(tabId)}
title={tooltip}
className={cn(
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
)}
style={{
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileIcon
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<span className="truncate flex items-center gap-0.5">
{dirty && <span className="text-primary mr-0.5"></span>}
{editorTab.fileName}
{suffix && <span className="text-muted-foreground ml-1">{suffix}</span>}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onRequestCloseEditorTab(editorTab.id);
}}
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
aria-label="Close editor tab"
>
<X size={12} />
</button>
</div>
);
}
if (item.type === 'session') {
const session = item.session;
const hasActivity = !!sessionActivityMap[session.id];

View File

@@ -0,0 +1,584 @@
/**
* TextEditorPane — pure Monaco editor body + toolbar.
* Extracted from TextEditorModal.tsx. Contains no Dialog shell.
* Parents (modal or tab) own content state, saving state, and toast calls.
*/
import {
CloudUpload,
Loader2,
Maximize2,
Search,
WrapText,
X,
} from 'lucide-react';
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
import type * as Monaco from 'monaco-editor';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// Configure Monaco to use local files instead of CDN
const monacoBasePath = import.meta.env.DEV
? './node_modules/monaco-editor/min/vs'
: `${import.meta.env.BASE_URL}monaco/vs`;
loader.config({ paths: { vs: monacoBasePath } });
import { useI18n } from '../../application/i18n/I18nProvider';
import { useClipboardBackend } from '../../application/state/useClipboardBackend';
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models';
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
import { Button } from '../ui/button';
import { Combobox } from '../ui/combobox';
// Map our language IDs to Monaco language IDs
const languageIdToMonaco = (langId: string): string => {
const mapping: Record<string, string> = {
'javascript': 'javascript',
'typescript': 'typescript',
'python': 'python',
'shell': 'shell',
'batch': 'bat',
'powershell': 'powershell',
'c': 'c',
'cpp': 'cpp',
'java': 'java',
'kotlin': 'kotlin',
'go': 'go',
'rust': 'rust',
'ruby': 'ruby',
'php': 'php',
'perl': 'perl',
'lua': 'lua',
'r': 'r',
'swift': 'swift',
'dart': 'dart',
'csharp': 'csharp',
'fsharp': 'fsharp',
'vb': 'vb',
'html': 'html',
'css': 'css',
'scss': 'scss',
'sass': 'sass',
'less': 'less',
'json': 'json',
'jsonc': 'json',
'json5': 'json',
'xml': 'xml',
'yaml': 'yaml',
'toml': 'ini',
'ini': 'ini',
'sql': 'sql',
'graphql': 'graphql',
'markdown': 'markdown',
'plaintext': 'plaintext',
'vue': 'html',
'svelte': 'html',
'dockerfile': 'dockerfile',
'makefile': 'makefile',
'diff': 'diff',
};
return mapping[langId] || 'plaintext';
};
// Convert HSL string "h s% l%" to hex color
const hslToHex = (hslString: string): string => {
const parts = hslString.trim().split(/\s+/);
if (parts.length < 3) return '#1e1e1e';
const h = parseFloat(parts[0]) / 360;
const s = parseFloat(parts[1].replace('%', '')) / 100;
const l = parseFloat(parts[2].replace('%', '')) / 100;
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
let r: number, g: number, b: number;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
const toHex = (x: number) => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
// Read a CSS custom-property and convert from HSL to hex
const getCssColor = (varName: string, fallback: string): string => {
const value = getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
return value ? hslToHex(value) : fallback;
};
interface EditorColors {
bg: string;
fg: string;
primary: string;
card: string;
mutedFg: string;
border: string;
}
/** Read all UI CSS variables that matter for the Monaco theme. */
const getEditorColors = (isDark: boolean): EditorColors => ({
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
});
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
const getThemeSignal = (): string => {
const root = document.documentElement;
return root.dataset.immersiveTheme
?? getComputedStyle(root).getPropertyValue('--background').trim();
};
export interface TextEditorPaneProps {
fileName: string;
content: string;
languageId: string;
wordWrap: boolean;
saving: boolean;
saveError: string | null;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
/** Layout mode — affects header chrome (modal shows close+maximize; tab-form only shows content controls since tab has its own close). */
chrome: 'modal' | 'tab';
/** Optional secondary label shown next to the filename in muted text — used by the tab form to display `host:remotePath`. */
subtitle?: string;
onContentChange: (content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => void;
onLanguageChange: (nextLanguageId: string) => void;
onToggleWordWrap: () => void;
onSave: () => void;
onRequestClose?: () => void; // modal only
onPromoteToTab?: () => void; // modal only — omit to hide the maximize button
initialViewState?: Monaco.editor.ICodeEditorViewState | null;
}
export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
fileName,
content,
languageId,
wordWrap,
saving,
saveError,
hotkeyScheme,
keyBindings,
chrome,
subtitle,
onContentChange,
onLanguageChange,
onToggleWordWrap,
onSave,
onRequestClose,
onPromoteToTab,
initialViewState,
}) => {
const { t } = useI18n();
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
const monaco = useMonaco();
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
const handleSaveRef = useRef<() => void>(() => {});
const handleCloseRef = useRef<(() => void) | null>(null);
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
// Track theme from document.documentElement class (syncs with app theme)
const [isDarkTheme, setIsDarkTheme] = useState(() =>
document.documentElement.classList.contains('dark')
);
// Track a signal that changes whenever immersive-mode or base theme colors change
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
// Custom theme name
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
useEffect(() => {
if (!monaco) return;
const colors = getEditorColors(isDarkTheme);
const themeColors: Record<string, string> = {
'editor.background': colors.bg,
'editor.foreground': colors.fg,
'editorCursor.foreground': colors.primary,
'editor.selectionBackground': colors.primary + '40',
'editor.inactiveSelectionBackground': colors.primary + '25',
'editorLineNumber.foreground': colors.mutedFg,
'editorLineNumber.activeForeground': colors.fg,
'editor.lineHighlightBackground': colors.fg + '08',
'editorWidget.background': colors.card,
'editorWidget.foreground': colors.fg,
'editorWidget.border': colors.border,
'input.background': colors.card,
'input.foreground': colors.fg,
'input.border': colors.border,
};
monaco.editor.defineTheme('netcatty-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: themeColors,
});
monaco.editor.defineTheme('netcatty-light', {
base: 'vs',
inherit: true,
rules: [],
colors: themeColors,
});
monaco.editor.setTheme(customThemeName);
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
useEffect(() => {
const root = document.documentElement;
const updateTheme = () => {
setIsDarkTheme(root.classList.contains('dark'));
setThemeSignal(getThemeSignal());
};
const observer = new MutationObserver(updateTheme);
observer.observe(root, {
attributes: true,
attributeFilter: ['class', 'style', 'data-immersive-theme'],
});
return () => observer.disconnect();
}, []);
const closeTabBinding = useMemo(
() => keyBindings.find((binding) => binding.action === 'closeTab'),
[keyBindings],
);
const handleSave = useCallback(() => {
if (saving) return;
onSave();
}, [saving, onSave]);
// Keep the ref updated with the latest handleSave function
useEffect(() => {
handleSaveRef.current = handleSave;
}, [handleSave]);
// Keep the close ref fresh so the Monaco Cmd/Ctrl+W command invokes the
// latest onRequestClose handler without re-binding the Monaco command.
useEffect(() => {
handleCloseRef.current = onRequestClose ?? null;
}, [onRequestClose]);
const readClipboardText = useCallback(async (): Promise<string | null> => {
try {
if (navigator.clipboard?.readText) {
return await navigator.clipboard.readText();
}
} catch {
// Fall through to Electron bridge
}
try {
return await readClipboardTextFromBridge();
} catch {
// Both clipboard APIs unavailable; signal failure so caller can fall back.
return null;
}
}, [readClipboardTextFromBridge]);
useEffect(() => {
readClipboardTextRef.current = readClipboardText;
}, [readClipboardText]);
const handlePaste = useCallback(async () => {
const editor = editorRef.current;
if (!editor) return;
const text = await readClipboardText();
if (text === null) {
// Clipboard read unavailable; fall back to Monaco's native paste.
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
return;
}
if (!text) return;
const selections = editor.getSelections();
if (!selections || selections.length === 0) return;
// Match Monaco's default multicursorPaste:'spread' behavior:
// distribute one line per cursor when line count equals cursor count.
const lines = text.split(/\r\n|\n/);
const distribute = selections.length > 1 && lines.length === selections.length;
editor.executeEdits(
'netcatty-paste',
selections.map((selection, i) => ({
range: selection,
text: distribute ? lines[i] : text,
forceMoveMarkers: true,
})),
);
editor.focus();
}, [readClipboardText]);
useEffect(() => {
handlePasteRef.current = handlePaste;
}, [handlePaste]);
const handleEditorChange = useCallback((value: string | undefined) => {
const editor = editorRef.current;
onContentChange(value ?? '', editor ? editor.saveViewState() : null);
}, [onContentChange]);
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
editorRef.current = editor;
if (initialViewState) editor.restoreViewState(initialViewState);
// Add save shortcut - use ref to avoid stale closure
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSaveRef.current();
});
// Close-tab shortcut inside Monaco. The capture-phase keydown on the
// Pane's root div also tries to handle this, but Monaco's internal
// key-event dispatcher fires first for focused editor keystrokes, so
// registering the command here is the reliable path.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW, () => {
handleCloseRef.current?.();
});
// Add find shortcut (Ctrl+F / Cmd+F)
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
// Trigger Monaco's built-in find widget
editor.trigger('keyboard', 'actions.find', null);
});
// Fallback paste path for Electron environments where Monaco paste can fail.
// Skip custom paste when focus is inside the find/replace widget so that
// its input fields receive the pasted text via default browser behavior.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
const active = document.activeElement;
if (active?.closest('.find-widget')) {
// Read clipboard and insert into the find/replace input field.
void (async () => {
try {
const text = await readClipboardTextRef.current();
if (!text) return;
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
const start = active.selectionStart ?? active.value.length;
const end = active.selectionEnd ?? active.value.length;
active.focus();
active.setSelectionRange(start, end);
document.execCommand('insertText', false, text);
}
} catch {
// Ignore paste simply won't work
}
})();
return;
}
void handlePasteRef.current();
});
editor.focus();
}, [initialViewState]);
// Capture-phase close-tab hotkey handler. Runs in both modal and tab chrome
// so Cmd/Ctrl+W works even when focus is inside Monaco (which otherwise
// swallows the event). Requires an `onRequestClose` prop from the parent.
const handleDialogKeyDownCapture = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (hotkeyScheme === 'disabled' || !closeTabBinding || !onRequestClose) return;
const isMac = hotkeyScheme === 'mac';
const keyStr = isMac ? closeTabBinding.mac : closeTabBinding.pc;
if (!matchesKeyBinding(e.nativeEvent, keyStr, isMac)) return;
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopPropagation();
onRequestClose();
}, [closeTabBinding, hotkeyScheme, onRequestClose]);
// Trigger search dialog
const handleSearch = useCallback(() => {
if (editorRef.current) {
editorRef.current.trigger('keyboard', 'actions.find', null);
editorRef.current.focus();
}
}, []);
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
const languageOptions = useMemo(
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
[supportedLanguages],
);
return (
<div
className="h-full flex flex-col"
onKeyDownCapture={handleDialogKeyDownCapture}
data-hotkey-close-tab={chrome === 'modal' ? 'true' : undefined}
>
{/* Header */}
<div className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center justify-between gap-4">
<div className="flex items-baseline gap-2 flex-1 min-w-0">
<span className="text-sm font-semibold truncate flex-shrink-0">
{fileName}
</span>
{subtitle && (
<span className="text-xs text-muted-foreground truncate" title={subtitle}>
{subtitle}
</span>
)}
{saveError && <span className="text-xs text-destructive truncate">{saveError}</span>}
</div>
<div className="flex items-center gap-2 min-w-0">
{/* Search button */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSearch}
title={t('common.search')}
>
<Search size={14} />
</Button>
{/* Word wrap toggle */}
<Button
variant={wordWrap ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7"
onClick={onToggleWordWrap}
title={t('sftp.editor.wordWrap')}
>
<WrapText size={14} />
</Button>
{/* Language selector */}
<Combobox
options={languageOptions}
value={languageId}
onValueChange={(v) => onLanguageChange(v || 'plaintext')}
placeholder={t('sftp.editor.syntaxHighlight')}
triggerClassName="h-7 max-w-[180px] min-w-[120px] text-xs"
/>
{/* Save button */}
<Button
variant="default"
size="sm"
className="h-7"
onClick={handleSave}
disabled={saving}
>
{saving ? (
<Loader2 size={14} className="mr-1.5 animate-spin" />
) : (
<CloudUpload size={14} className="mr-1.5" />
)}
{saving ? t('sftp.editor.saving') : t('sftp.editor.save')}
</Button>
{/* Maximize button — modal chrome only, when onPromoteToTab is provided */}
{chrome === 'modal' && onPromoteToTab && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onPromoteToTab}
title={t('sftp.editor.maximize')}
>
<Maximize2 size={14} />
</Button>
)}
{/* Close button — modal chrome only */}
{chrome === 'modal' && onRequestClose && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRequestClose}
>
<X size={14} />
</Button>
)}
</div>
</div>
</div>
{/* Monaco Editor */}
<div className="flex-1 min-h-0 relative">
<Editor
height="100%"
language={monacoLanguage}
value={content}
onChange={handleEditorChange}
onMount={handleEditorMount}
theme={customThemeName}
loading={
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Loader2 size={32} className="animate-spin text-muted-foreground" />
</div>
}
options={{
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
contextmenu: false,
minimap: { enabled: true },
fontSize: 14,
lineNumbers: 'on',
roundedSelection: false,
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
insertSpaces: true,
wordWrap: wordWrap ? 'on' : 'off',
folding: true,
renderWhitespace: 'selection',
bracketPairColorization: { enabled: true },
find: {
addExtraSpaceOnTop: false,
autoFindInSelection: 'never',
seedSearchStringFromSelection: 'selection',
},
}}
/>
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
<span>
{getLanguageName(languageId)}
</span>
<span>
{content.split('\n').length} lines {content.length} characters
</span>
</div>
</div>
);
};
export default TextEditorPane;

View File

@@ -0,0 +1,128 @@
/**
* TextEditorTabView — thin wrapper that binds an editorTab entry to TextEditorPane.
*
* Each tab has its own instance (keyed by tabId), so Monaco is never torn down
* on tab-switch — we just toggle CSS visibility via the `isVisible` prop.
*/
import type * as Monaco from 'monaco-editor';
import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { editorSftpWrite } from '../../application/state/editorSftpBridge';
import { editorTabStore, useEditorTab, type EditorTabId } from '../../application/state/editorTabStore';
import type { HotkeyScheme, KeyBinding } from '../../domain/models';
import type { Host } from '../../types';
import { toast } from '../ui/toast';
import { TextEditorPane } from './TextEditorPane';
export interface TextEditorTabViewProps {
tabId: EditorTabId;
/** When false the view is hidden via display:none so the Monaco instance persists. */
isVisible: boolean;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
/** Host lookup for building the `host:remotePath` subtitle next to the filename. */
hostById: Map<string, Host>;
/** Routed into Monaco's Cmd/Ctrl+W command so closing the editor tab works
* even when focus is inside the editor (Monaco otherwise swallows the event). */
onRequestClose: (tabId: EditorTabId) => void;
}
export const TextEditorTabView: React.FC<TextEditorTabViewProps> = ({
tabId,
isVisible,
hotkeyScheme,
keyBindings,
hostById,
onRequestClose,
}) => {
const { t } = useI18n();
const tab = useEditorTab(tabId);
const handleContentChange = useCallback(
(content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
editorTabStore.updateContent(tabId, content, viewState);
},
[tabId],
);
const handleLanguageChange = useCallback(
(lang: string) => {
editorTabStore.setLanguage(tabId, lang);
},
[tabId],
);
const handleToggleWordWrap = useCallback(() => {
const current = editorTabStore.getTab(tabId);
if (!current) return;
editorTabStore.setWordWrap(tabId, !current.wordWrap);
}, [tabId]);
const handleSave = useCallback(async () => {
// Read live store state at call time — React state snapshot lags the store
// by one microtask, so a keystroke between onChange and this save would
// otherwise leave us writing stale content and marking a stale baseline.
const current = editorTabStore.getTab(tabId);
if (!current) return;
if (current.savingState === 'saving') return;
editorTabStore.setSavingState(tabId, 'saving');
try {
await editorSftpWrite(current.sessionId, current.hostId, current.remotePath, current.content);
editorTabStore.markSaved(tabId, current.content);
toast.success(t('sftp.editor.saved'), 'SFTP');
} catch (e) {
const msg = e instanceof Error ? e.message : t('sftp.editor.saveFailed');
editorTabStore.setSavingState(tabId, 'error', msg);
toast.error(msg, 'SFTP');
}
}, [tabId, t]);
// Tab has been closed — render nothing (parent should remove this instance,
// but guard here in case of a transient render before unmount).
if (!tab) return null;
const isDirty = tab.content !== tab.baselineContent;
// Subtitle shown next to the filename in the Pane header, e.g.
// "Rainyun-114.66.26.174:/root/hello-server.go". Falls back to hostId when
// we don't have a Host record (session may have been removed).
const host = hostById.get(tab.hostId);
const hostLabel = host?.label ?? tab.hostId;
const subtitle = `${hostLabel}:${tab.remotePath}`;
return (
// Sibling tab panels (VaultView, SftpView, TerminalLayerMount, LogView)
// all fill their flex-1 parent via `absolute inset-0`. Match that here so
// an inactive editor tab doesn't collapse to zero height in normal flow,
// and an active one fills the viewport instead of stacking beneath others.
// z-index high enough to stay above the TerminalLayer's inner `z-10` panels
// (TerminalLayer root is visibility:hidden when editor tabs are active, but
// its children's stacking contexts can still overlap without an explicit z.)
<div
style={{ display: isVisible ? undefined : 'none', zIndex: 20 }}
className="absolute inset-0 min-h-0 flex flex-col bg-background"
>
<TextEditorPane
chrome="tab"
fileName={`${tab.fileName}${isDirty ? ' *' : ''}`}
subtitle={subtitle}
onRequestClose={() => onRequestClose(tabId)}
content={tab.content}
languageId={tab.languageId}
wordWrap={tab.wordWrap}
saving={tab.savingState === 'saving'}
saveError={tab.saveError}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onContentChange={handleContentChange}
onLanguageChange={handleLanguageChange}
onToggleWordWrap={handleToggleWordWrap}
onSave={handleSave}
initialViewState={tab.viewState}
/>
</div>
);
};
export default TextEditorTabView;

View File

@@ -0,0 +1,104 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { Button } from "../ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
export type UnsavedChoice = "save" | "discard" | "cancel";
interface Pending {
fileName: string;
resolve: (choice: UnsavedChoice) => void;
}
interface UnsavedChangesAPI {
prompt: (fileName: string) => Promise<UnsavedChoice>;
}
export const UnsavedChangesProvider: React.FC<{
children: (api: UnsavedChangesAPI) => React.ReactNode;
}> = ({ children }) => {
const { t } = useI18n();
const [pending, setPending] = useState<Pending | null>(null);
const pendingRef = useRef<Pending | null>(null);
pendingRef.current = pending;
const prompt = useCallback(
(fileName: string) =>
new Promise<UnsavedChoice>((resolve) => {
// Re-entrance: if a prior prompt is still pending, cancel it so its caller
// doesn't hang forever waiting for a resolve that now belongs to a new prompt.
const prior = pendingRef.current;
if (prior) prior.resolve("cancel");
setPending({ fileName, resolve });
}),
[],
);
// Register the prompt function as the module-level singleton so it can be
// called from outside the React tree (e.g. useSftpViewPaneActions).
useEffect(() => {
promptSingleton = prompt;
return () => { promptSingleton = null; };
}, [prompt]);
// On unmount, resolve any in-flight prompt as "cancel" so awaiting callers don't leak.
useEffect(() => () => {
const prior = pendingRef.current;
if (prior) {
prior.resolve("cancel");
pendingRef.current = null;
}
}, []);
const resolveWith = useCallback((choice: UnsavedChoice) => {
if (!pending) return;
pending.resolve(choice);
setPending(null);
}, [pending]);
return (
<>
{children({ prompt })}
<Dialog open={!!pending} onOpenChange={(o) => { if (!o) resolveWith("cancel"); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("sftp.editor.unsavedTitle")}</DialogTitle>
<DialogDescription>
{t("sftp.editor.unsavedMessage", { fileName: pending?.fileName ?? "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={() => resolveWith("cancel")}>
{t("common.cancel")}
</Button>
<Button variant="outline" onClick={() => resolveWith("discard")}>
{t("sftp.editor.discardChanges")}
</Button>
<Button variant="default" onClick={() => resolveWith("save")}>
{t("sftp.editor.saveAndClose")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
// ---------------------------------------------------------------------------
// Module-level singleton — lets non-React code call the dialog without
// prop-drilling. Registered/unregistered by UnsavedChangesProvider above.
// ---------------------------------------------------------------------------
let promptSingleton: ((fileName: string) => Promise<UnsavedChoice>) | null = null;
export const promptUnsavedChanges = (fileName: string): Promise<UnsavedChoice> => {
if (!promptSingleton) return Promise.resolve("cancel");
return promptSingleton(fileName);
};

View File

@@ -20,7 +20,10 @@ export interface SftpTransferSource {
// Types for the context
export interface SftpPaneCallbacks {
onConnect: (host: Host | "local") => void;
onDisconnect: () => void;
/** Resolves true if disconnect completed, false if the user canceled the
* dirty-editor prompt. Callers that follow up with a replacement connect
* must gate on the result. */
onDisconnect: () => Promise<boolean>;
onPrepareSelection: () => void;
onNavigateTo: (path: string) => void;
onNavigateUp: () => void;
@@ -49,6 +52,7 @@ export interface SftpPaneCallbacks {
onOpenFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
onDownloadFiles?: (entries: SftpFileEntry[]) => void; // Batch download — picks one target directory for remote panes
// External file upload (supports folders via DataTransfer)
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;

View File

@@ -5,6 +5,7 @@ import type { useSftpState } from "../../application/state/useSftpState";
import type { HotkeyScheme, KeyBinding } from "../../domain/models";
import FileOpenerDialog from "../FileOpenerDialog";
import TextEditorModal from "../TextEditorModal";
import type { TextEditorModalSnapshot } from "../TextEditorModal";
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog } from "./index";
import { SftpTransferQueue } from "./SftpTransferQueue";
@@ -44,6 +45,7 @@ interface SftpOverlaysProps {
setFileOpenerTarget: (target: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
handleFileOpenerSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
}
export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
@@ -80,6 +82,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
setFileOpenerTarget,
handleFileOpenerSelect,
handleSelectSystemApp,
onPromoteToTab,
}) => {
return (
<>
@@ -146,6 +149,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onPromoteToTab={onPromoteToTab}
/>
{/* File Opener Dialog */}

View File

@@ -61,7 +61,7 @@ interface SftpPaneDialogsProps {
hostSearch: string;
setHostSearch: (value: string) => void;
onConnect: (host: Host | "local") => void;
onDisconnect: () => void;
onDisconnect: () => Promise<boolean>;
}
const HostHint: React.FC<{ label?: string }> = ({ label }) =>
@@ -357,13 +357,16 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
side={side}
hostSearch={hostSearch}
onHostSearchChange={setHostSearch}
onSelectLocal={() => {
onDisconnect();
onConnect("local");
onSelectLocal={async () => {
// Only connect to the new target if the disconnect actually happened.
// A cancel on the dirty-editor prompt must keep the user on the
// current host instead of silently switching and stranding tabs.
const ok = await onDisconnect();
if (ok) onConnect("local");
}}
onSelectHost={(host) => {
onDisconnect();
onConnect(host);
onSelectHost={async (host) => {
const ok = await onDisconnect();
if (ok) onConnect(host);
}}
/>
</>

View File

@@ -58,6 +58,7 @@ interface SftpPaneFileListProps {
onOpenFileWith?: (entry: SftpFileEntry) => void;
onEditFile?: (entry: SftpFileEntry) => void;
onDownloadFile?: (entry: SftpFileEntry) => void;
onDownloadFiles?: (entries: SftpFileEntry[]) => void;
onEditPermissions?: (entry: SftpFileEntry) => void;
openRenameDialog: (name: string) => void;
openDeleteConfirm: (targets: string[]) => void;
@@ -143,6 +144,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
onOpenFileWith,
onEditFile,
onDownloadFile,
onDownloadFiles,
onEditPermissions,
openRenameDialog,
openDeleteConfirm,
@@ -243,7 +245,23 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
)}
{onDownloadFile &&
(!isNavigableDirectory(entry) || !pane.connection?.isLocal) && (
<ContextMenuItem onClick={() => onDownloadFile(entry)}>
<ContextMenuItem
onClick={() => {
const currentSelected = selectedFilesRef.current;
if (
onDownloadFiles &&
currentSelected.has(entry.name) &&
currentSelected.size > 1
) {
const entries = Array.from(currentSelected)
.map((name) => filesByName.get(String(name)))
.filter((f): f is SftpFileEntry => !!f);
onDownloadFiles(entries);
} else {
onDownloadFile(entry);
}
}}
>
<Download size={14} className="mr-2" />{" "}
{t("sftp.context.download")}
</ContextMenuItem>
@@ -349,6 +367,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
onCopyToOtherPane,
onMoveEntriesToPath,
onDownloadFile,
onDownloadFiles,
onDragEnd,
onEditFile,
onEditPermissions,

View File

@@ -570,6 +570,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onOpenFileWith={callbacks.onOpenFileWith}
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
onDownloadFiles={callbacks.onDownloadFiles}
onEditPermissions={callbacks.onEditPermissions}
openRenameDialog={openRenameDialog}
openDeleteConfirm={openDeleteConfirm}

View File

@@ -5,8 +5,11 @@ import { getParentPath, joinPath as joinFsPath } from "../../../application/stat
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
import { getFileExtension, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { getFileExtension, getLanguageId, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { isNavigableDirectory } from "../index";
import { editorTabStore } from "../../../application/state/editorTabStore";
import { toEditorTabId, activeTabStore } from "../../../application/state/activeTabStore";
import type { TextEditorModalSnapshot } from "../../TextEditorModal";
interface UseSftpViewFileOpsParams {
sftpRef: MutableRefObject<SftpStateApi>;
@@ -80,6 +83,7 @@ interface UseSftpViewFileOpsResult {
} | null>
>;
handleSaveTextFile: (content: string) => Promise<void>;
onPromoteToTab: (snapshot: TextEditorModalSnapshot) => void;
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
@@ -98,6 +102,8 @@ interface UseSftpViewFileOpsResult {
onOpenFileWithRight: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFilesLeft: (files: SftpFileEntry[]) => void;
onDownloadFilesRight: (files: SftpFileEntry[]) => void;
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
onUploadExternalFilesRight: (dataTransfer: DataTransfer, targetPath?: string) => void;
}
@@ -298,6 +304,31 @@ export const useSftpViewFileOps = ({
[sftpRef],
);
const handlePromoteToTab = useCallback((snapshot: TextEditorModalSnapshot) => {
const target = textEditorTargetRef.current;
if (!target) return;
const pane = target.side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
const connection = pane.connection;
if (!connection || !target.hostId) return;
const editorId = editorTabStore.promoteFromModal({
sessionId: connection.id,
hostId: target.hostId,
remotePath: target.fullPath,
fileName: target.file.name,
languageId: snapshot.languageId || getLanguageId(target.file.name),
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: snapshot.viewState,
});
activeTabStore.setActiveTabId(toEditorTabId(editorId));
// Close the modal
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
}, [sftpRef]);
const onEditFileLeft = useCallback(
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("left", file, fullPath),
[handleEditFileForSide],
@@ -589,6 +620,177 @@ export const useSftpViewFileOps = ({
[handleDownloadFileForSide],
);
// Multi-file download. For local panes, each file auto-downloads as a blob
// (no prompt). For remote panes, prompts for a target directory once and
// streams all selected entries into it — avoids the per-file save dialog
// that would otherwise appear N times.
const handleDownloadFilesForSide = useCallback(
async (side: "left" | "right", files: SftpFileEntry[]) => {
if (files.length === 0) return;
if (files.length === 1) {
await handleDownloadFileForSide(side, files[0]);
return;
}
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
if (pane.connection.isLocal) {
for (const file of files) {
await handleDownloadFileForSide(side, file);
}
return;
}
if (!selectDirectory || !startStreamTransfer || !getSftpIdForConnection) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const sftpId = getSftpIdForConnection(pane.connection.id);
if (!sftpId) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
if (!selectedDirectory) return;
for (const file of files) {
const sourcePath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const targetPath = joinFsPath(selectedDirectory, file.name);
const isDirectory = isNavigableDirectory(file);
try {
if (isDirectory) {
const status = await sftpRef.current.downloadToLocal({
fileName: file.name,
sourcePath,
targetPath,
sftpId,
connectionId: pane.connection.id,
sourceEncoding: pane.filenameEncoding,
isDirectory: true,
});
if (status === "completed") {
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} else if (status === "failed") {
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
}
continue;
}
const transferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const fileSize = typeof file.size === "string" ? parseInt(file.size, 10) || 0 : (file.size || 0);
sftpRef.current.addExternalUpload({
id: transferId,
fileName: file.name,
sourcePath,
targetPath,
sourceConnectionId: pane.connection.id,
targetConnectionId: "local",
direction: "download",
status: "transferring",
totalBytes: fileSize,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: false,
});
let errorHandled = false;
const result = await startStreamTransfer(
{
transferId,
sourcePath,
targetPath,
sourceType: "sftp",
targetType: "local",
sourceSftpId: sftpId,
totalBytes: fileSize,
sourceEncoding: pane.filenameEncoding,
},
(transferred, total, speed) => {
sftpRef.current.updateExternalUpload(transferId, {
transferredBytes: transferred,
totalBytes: total,
speed,
});
},
() => {
sftpRef.current.updateExternalUpload(transferId, {
status: "completed",
transferredBytes: fileSize,
endTime: Date.now(),
});
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
},
(error) => {
errorHandled = true;
const isCancelError = error.includes("cancelled") || error.includes("canceled");
sftpRef.current.updateExternalUpload(transferId, {
status: isCancelError ? "cancelled" : "failed",
error: isCancelError ? undefined : error,
endTime: Date.now(),
});
if (!isCancelError) {
toast.error(error, "SFTP");
}
},
);
if (result === undefined) {
sftpRef.current.updateExternalUpload(transferId, {
status: "failed",
error: t("sftp.error.downloadFailed"),
endTime: Date.now(),
});
toast.error(t("sftp.error.downloadFailed"), "SFTP");
continue;
}
if (result?.error && !errorHandled) {
const isCancelError = result.error.includes("cancelled") || result.error.includes("canceled");
sftpRef.current.updateExternalUpload(transferId, {
status: isCancelError ? "cancelled" : "failed",
error: isCancelError ? undefined : result.error,
endTime: Date.now(),
});
if (!isCancelError) {
toast.error(result.error, "SFTP");
}
}
} catch (e) {
logger.error("[SftpView] Failed to download file:", e);
toast.error(
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
"SFTP",
);
}
}
},
[
sftpRef,
t,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection,
handleDownloadFileForSide,
],
);
const onDownloadFilesLeft = useCallback(
(files: SftpFileEntry[]) => handleDownloadFilesForSide("left", files),
[handleDownloadFilesForSide],
);
const onDownloadFilesRight = useCallback(
(files: SftpFileEntry[]) => handleDownloadFilesForSide("right", files),
[handleDownloadFilesForSide],
);
const onOpenEntryLeft = useCallback(
(entry: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.leftPane;
@@ -664,6 +866,7 @@ export const useSftpViewFileOps = ({
fileOpenerTarget,
setFileOpenerTarget,
handleSaveTextFile,
onPromoteToTab: handlePromoteToTab,
handleFileOpenerSelect,
handleSelectSystemApp,
onEditPermissionsLeft,
@@ -678,6 +881,8 @@ export const useSftpViewFileOps = ({
onOpenFileWithRight,
onDownloadFileLeft,
onDownloadFileRight,
onDownloadFilesLeft,
onDownloadFilesRight,
onUploadExternalFilesLeft,
onUploadExternalFilesRight,
};

View File

@@ -3,6 +3,9 @@ import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { keepOnlyActivePaneSelections } from "./selectionScope";
import { editorTabStore } from "../../../application/state/editorTabStore";
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
interface UseSftpViewPaneActionsParams {
sftpRef: MutableRefObject<SftpStateApi>;
@@ -13,8 +16,8 @@ interface UseSftpViewPaneActionsResult {
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
onConnectLeft: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
onConnectRight: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
onDisconnectLeft: () => void;
onDisconnectRight: () => void;
onDisconnectLeft: () => Promise<boolean>;
onDisconnectRight: () => Promise<boolean>;
onPrepareSelectionLeft: () => void;
onPrepareSelectionRight: () => void;
onNavigateToLeft: (path: string) => void;
@@ -127,8 +130,42 @@ export const useSftpViewPaneActions = ({
(host: Parameters<SftpStateApi["connect"]>[1]) => sftpRef.current.connect("right", host),
[sftpRef],
);
const onDisconnectLeft = useCallback(() => sftpRef.current.disconnect("left"), [sftpRef]);
const onDisconnectRight = useCallback(() => sftpRef.current.disconnect("right"), [sftpRef]);
// Returns `true` if the disconnect actually happened, `false` if the user
// canceled the dirty-editor prompt. Callers that kick off a replacement
// connect (e.g. the host picker) MUST gate their follow-up on this result
// so a canceled prompt doesn't silently drop the user onto a new host.
const onDisconnectLeft = useCallback(async (): Promise<boolean> => {
const connectionId = sftpRef.current.getActivePane("left")?.connection?.id;
if (connectionId) {
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const tab = editorTabStore.getTab(id);
if (!tab) return;
await sftpRef.current.writeTextFileByConnection(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
editorTabStore.markSaved(id, tab.content);
};
const ok = await editorTabStore.confirmCloseBySession(connectionId, choice, saveTab);
if (!ok) return false;
}
sftpRef.current.disconnect("left");
return true;
}, [sftpRef]);
const onDisconnectRight = useCallback(async (): Promise<boolean> => {
const connectionId = sftpRef.current.getActivePane("right")?.connection?.id;
if (connectionId) {
const choice = (tab: EditorTab) => promptUnsavedChanges(tab.fileName);
const saveTab = async (id: EditorTabId) => {
const tab = editorTabStore.getTab(id);
if (!tab) return;
await sftpRef.current.writeTextFileByConnection(tab.sessionId, tab.hostId, tab.remotePath, tab.content);
editorTabStore.markSaved(id, tab.content);
};
const ok = await editorTabStore.confirmCloseBySession(connectionId, choice, saveTab);
if (!ok) return false;
}
sftpRef.current.disconnect("right");
return true;
}, [sftpRef]);
const onPrepareSelectionLeft = useCallback(() => {
keepOnlyActivePaneSelections(sftpRef.current, "left");
}, [sftpRef]);

View File

@@ -169,6 +169,7 @@ export const useSftpViewPaneCallbacks = ({
onOpenFile: fileOps.onOpenFileLeft,
onOpenFileWith: fileOps.onOpenFileWithLeft,
onDownloadFile: fileOps.onDownloadFileLeft,
onDownloadFiles: fileOps.onDownloadFilesLeft,
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
onListDirectory: makeListDirectory("left", () => sftpRef.current.leftPane),
}),
@@ -206,6 +207,7 @@ export const useSftpViewPaneCallbacks = ({
onOpenFile: fileOps.onOpenFileRight,
onOpenFileWith: fileOps.onOpenFileWithRight,
onDownloadFile: fileOps.onDownloadFileRight,
onDownloadFiles: fileOps.onDownloadFilesRight,
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
onListDirectory: makeListDirectory("right", () => sftpRef.current.rightPane),
}),
@@ -232,6 +234,7 @@ export const useSftpViewPaneCallbacks = ({
fileOpenerTarget: fileOps.fileOpenerTarget,
setFileOpenerTarget: fileOps.setFileOpenerTarget,
handleSaveTextFile: fileOps.handleSaveTextFile,
onPromoteToTab: fileOps.onPromoteToTab,
handleFileOpenerSelect: fileOps.handleFileOpenerSelect,
handleSelectSystemApp: fileOps.handleSelectSystemApp,
};

View File

@@ -0,0 +1,22 @@
import test from "node:test";
import assert from "node:assert/strict";
import { decideGhostSuggestion } from "./autocomplete/ghostSuggestionPolicy.ts";
test("keeps the active ghost suggestion while input still fits it", () => {
const decision = decideGhostSuggestion("docker ps -a", "doc", "docker compose ls");
assert.deepEqual(decision, { type: "keep" });
});
test("switches to a new suggestion once the active one no longer matches", () => {
const decision = decideGhostSuggestion("docker ps -a", "dog", "dogstatsd");
assert.deepEqual(decision, { type: "show", suggestion: "dogstatsd" });
});
test("hides the ghost when neither the active nor next suggestion matches", () => {
const decision = decideGhostSuggestion("docker ps -a", "dog", null);
assert.deepEqual(decision, { type: "hide" });
});

View File

@@ -0,0 +1,325 @@
import test from "node:test";
import assert from "node:assert/strict";
import { GhostTextAddon } from "./autocomplete/GhostTextAddon.ts";
type RenderListener = () => void;
type ResizeListener = () => void;
class FakeElement {
public readonly style: Record<string, string> = {};
public textContent = "";
public className = "";
public children: FakeElement[] = [];
appendChild(child: FakeElement): FakeElement {
this.children.push(child);
return child;
}
insertBefore(child: FakeElement, referenceNode: FakeElement | null): FakeElement {
if (!referenceNode) {
this.children.push(child);
return child;
}
const index = this.children.indexOf(referenceNode);
if (index < 0) {
this.children.push(child);
return child;
}
this.children.splice(index, 0, child);
return child;
}
remove(): void {
// No-op for tests.
}
querySelector(selector: string): FakeElement | null {
if (selector === ".xterm-screen") {
return this.children.find((child) => child.className === "xterm-screen") ?? null;
}
return null;
}
}
function installFakeDocument(): () => void {
const previousDocument = globalThis.document;
const fakeDocument = {
createElement() {
return new FakeElement();
},
} as unknown as Document;
Object.defineProperty(globalThis, "document", {
configurable: true,
value: fakeDocument,
});
return () => {
if (previousDocument === undefined) {
delete (globalThis as { document?: Document }).document;
return;
}
Object.defineProperty(globalThis, "document", {
configurable: true,
value: previousDocument,
});
};
}
function createFakeTerm() {
const renderListeners: RenderListener[] = [];
const resizeListeners: ResizeListener[] = [];
const element = new FakeElement();
const screen = new FakeElement();
screen.className = "xterm-screen";
element.appendChild(screen);
const term = {
element,
cols: 80,
rows: 24,
options: {
fontSize: 14,
fontFamily: "monospace",
},
buffer: {
active: {
cursorX: 2,
cursorY: 0,
},
},
_core: {
_renderService: {
dimensions: {
css: {
cell: {
width: 9,
height: 18,
},
},
},
},
},
onRender(listener: RenderListener) {
renderListeners.push(listener);
return {
dispose() {
const index = renderListeners.indexOf(listener);
if (index >= 0) renderListeners.splice(index, 1);
},
};
},
onResize(listener: ResizeListener) {
resizeListeners.push(listener);
return {
dispose() {
const index = resizeListeners.indexOf(listener);
if (index >= 0) resizeListeners.splice(index, 1);
},
};
},
};
return {
term,
ghostElement: () => screen.children[0]?.children[0] ?? null,
fireRender() {
for (const listener of [...renderListeners]) listener();
},
};
}
test("shifts ghost to predicted cursor column as matching input is typed", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.style.display, "block");
assert.equal(ghost.textContent, "cker");
// show() anchored at cursorX=2, cell width=9 → left=18.
assert.equal(ghost.style.left, "18px");
addon.adjustToInput("doc");
// After one matching char, the ghost predicts the cursor has moved
// to column 3 and trims "c" from the tail so the next char starts
// where the echo will land. Not waiting for xterm's render keeps
// ghost + real input aligned across SSH echo latency.
assert.equal(ghost.style.display, "block");
assert.equal(ghost.textContent, "ker");
assert.equal(ghost.style.left, "27px");
assert.equal(addon.getGhostText(), "ker");
} finally {
restoreDocument();
}
});
test("walks the anchor column backwards on backspace so the ghost re-aligns", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
addon.adjustToInput("doc");
assert.equal(ghost.textContent, "ker");
assert.equal(ghost.style.left, "27px");
// Backspace below the anchor input — the ghost should shift *left*,
// not stay pinned at the show-time anchor column. Pinning would
// leave a visual gap between the real cursor and the ghost.
addon.adjustToInput("d");
assert.equal(ghost.textContent, "ocker");
// anchor was cursorX=2 captured at show(); "d" is 1 char below
// anchorInputLength=2 → predicted cursor column = 1.
assert.equal(ghost.style.left, "9px");
// Backspace past the anchor back to empty: left is clamped at 0.
addon.adjustToInput("");
assert.equal(ghost.textContent, "docker");
assert.equal(ghost.style.left, "0px");
} finally {
restoreDocument();
}
});
test("advances the anchor by two cells when a CJK glyph is typed", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
// Suggestion starts with a CJK char so the prefix-match survives
// the next keystroke.
addon.show("你好世界", "");
const ghost = ghostElement();
assert.ok(ghost);
// show() anchored at cursorX=2. Input length 0 → delta 0 → left=18.
assert.equal(ghost.style.left, "18px");
addon.adjustToInput("你");
// One CJK char = 2 cells. Predicted col = 2 + 2 = 4 → left 36px.
assert.equal(ghost.textContent, "好世界");
assert.equal(ghost.style.left, "36px");
} finally {
restoreDocument();
}
});
test("wraps the ghost to the next row when the predicted column crosses cols", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Shrink the terminal to 10 cols to keep the math obvious. Anchor at
// col 8 with 5 ASCII chars to type → predicted col = 13, which should
// wrap to col 3 of row 1.
term.cols = 10;
term.buffer.active.cursorX = 8;
addon.activate(term as never);
addon.show("abcdefghij", "ab");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.style.top, "0px");
addon.adjustToInput("abcde");
// Predicted col = 8 + (5-2) = 11 → wraps to col 1 on next row.
// cellWidth=9, cellHeight=18.
assert.equal(ghost.textContent, "fghij");
assert.equal(ghost.style.left, "9px");
assert.equal(ghost.style.top, "18px");
} finally {
restoreDocument();
}
});
test("self-heals a stale anchor on render while no adjustToInput has fired", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
// show() captures cursorX=2 — simulate this firing during the
// keystroke→echo gap by later advancing the live cursor and
// verifying the ghost anchor snaps to the echoed position.
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.style.left, "18px");
term.buffer.active.cursorX = 5;
fireRender();
// Input hasn't moved from the show-time baseline, so updatePosition
// re-reads live cursor: new left = 5 * 9 = 45px.
assert.equal(ghost.style.left, "45px");
} finally {
restoreDocument();
}
});
test("wraps the ghost to the previous row when deletion crosses a row boundary", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
term.cols = 10;
term.buffer.active.cursorX = 1;
term.buffer.active.cursorY = 1;
addon.activate(term as never);
// Anchored at row 1 col 1 with 5 chars already typed.
addon.show("abcdefghij", "abcde");
const ghost = ghostElement();
assert.ok(ghost);
// Backspace back to 2 chars — delta = -3 across a row boundary.
addon.adjustToInput("ab");
// targetCol = 1 - 3 = -2 → col = 8 (wrapped) on row 0.
assert.equal(ghost.textContent, "cdefghij");
assert.equal(ghost.style.left, "72px");
assert.equal(ghost.style.top, "0px");
} finally {
restoreDocument();
}
});
test("hides ghost immediately when input no longer matches suggestion", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
addon.activate(term as never);
addon.show("docker", "do");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.style.display, "block");
addon.adjustToInput("dox");
assert.equal(ghost.style.display, "none");
assert.equal(ghost.textContent, "");
assert.equal(addon.isActive(), false);
} finally {
restoreDocument();
}
});

View File

@@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getAlignedPrompt } from "./autocomplete/promptDetector.ts";
function createFakeTerm(lineText: string, cursorX: number) {
return {
buffer: {
active: {
cursorX,
cursorY: 0,
baseY: 0,
getLine(line: number) {
if (line !== 0) return undefined;
return {
isWrapped: false,
translateToString() {
return lineText;
},
};
},
},
},
};
}
test("prefers the typed buffer when shell echo is still one character behind", () => {
const term = createFakeTerm("$ do", 4);
const result = getAlignedPrompt(term as never, "doc", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "doc");
assert.equal(result.prompt.cursorOffset, 3);
assert.equal(result.alignedTyped, "doc");
});
test("still trims prompt decorations out of the detected input", () => {
const term = createFakeTerm("➜ ~ do", 7);
const result = getAlignedPrompt(term as never, "do", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "➜ ~ ");
assert.equal(result.prompt.userInput, "do");
assert.equal(result.prompt.cursorOffset, 2);
assert.equal(result.alignedTyped, "do");
});

View File

@@ -2,7 +2,7 @@
* Terminal Toolbar
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
*/
import { Check, FolderInput, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
import { Check, ChevronRight, FolderInput, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
import React, { useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host } from '../../types';
@@ -50,11 +50,24 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
}) => {
const { t } = useI18n();
const [highlightPopoverOpen, setHighlightPopoverOpen] = useState(false);
// Overflow popover + encoding submenu are both controlled so that
// picking an encoding closes the whole chain, and so the parent popover
// can ignore clicks that land in the submenu portal (otherwise the
// submenu click would read as "outside" and dismiss the parent).
const [overflowOpen, setOverflowOpen] = useState(false);
const [encodingSubmenuOpen, setEncodingSubmenuOpen] = useState(false);
const buttonBase = "h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent";
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
const isSSHSession = !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet' && host?.protocol !== 'mosh' && !host?.moshEnabled && host?.hostname !== 'localhost';
const isMoshSession = host?.protocol === 'mosh' || host?.moshEnabled;
// Local PTY inherits the OS locale and mosh always uses its own UTF-8
// framing, so the quick-switch menu only makes sense for sessions whose
// backend decoder we actually control (SSH, telnet, serial). Hostname
// isn't part of the gate — telnet/SSH targets pointed at localhost
// (test daemons, forwarded endpoints) still have a real backend
// decoder we can drive.
const encodingSwitchSupported = !isLocalTerminal && !isMoshSession;
const hidesSftp = isLocalTerminal || isSerialTerminal;
const menuItemClass = "w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors";
@@ -106,7 +119,13 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
single ⋮ trigger so the toolbar doesn't feel crowded.
Highlight / Compose / Search stay visible because they
are toggled mid-session, not just once. */}
<Popover>
<Popover
open={overflowOpen}
onOpenChange={(open) => {
setOverflowOpen(open);
if (!open) setEncodingSubmenuOpen(false);
}}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
@@ -122,7 +141,19 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
</TooltipTrigger>
<TooltipContent>{t("terminal.toolbar.more")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-48 p-1" align="end">
<PopoverContent
className="w-48 p-1"
align="end"
onInteractOutside={(e) => {
// Radix treats the submenu's portalled content as
// "outside" this popover; without this guard a click
// in the submenu would dismiss the parent.
const target = e.target as Element | null;
if (target?.closest('[data-encoding-submenu="true"]')) {
e.preventDefault();
}
}}
>
{!hidesSftp && (
<PopoverClose asChild>
<button
@@ -150,32 +181,56 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
<span className="flex-1 text-left truncate">{t("terminal.toolbar.terminalSettings")}</span>
</button>
</PopoverClose>
{isSSHSession && onSetTerminalEncoding && (
<>
<div className="h-px bg-border/60 my-1 mx-1" />
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
<Languages size={11} />
{t("terminal.toolbar.encoding")}
</div>
{(["utf-8", "gb18030"] as const).map((enc) => (
<PopoverClose asChild key={enc}>
<button
type="button"
className={cn(menuItemClass, "pl-6", terminalEncoding === enc && "font-medium")}
onClick={() => onSetTerminalEncoding(enc)}
>
<Check
size={12}
className={cn(
"shrink-0",
terminalEncoding === enc ? "opacity-100" : "opacity-0",
)}
/>
{t(`terminal.toolbar.encoding.${enc === "utf-8" ? "utf8" : enc}`)}
</button>
</PopoverClose>
))}
</>
{encodingSwitchSupported && onSetTerminalEncoding && (
<Popover open={encodingSubmenuOpen} onOpenChange={setEncodingSubmenuOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={menuItemClass}
aria-haspopup="menu"
aria-expanded={encodingSubmenuOpen}
>
<Languages size={12} className="shrink-0" />
<span className="flex-1 text-left truncate">{t("terminal.toolbar.encoding")}</span>
<ChevronRight size={12} className="shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
data-encoding-submenu="true"
className="w-40 p-1"
side="right"
align="start"
sideOffset={6}
>
{(["utf-8", "gb18030"] as const).map((enc) => {
const isActive = terminalEncoding === enc;
return (
<button
key={enc}
type="button"
className={cn(menuItemClass, isActive && "font-medium")}
onClick={() => {
onSetTerminalEncoding(enc);
setEncodingSubmenuOpen(false);
setOverflowOpen(false);
}}
>
<Languages size={12} className="shrink-0" />
<span className="flex-1 text-left truncate">
{t(`terminal.toolbar.encoding.${enc === "utf-8" ? "utf8" : enc}`)}
</span>
<Check
size={12}
className={cn(
"shrink-0",
isActive ? "opacity-100" : "opacity-0",
)}
/>
</button>
);
})}
</PopoverContent>
</Popover>
)}
</PopoverContent>
</Popover>

View File

@@ -10,12 +10,56 @@
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
/**
* Minimal East-Asian-Width-style classifier: returns 2 for wide glyphs
* (CJK ideographs, fullwidth forms, most emoji, hangul syllables) and
* 1 otherwise. Not full wcwidth — just enough to keep the predicted
* ghost column from drifting by one cell per CJK char typed.
*/
function codePointCellWidth(cp: number): number {
if (
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals, Kangxi
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK Compat
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compat Ideographs
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compat Forms
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
(cp >= 0x1f300 && cp <= 0x1faff) || // Emoji blocks
(cp >= 0x20000 && cp <= 0x3fffd) // CJK Extension B-F, G
) {
return 2;
}
return 1;
}
function stringCellWidth(s: string): number {
let w = 0;
for (const ch of s) {
const cp = ch.codePointAt(0) ?? 0;
w += codePointCellWidth(cp);
}
return w;
}
export class GhostTextAddon implements IDisposable {
private term: XTerm | null = null;
private ghostElement: HTMLSpanElement | null = null;
private containerElement: HTMLDivElement | null = null;
private currentSuggestion: string = "";
private currentInput: string = "";
/** Cursor column captured at show() time — the anchor the ghost was painted from. */
private anchorCursorX = 0;
/** Cursor row captured at show() time. */
private anchorCursorY = 0;
/** Length of currentInput at show() time — lets adjustToInput shift left
* by (newInput.length - anchorInputLength) cells without having to
* re-read xterm's cursorX (which hasn't advanced yet at keystroke time). */
private anchorInputLength = 0;
private disposed = false;
private disposables: IDisposable[] = [];
private lastLeft = -1;
@@ -37,6 +81,9 @@ export class GhostTextAddon implements IDisposable {
height: "100%",
pointerEvents: "none",
overflow: "hidden",
// Sit above xterm's canvas — xterm's default renderer paints its
// theme.background across every cell including empty ones, so a
// ghost placed beneath the canvas would be completely occluded.
zIndex: "1",
});
@@ -63,17 +110,25 @@ export class GhostTextAddon implements IDisposable {
termElement.appendChild(this.containerElement);
}
// Update position on scroll and render to keep ghost text aligned
this.disposables.push(
term.onRender(() => {
if (this.isVisible()) this.updatePosition();
if (this.isVisible()) {
this.updatePosition();
}
}),
);
// Invalidate cell dimension cache on resize so measurements stay accurate
// Invalidate cell dimension cache on resize so measurements stay
// accurate, and force a pixel-coord recompute on the next render —
// otherwise the lastLeft/lastTop short-circuit in updatePosition
// would keep the ghost at stale pixel coordinates until the user
// typed again.
this.disposables.push(
term.onResize(() => {
invalidateCellDimensionCache();
this.lastLeft = -1;
this.lastTop = -1;
if (this.isVisible()) this.updatePosition();
}),
);
}
@@ -97,6 +152,12 @@ export class GhostTextAddon implements IDisposable {
this.currentSuggestion = fullSuggestion;
this.currentInput = currentInput;
this.anchorCursorX = this.term.buffer.active.cursorX;
this.anchorCursorY = this.term.buffer.active.cursorY;
this.anchorInputLength = currentInput.length;
// Force position recalc since the text also changed.
this.lastLeft = -1;
this.lastTop = -1;
this.updatePosition();
this.ghostElement.textContent = ghostText;
@@ -113,6 +174,43 @@ export class GhostTextAddon implements IDisposable {
}
this.currentSuggestion = "";
this.currentInput = "";
this.anchorInputLength = 0;
}
/**
* Re-align the ghost against a freshly-updated user input synchronously.
* Called from handleInput on every keystroke that mutates the typed
* buffer so ghost text never falls out of sync with what the user has
* actually typed.
*
* Implementation relies on the predict-anchor-shift trick rather than
* re-reading xterm's live cursorX: xterm hasn't echoed the triggering
* keystroke yet at this point, so cursorX still points at the
* pre-keystroke column. Instead we track the cursor column captured
* at show() time and advance the ghost's left by the number of chars
* typed since — so the tail aligns with where the real cursor *will*
* land once the echo arrives, even across SSH round-trip latency.
*/
adjustToInput(newInput: string): void {
if (this.disposed || !this.ghostElement || !this.currentSuggestion) return;
if (!this.currentSuggestion.startsWith(newInput)) {
this.hide();
return;
}
this.currentInput = newInput;
const ghostText = this.currentSuggestion.substring(newInput.length);
if (!ghostText) {
this.hide();
return;
}
// Force position recomputation — updatePosition skips DOM writes
// when the left/top cache hasn't changed, but we also need the new
// textContent to flush.
this.lastLeft = -1;
this.lastTop = -1;
this.ghostElement.textContent = ghostText;
this.updatePosition();
this.ghostElement.style.display = "block";
}
getSuggestion(): string {
@@ -124,6 +222,17 @@ export class GhostTextAddon implements IDisposable {
this.currentSuggestion);
}
/**
* True when the ghost has a live suggestion even if it's momentarily
* shown underneath the real text while the user keeps typing within
* the prediction. Accept-path gates should use this instead of
* isVisible() so the suggestion remains available even while its
* leading characters are fully covered by real glyphs.
*/
isActive(): boolean {
return !this.disposed && !!this.currentSuggestion;
}
getGhostText(): string {
if (!this.currentSuggestion || !this.currentInput) return "";
return this.currentSuggestion.startsWith(this.currentInput)
@@ -151,11 +260,47 @@ export class GhostTextAddon implements IDisposable {
private updatePosition(): void {
if (!this.term || !this.ghostElement) return;
// Self-heal a stale anchor: when show() fires during the SSH
// keystroke→echo gap, cursorX captured there is still the
// pre-echo column. While no adjustToInput has moved us from the
// show-time baseline, re-read live cursor on each render tick so
// the anchor snaps to the echoed position once it arrives.
if (this.currentInput.length === this.anchorInputLength) {
this.anchorCursorX = this.term.buffer.active.cursorX;
this.anchorCursorY = this.term.buffer.active.cursorY;
}
const dims = getXTermCellDimensions(this.term);
const buffer = this.term.buffer.active;
const left = buffer.cursorX * dims.width;
const top = buffer.cursorY * dims.height;
// Advance (or walk back) the anchor column by the cell width of
// whatever the user has typed since show() was called. Using cell
// width (not code-unit length) lets CJK / emoji / fullwidth glyphs
// advance by 2 cells instead of 1. Backspace / Ctrl-W produces a
// negative delta by shrinking currentInput below anchorInputLength.
const cellDelta = this.currentInput.length >= this.anchorInputLength
? stringCellWidth(this.currentInput.slice(this.anchorInputLength))
: -stringCellWidth(
// currentSuggestion[0..anchorInputLength] equals what was typed
// when show() fired (prefix-match invariant), so its slice gives
// the correct cell widths for the deleted glyphs.
this.currentSuggestion.slice(this.currentInput.length, this.anchorInputLength),
);
const cols = Math.max(1, this.term.cols);
const targetCol = this.anchorCursorX + cellDelta;
// Wrap the predicted cursor position across line boundaries in both
// directions — the real xterm cursor wraps to the next row once it
// crosses cols forward, and to the previous row when a deletion
// crosses back past column 0. JS `%` returns negative for negative
// dividends, so normalize both col and rowOffset explicitly.
let col = targetCol % cols;
let rowOffset = Math.floor(targetCol / cols);
if (col < 0) {
col += cols;
}
// Clamp to the visible top row so a runaway negative delta (e.g.
// deleted past the prompt) doesn't render above the terminal.
const top = Math.max(0, this.anchorCursorY + rowOffset) * dims.height;
const left = col * dims.width;
// Skip DOM writes if position hasn't changed (avoids unnecessary style recalc)
if (left === this.lastLeft && top === this.lastTop) return;

View File

@@ -66,6 +66,14 @@ export interface CompletionContext {
isOptionArg: boolean;
}
export function shellEscape(name: string): string {
if (!name) return name;
if (/[\\$'"|!<>;#~` ]/.test(name)) {
return `'${name.replace(/'/g, "'\\''")}'`;
}
return name;
}
/**
* Parse a command line string into tokens, handling quoting.
*/
@@ -241,9 +249,9 @@ export async function getCompletions(
const { pathPrefix, quoteSuffix } = resolvePathComponents(ctx.currentWord, options.cwd);
const isQuotedPath = ctx.currentWord.startsWith('"') || ctx.currentWord.startsWith("'");
for (const entry of pathEntries) {
const insertName = isQuotedPath || !entry.name.includes(" ")
const insertName = isQuotedPath || !/[\\$'"|!<>;#~` ]/.test(entry.name)
? entry.name
: entry.name.replace(/ /g, "\\ ");
: shellEscape(entry.name);
const suffix = entry.type === "directory" ? "/" : "";
const fullPath = pathPrefix + insertName + suffix + quoteSuffix;
const suggestion = {

View File

@@ -0,0 +1,24 @@
export type GhostSuggestionDecision =
| { type: "keep" }
| { type: "show"; suggestion: string }
| { type: "hide" };
/**
* Prefer a stable ghost suggestion while the user's typed input still
* falls within the currently shown prediction. This avoids a "jitter"
* effect where freshly fetched suggestions keep replacing the same
* visual prediction one character at a time.
*/
export function decideGhostSuggestion(
activeSuggestion: string | null,
input: string,
nextSuggestion: string | null,
): GhostSuggestionDecision {
if (activeSuggestion && activeSuggestion.startsWith(input)) {
return { type: "keep" };
}
if (nextSuggestion && nextSuggestion.startsWith(input)) {
return { type: "show", suggestion: nextSuggestion };
}
return { type: "hide" };
}

View File

@@ -3,3 +3,4 @@ export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandl
export { default as AutocompletePopup } from "./AutocompletePopup";
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
export { recordCommand, clearHistory, deleteHistoryEntry, getHistoryCount } from "./commandHistoryStore";
export { shellEscape } from "./completionEngine";

View File

@@ -38,6 +38,30 @@ const NO_PROMPT: PromptDetectionResult = {
isAtPrompt: false, promptText: "", userInput: "", cursorOffset: 0,
};
export interface AlignedPromptResult {
/** The prompt view every consumer should use for parsing / suggestion lookup / line rewrites. */
prompt: PromptDetectionResult;
/**
* The keystroke buffer, but only when it's both marked reliable AND
* actually matches the tail of the raw detected userInput. Returns
* null otherwise — the single signal downstream uses to decide
* whether to record it as the executed command.
*/
alignedTyped: string | null;
}
function replacePromptUserInput(
prompt: PromptDetectionResult,
userInput: string,
): PromptDetectionResult {
return {
isAtPrompt: true,
promptText: prompt.promptText,
userInput,
cursorOffset: userInput.length,
};
}
/**
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
*/
@@ -205,6 +229,92 @@ function findPromptBoundary(lineText: string): number {
return lastBoundary;
}
/**
* Reconcile a buffer-parsed prompt with the user's own keystroke history.
*
* findPromptBoundary stops at the first `PROMPT_CHAR + space` it sees, so
* themes that render additional content after the prompt char — e.g.
* oh-my-zsh's robbyrussell prints "➜ ~ " where `~` is the cwd — get
* parsed as prompt="➜ " + userInput="~ lo". Every consumer downstream
* (history recording, suggestion matching, insertion) then treats the
* theme's cwd marker as part of the user's command, which pollutes
* history with entries like "~ sudo id" and makes Tab insertions prepend
* a phantom "~ " to the typed command (issue #806).
*
* Whenever we have an independent record of what the user actually typed
* since the last Enter (keystroke buffer), we can detect this case: the
* real input is always a suffix of the over-captured userInput. When it
* is, reattribute the leading garbage back to promptText so the rest of
* the pipeline sees the clean split.
*/
export function reconcilePromptWithTypedInput(
prompt: PromptDetectionResult,
typedInput: string,
): PromptDetectionResult {
if (!prompt.isAtPrompt) return prompt;
if (!typedInput) return prompt;
if (prompt.userInput === typedInput) return prompt;
if (
prompt.userInput.length > typedInput.length &&
prompt.userInput.endsWith(typedInput)
) {
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
return {
isAtPrompt: true,
promptText: prompt.promptText + extra,
userInput: typedInput,
cursorOffset: typedInput.length,
};
}
return prompt;
}
/**
* Unified entry point for any autocomplete code path that needs a prompt
* view. Every consumer (fetchSuggestions, insertSuggestion,
* handleSubDirSelect, Enter-record) goes through this one helper so the
* alignment policy lives in exactly one place — if another out-of-band
* line-rewrite path gets added later and forgets to notify the keystroke
* buffer, the worst that happens is reconcile no-ops and we degrade to
* pre-#806 behavior, not a worse pollution.
*
* Alignment rule: the keystroke buffer is usable only when it's marked
* reliable AND the raw detected prompt still looks like the same shell
* line. When the raw buffer has either over-captured prompt chrome
* (`raw.userInput.endsWith(typedBuffer)`) or under-captured because the
* shell echo/render is lagging behind local keystrokes
* (`typedBuffer.startsWith(raw.userInput)`), prefer the typed buffer.
* Otherwise the buffer is ignored and the raw detector result passes
* through.
*/
export function getAlignedPrompt(
term: XTerm | null,
typedBuffer: string,
typedReliable: boolean,
): AlignedPromptResult {
if (!term) return { prompt: NO_PROMPT, alignedTyped: null };
const raw = detectPrompt(term);
if (!typedReliable || typedBuffer.length === 0 || !raw.isAtPrompt) {
return { prompt: raw, alignedTyped: null };
}
if (raw.userInput === typedBuffer) {
return { prompt: raw, alignedTyped: typedBuffer };
}
if (raw.userInput.length > typedBuffer.length && raw.userInput.endsWith(typedBuffer)) {
return {
prompt: reconcilePromptWithTypedInput(raw, typedBuffer),
alignedTyped: typedBuffer,
};
}
if (typedBuffer.length > raw.userInput.length && typedBuffer.startsWith(raw.userInput)) {
return {
prompt: replacePromptUserInput(raw, typedBuffer),
alignedTyped: typedBuffer,
};
}
return { prompt: raw, alignedTyped: null };
}
/**
* Simplified prompt detection: just check if we're likely at a prompt.
*/

View File

@@ -11,12 +11,14 @@
import { startTransition, useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { Terminal as XTerm } from "@xterm/xterm";
import { GhostTextAddon } from "./GhostTextAddon";
import { detectPrompt, type PromptDetectionResult } from "./promptDetector";
import { getAlignedPrompt, type PromptDetectionResult } from "./promptDetector";
import { getCompletions, parseCommandLine, type CompletionSuggestion } from "./completionEngine";
import { recordCommand } from "./commandHistoryStore";
import { shellEscape } from "./completionEngine";
import { preloadCommonSpecs } from "./figSpecLoader";
import { getXTermCellDimensions } from "./xtermUtils";
import { listDirectoryEntries, normalizePathTokenForLookup } from "./remotePathCompleter";
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
export interface AutocompleteSettings {
enabled: boolean;
@@ -64,6 +66,8 @@ export interface SubDirPanel {
dirPath: string;
}
export interface AutocompleteState {
suggestions: CompletionSuggestion[];
selectedIndex: number;
@@ -111,6 +115,13 @@ export function useTerminalAutocomplete(
...DEFAULT_AUTOCOMPLETE_SETTINGS,
...userSettings,
};
// Mutual-exclusivity guard matching the repo-wide contract:
// - SettingsTerminalTab toggles one off when the other is enabled.
// - domain/models.ts normalizes stored settings so popup wins.
// Keep the guard here too so callers that pass DEFAULT_AUTOCOMPLETE_SETTINGS
// directly (e.g. tests or future embedders) don't end up rendering both
// systems at once. In the normal Terminal.tsx → store path only one of
// the two arrives as true, so this is defensive, not load-bearing.
const settings: AutocompleteSettings = {
...rawSettings,
showGhostText: rawSettings.showPopupMenu ? false : rawSettings.showGhostText,
@@ -149,6 +160,31 @@ export function useTerminalAutocomplete(
const lastAcceptedCommandRef = useRef<string | null>(null);
/** Monotonic counter to invalidate stale async sub-dir fetches */
const subDirFetchVersionRef = useRef(0);
/**
* Keystroke buffer mirroring what the user has typed since the last
* prompt boundary (Enter / Ctrl-C / Ctrl-U / cursor movement).
*
* detectPrompt parses the xterm buffer and can misattribute theme
* content — e.g. oh-my-zsh robbyrussell's "➜ ~ " — as user input.
* Keeping an independent keystroke log lets getAlignedPrompt snap the
* detected userInput back to what was actually typed (and only when
* the buffer matches the live line's tail), which in turn keeps
* history recording and Tab insertion honest (#806).
*/
const typedInputBufferRef = useRef<string>("");
/**
* Whether typedInputBufferRef can be trusted as the full tail of the
* current command line. Cleared after any event this append-only buffer
* can't follow (history recall via ↑/Ctrl-P, cursor moves, reverse
* search, etc.). Reset to true on clean line boundaries — Enter,
* Ctrl-C, Ctrl-U — and after we explicitly re-align via
* insertSuggestion or a ghost-text accept.
*
* Without this flag, an Up-arrow-recall workflow would leave the buffer
* holding only the post-navigation suffix, and Enter would record that
* suffix as a command (pollutes history, misleads future completions).
*/
const typedBufferReliableRef = useRef<boolean>(true);
// Preload common specs on first mount (only if enabled)
useEffect(() => {
@@ -203,6 +239,17 @@ export function useTerminalAutocomplete(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, settings.enabled]);
// Hide any active ghost when the user turns showGhostText off mid-
// session. The fetchSuggestions branch (~L531) already gates new
// shows on the flag, but a ghost that was already on screen at toggle
// time would otherwise keep sliding around under a disabled setting
// until something unrelated called clearState (Codex #815 P2).
useEffect(() => {
if (!settings.showGhostText) {
ghostAddonRef.current?.hide();
}
}, [settings.showGhostText]);
/**
* Write accepted text to the terminal via callback (no CustomEvent).
*/
@@ -246,8 +293,12 @@ export function useTerminalAutocomplete(
return;
}
const term = termRef.current;
const livePrompt = term ? detectPrompt(term) : null;
const activePrompt = livePrompt?.isAtPrompt ? livePrompt : lastPromptRef.current;
const { prompt: livePrompt } = getAlignedPrompt(
term,
typedInputBufferRef.current,
typedBufferReliableRef.current,
);
const activePrompt = livePrompt.isAtPrompt ? livePrompt : lastPromptRef.current;
const activeWord = activePrompt?.isAtPrompt
? parseCommandLine(activePrompt.userInput).currentWord
: parseCommandLine(item.text).currentWord;
@@ -396,8 +447,10 @@ export function useTerminalAutocomplete(
const panel = s.subDirPanels[level];
if (!panel) return;
// Get current prompt to know what command prefix to keep (e.g., "cd ")
const prompt = detectPrompt(term);
// Get current prompt to know what command prefix to keep (e.g., "cd ").
// getAlignedPrompt handles robbyrussell-style themes by trimming the
// cwd marker out of userInput when the typed buffer is aligned (#806).
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
if (!prompt.isAtPrompt) return;
// Find the command part (everything before the path argument)
@@ -412,9 +465,9 @@ export function useTerminalAutocomplete(
: "";
const quoteSuffix = quotePrefix && currentToken.endsWith(quotePrefix) ? quotePrefix : "";
const suffix = entry.type === "directory" ? "/" : "";
const entryName = quotePrefix || !entry.name.includes(" ")
const entryName = quotePrefix || !/[\\$'"|!<>;#~` ]/.test(entry.name)
? entry.name
: entry.name.replace(/ /g, "\\ ");
: shellEscape(entry.name);
const fullPath = panel.dirPath + entryName + suffix;
const replacementPath = `${quotePrefix}${fullPath}${quoteSuffix}`;
@@ -423,7 +476,13 @@ export function useTerminalAutocomplete(
const clearSeq = isWindows
? "\b".repeat(prompt.userInput.length)
: "\x15";
writeToTerminal(clearSeq + cmdPrefix + replacementPath);
const newCommand = cmdPrefix + replacementPath;
writeToTerminal(clearSeq + newCommand);
// Sub-dir selection rewrote the whole command line; re-align the
// keystroke buffer so the next Enter records the executed command
// instead of whatever partial input we had before (P2 from #814).
typedInputBufferRef.current = newCommand;
typedBufferReliableRef.current = true;
clearState();
if (entry.type === "directory") {
@@ -444,7 +503,7 @@ export function useTerminalAutocomplete(
// Capture version at start — if it changes during async work, discard results
const version = ++fetchVersionRef.current;
const prompt = detectPrompt(term);
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
lastPromptRef.current = prompt;
if (!prompt.isAtPrompt || prompt.userInput.length < settingsRef.current.minChars) {
@@ -485,16 +544,24 @@ export function useTerminalAutocomplete(
// Discard stale results: if the user kept typing while getCompletions was running,
// the current prompt input will have changed. Re-detect and compare.
const currentPrompt = detectPrompt(term);
const { prompt: currentPrompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
if (!currentPrompt.isAtPrompt || currentPrompt.userInput !== input) {
return; // Input changed — these completions are stale
}
// Ghost text: use the best suggestion
if (settingsRef.current.showGhostText && completions.length > 0) {
ghostAddonRef.current?.show(completions[0].text, input);
} else {
ghostAddonRef.current?.hide();
// Ghost text: keep the active prediction stable while the user's
// input still fits within it. Only swap to a fresh prediction once
// the current one no longer matches the typed prefix.
if (settingsRef.current.showGhostText) {
const ghost = ghostAddonRef.current;
const activeSuggestion = ghost?.isActive() ? ghost.getSuggestion() : null;
const nextSuggestion = completions.length > 0 ? completions[0].text : null;
const ghostDecision = decideGhostSuggestion(activeSuggestion, input, nextSuggestion);
if (ghostDecision.type === "show") {
ghost?.show(ghostDecision.suggestion, input);
} else if (ghostDecision.type === "hide") {
ghost?.hide();
}
}
// Popup
@@ -568,23 +635,136 @@ export function useTerminalAutocomplete(
if (lastAcceptedCommandRef.current) {
recordCommand(lastAcceptedCommandRef.current, hostIdRef.current, hostOsRef.current);
} else {
// Try real-time detection; fall back to cached prompt
const livePrompt = termRef.current ? detectPrompt(termRef.current) : null;
const prompt = (livePrompt?.isAtPrompt && livePrompt.userInput.trim())
? livePrompt
: lastPromptRef.current;
if (prompt?.isAtPrompt && prompt.userInput.trim()) {
recordCommand(prompt.userInput.trim(), hostIdRef.current, hostOsRef.current);
// Require a live prompt before trusting either keystroke buffer
// or buffer-based detection — otherwise sudo password Enter
// would record the typed password as a command.
const { prompt: livePrompt, alignedTyped } = getAlignedPrompt(
termRef.current,
typedInputBufferRef.current,
typedBufferReliableRef.current,
);
if (livePrompt.isAtPrompt) {
// alignedTyped is only non-null when the buffer is reliable
// AND matches the live line's tail — that single signal
// covers both the robbyrussell "~ " case (#806) and the
// stale-buffer cases from out-of-band pastes / history
// recall (#814 P1/P2). When it's null we fall back to the
// reconciled livePrompt.userInput, which for paste-bypass
// scenarios lands on pre-PR behavior (no regression).
if (alignedTyped && alignedTyped.trim()) {
recordCommand(alignedTyped.trim(), hostIdRef.current, hostOsRef.current);
} else if (livePrompt.userInput.trim()) {
recordCommand(livePrompt.userInput.trim(), hostIdRef.current, hostOsRef.current);
}
} else if (lastPromptRef.current?.isAtPrompt && lastPromptRef.current.userInput.trim()) {
// Only fall back to the cached prompt when we have no live
// reading at all — guards against recording during interactive
// prompts where detectPrompt correctly bails out.
recordCommand(lastPromptRef.current.userInput.trim(), hostIdRef.current, hostOsRef.current);
}
}
lastAcceptedCommandRef.current = null;
}
typedInputBufferRef.current = "";
typedBufferReliableRef.current = true;
clearState();
return;
}
// Ctrl+C, Ctrl+U — clear
// Ctrl+C, Ctrl+U — clear. These kill the zle line entirely, so the
// buffer is once again a true reflection of the (empty) line.
if (data === "\x03" || data === "\x15") {
typedInputBufferRef.current = "";
typedBufferReliableRef.current = true;
// Same rationale as the ctrl/escape early returns below: any
// previously-accepted suggestion is gone from the line too, so
// accept → Ctrl-C → type "foo" → Enter must not log the stale
// accepted command via the Enter fast path.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
// Backspace / DEL: drop the last typed char so the buffer stays aligned
// with what the shell actually holds.
if (data === "\x7f" || data === "\b") {
typedInputBufferRef.current = typedInputBufferRef.current.slice(0, -1);
} else if (data === "\x17") {
// Ctrl+W: word-erase — kill the trailing whitespace + word.
typedInputBufferRef.current = typedInputBufferRef.current.replace(/\s*\S+\s*$/, "");
} else if (data.startsWith("\x1b[200~")) {
// Bracketed paste: "\x1b[200~...\x1b[201~". The inner bytes are
// literal input, so newlines stay on the zle line instead of
// executing each segment — meaning we must preserve the whole
// content in the buffer, not just the post-final-newline tail
// (Codex #814 P2).
//
// Reliability is *inherited*, not reset: if the buffer was
// already aligned with the line (reliable=true), appending this
// paste keeps it aligned; if the buffer was unreliable (e.g.
// after ↑ recalled a history command so line ≠ buffer), the
// paste only extends the tail but the head is still whatever
// the shell had, so the buffer stays unreliable. Without this,
// a paste-after-recall flow would flip reliability back on and
// Enter would record just the pasted suffix as the command
// (Codex #814 P1 follow-up).
const endIdx = data.indexOf("\x1b[201~");
const content = endIdx >= 0
? data.slice("\x1b[200~".length, endIdx)
: data.slice("\x1b[200~".length);
typedInputBufferRef.current += content;
// Paste extends the line past whatever was accepted, so the
// Enter fast-path must not record the pre-paste accepted
// command — mirrors the non-bracketed paste branch below.
lastAcceptedCommandRef.current = null;
clearState();
return;
} else if (data.startsWith("\x1b") && data !== "\x1b") {
// Cursor-movement / function keys — we lose track of where the
// cursor sits relative to our append-only buffer. Mark the
// buffer unreliable and drop it; detectPrompt takes over until
// the next Enter / Ctrl-C / Ctrl-U.
typedInputBufferRef.current = "";
typedBufferReliableRef.current = false;
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
typedInputBufferRef.current += data;
} else if (data.length > 1 && !data.startsWith("\x1b")) {
// Paste chunk. Any \r / \n inside executes the preceding text as
// a command in the shell, so keeping the pre-newline portion in
// our buffer would leave stale content that a later Enter could
// record (Codex #814 P2). Drop everything up to and including
// the last terminator and keep only the tail as new content.
// Intermediate executed lines aren't synthesized back into
// recordCommand here — the onCommandExecuted path in
// createXTermRuntime still captures them independently.
const lastCR = data.lastIndexOf("\r");
const lastLF = data.lastIndexOf("\n");
const nlIdx = Math.max(lastCR, lastLF);
if (nlIdx >= 0) {
typedInputBufferRef.current = data.slice(nlIdx + 1);
typedBufferReliableRef.current = true;
// The embedded newline flushed any previously-accepted
// suggestion too — clearing the cache here prevents the next
// Enter from falling into the lastAcceptedCommandRef fast path
// and recording that stale command.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
typedInputBufferRef.current += data;
} else if (data.length === 1 && data.charCodeAt(0) < 32) {
// Any other single control char (Ctrl-A, Ctrl-E, Ctrl-B, Ctrl-F,
// Ctrl-R, Ctrl-P, Ctrl-N, ...) moves the cursor or swaps the
// line in ways this append-only buffer can't follow. Same story
// as escape sequences above — and hide the ghost too, so the
// unreliable-accept fallback doesn't pull a stale tail onto a
// recalled line (Codex #815 follow-up).
typedInputBufferRef.current = "";
typedBufferReliableRef.current = false;
// Null the fast-path accepted-command cache: accept-then-Ctrl-R
// should not let an old accepted command sneak back in via the
// Enter fast path after reverse-search picks a different one.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
@@ -593,6 +773,10 @@ export function useTerminalAutocomplete(
// since cursor position may have changed, making current suggestions invalid.
// Up/Down/Right/Tab are handled by handleKeyEvent; other sequences land here.
if (data.startsWith("\x1b") && data !== "\x1b") {
// Same fast-path reset as the single-byte ctrl-char branch above —
// accept-then-↑/↓ must not record the stale accepted command if
// the user then presses Enter on a different recalled line.
lastAcceptedCommandRef.current = null;
clearState();
return;
}
@@ -601,6 +785,20 @@ export function useTerminalAutocomplete(
// command is being edited further (e.g., accepted "git status" then added " --short")
lastAcceptedCommandRef.current = null;
// Re-align any visible ghost text to the freshly-updated buffer
// immediately. Without this the ghost keeps the tail it captured at
// show() time; a fast "type + press →" sequence then pastes the
// pre-update tail on top of the new input ("doc" + "cker ls" →
// "doccker ls"). Only safe to call when the buffer is reliable —
// otherwise its content doesn't correspond to the live line and
// adjustToInput would make the ghost lie. Also skip when the user
// has turned showGhostText off mid-session: otherwise a ghost that
// was active before the toggle would keep moving around under a
// setting the user just said to disable (Codex #815 P2).
if (typedBufferReliableRef.current && settingsRef.current.showGhostText) {
ghostAddonRef.current?.adjustToInput(typedInputBufferRef.current);
}
// Fast typing suppression: if typing faster than threshold, skip this debounce cycle
const isFastTyping = timeSinceLastKeystroke < settingsRef.current.fastTypingThresholdMs;
@@ -654,15 +852,46 @@ export function useTerminalAutocomplete(
return false;
}
}
// Otherwise: accept ghost text
if (ghost?.isVisible()) {
// Otherwise: accept ghost text. Use isActive(), not isVisible(),
// so a fast "type + →" that lands in the hide-until-render gap
// still hits this branch and accepts the pending ghost.
if (ghost?.isActive()) {
e.preventDefault();
const ghostText = ghost.getGhostText();
const fullSuggestion = ghost.getSuggestion();
// When the keystroke buffer is reliable, recompute the tail
// against the *live* buffer so a fast "type + →" in the
// hide-until-render gap still writes the correct tail. When
// it's not reliable (post history-recall / Ctrl-R), we can't
// treat empty buffer as "nothing typed" — the line actually
// has content we're not tracking — so fall back to the
// ghost's own cached tail instead of writing the entire
// suggestion onto an already-populated line.
let ghostText: string;
let newBuffer: string | null;
if (typedBufferReliableRef.current) {
const live = typedInputBufferRef.current;
if (fullSuggestion && fullSuggestion.startsWith(live)) {
ghostText = fullSuggestion.substring(live.length);
newBuffer = fullSuggestion;
} else {
ghostText = "";
newBuffer = null;
}
} else {
ghostText = ghost.getGhostText();
newBuffer = null; // buffer is unreliable; don't flip it back on
}
if (ghostText) {
writeToTerminal(ghostText);
lastAcceptedCommandRef.current = ghost.getSuggestion();
lastAcceptedCommandRef.current = fullSuggestion;
if (newBuffer !== null) {
typedInputBufferRef.current = newBuffer;
typedBufferReliableRef.current = true;
}
ghost.hide();
clearState();
} else {
ghost.hide();
}
return false;
}
@@ -670,18 +899,45 @@ export function useTerminalAutocomplete(
// Ctrl+Right / Alt+Right (Mac): accept next word
if (e.key === "ArrowRight" && (e.ctrlKey || e.altKey) && !e.metaKey && !e.shiftKey) {
if (ghost?.isVisible()) {
if (ghost?.isActive()) {
e.preventDefault();
const fullSuggestion = ghost.getSuggestion();
if (!fullSuggestion) {
ghost.hide();
return false;
}
// Determine the baseline the next word should extend. Reliable
// buffer: resync the ghost to the live buffer so getNextWord
// operates on the up-to-date tail. Unreliable buffer (post
// history-recall / Ctrl-R): don't reanchor to "" — that would
// make getNextWord hand back the very first word and the shell
// would duplicate leading tokens on top of the recalled line.
// Fall back to the ghost's existing cached input instead.
if (typedBufferReliableRef.current) {
const live = typedInputBufferRef.current;
if (fullSuggestion.startsWith(live)) {
ghost.show(fullSuggestion, live);
} else {
ghost.hide();
return false;
}
}
const base = ghost.getGhostText().length > 0
? fullSuggestion.substring(0, fullSuggestion.length - ghost.getGhostText().length)
: fullSuggestion;
const nextWord = ghost.getNextWord();
if (nextWord) {
writeToTerminal(nextWord);
// Update ghost text to show remaining
const fullSuggestion = ghost.getSuggestion();
const currentInput = ghost.getGhostText().substring(nextWord.length);
if (currentInput && fullSuggestion) {
// Rebuild: the new input is old input + nextWord
const oldInput = fullSuggestion.substring(0, fullSuggestion.length - ghost.getGhostText().length);
ghost.show(fullSuggestion, oldInput + nextWord);
// Only extend the buffer if it was already aligned with the
// line — otherwise we'd end up with just the appended word,
// which the next Enter would then record as the command.
if (typedBufferReliableRef.current) {
typedInputBufferRef.current += nextWord;
}
// Shrink the ghost to reflect what's left after the accept.
const newInput = base + nextWord;
if (fullSuggestion.startsWith(newInput) && fullSuggestion.length > newInput.length) {
ghost.show(fullSuggestion, newInput);
} else {
ghost.hide();
}
@@ -702,7 +958,7 @@ export function useTerminalAutocomplete(
}
// Hide stale ghost text before Tab reaches the shell — the shell's
// completion will rewrite the line and the old ghost would mislead.
if (ghost?.isVisible()) {
if (ghost?.isActive()) {
ghost.hide();
}
}
@@ -846,8 +1102,8 @@ export function useTerminalAutocomplete(
if (!term) return;
// Always use real-time prompt detection — lastPromptRef may be stale
// if the user typed more characters after suggestions were fetched
const prompt = detectPrompt(term);
// if the user typed more characters after suggestions were fetched.
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
if (!prompt.isAtPrompt) return;
// If suggestion starts with the current input, insert only the remaining part.
@@ -871,6 +1127,18 @@ export function useTerminalAutocomplete(
writeToTerminal(payload);
}
// Keystroke buffer now reflects the accepted text (either extended by
// the insertion suffix, or wholesale replaced by the fuzzy-match path
// that emits Ctrl-U first). Re-aligning it here keeps the subsequent
// Enter-record honest, and flips reliability back on since we know
// the line content exactly.
if (execute) {
typedInputBufferRef.current = "";
} else {
typedInputBufferRef.current = suggestion.text;
}
typedBufferReliableRef.current = true;
// Track accepted command for accurate history recording on fast Enter
if (!execute) {
lastAcceptedCommandRef.current = suggestion.text;

View File

@@ -485,6 +485,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// Wrap for this terminal only, after broadcasting
const snippetIsMultiLine = snippetData.includes("\n");
if (snippetIsMultiLine && term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) snippetData = wrapBracketedPaste(snippetData);
// Notify autocomplete with the final (possibly bracket-wrapped)
// bytes so its keystroke buffer can tell literal multi-line
// paste ("\x1b[200~...\x1b[201~") from the non-bracketed path
// where each \n executes an intermediate command (#814 P2).
ctx.onAutocompleteInput?.(snippetData);
ctx.terminalBackend.writeToSession(id, snippetData);
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
const cmd = snippet.command.trim();
@@ -525,8 +530,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
navigator.clipboard.readText().then((text) => {
const id = ctx.sessionRef.current;
if (id) {
let data = normalizeLineEndings(text);
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
const rawData = normalizeLineEndings(text);
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
? wrapBracketedPaste(rawData)
: rawData;
// Notify autocomplete with the final bytes so bracketed
// pastes preserve their inner newlines as literal input.
ctx.onAutocompleteInput?.(data);
ctx.terminalBackend.writeToSession(id, data);
scrollToBottomAfterPaste();
}
@@ -537,8 +547,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const selection = term.getSelection();
const id = ctx.sessionRef.current;
if (selection && id) {
let data = normalizeLineEndings(selection);
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
const rawData = normalizeLineEndings(selection);
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
? wrapBracketedPaste(rawData)
: rawData;
ctx.onAutocompleteInput?.(data);
ctx.terminalBackend.writeToSession(id, data);
scrollToBottomAfterPaste();
}
@@ -572,8 +585,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
try {
const text = await navigator.clipboard.readText();
if (text && ctx.sessionRef.current) {
let data = normalizeLineEndings(text);
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
const rawData = normalizeLineEndings(text);
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
? wrapBracketedPaste(rawData)
: rawData;
ctx.onAutocompleteInput?.(data);
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
scrollToBottomAfterPaste();
}

View File

@@ -5,6 +5,17 @@ module.exports = {
appId: 'com.netcatty.app',
productName: 'Netcatty',
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
// Platform-split icons (#813):
// - public/icon.png keeps Apple's HIG grid margin so the rendered
// squircle sits at ~88% of the PNG canvas. macOS needs this —
// the dock renders icons with its own rounding/shadow and most
// third-party apps (#803) leave that grid margin alone so the
// squircle lines up with neighbors.
// - public/icon-win.png uses a tight-crop viewBox so the squircle
// fills 100% of the PNG. Windows / Linux taskbars render icons
// full-bleed, so the Apple margin showed up as visible padding,
// making the app icon look smaller than other apps in taskbar /
// Start menu / desktop shortcuts.
icon: 'public/icon.png',
// npmRebuild must stay enabled for macOS and Windows builds — without it,
// node-pty's native module is not recompiled for the Electron ABI, causing
@@ -84,6 +95,7 @@ module.exports = {
]
},
win: {
icon: 'public/icon-win.png',
target: [
{
target: 'nsis',
@@ -108,6 +120,10 @@ module.exports = {
shortcutName: 'Netcatty'
},
linux: {
// Linux desktop icons render full-bleed like Windows — use the
// tight-crop source so the app icon doesn't look padded in KDE /
// GNOME launchers or AppImage integrations.
icon: 'public/icon-win.png',
target: ['AppImage', 'deb', 'rpm'],
category: 'Development'
},

View File

@@ -10,9 +10,29 @@
"use strict";
const crypto = require("crypto");
const { StringDecoder } = require("node:string_decoder");
const iconv = require("iconv-lite");
const { stripAnsi } = require("./shellUtils.cjs");
const { classifyLocalShellType } = require("../../../lib/localShell.cjs");
// Build a stateful decoder for a full exec call. Serial data events can
// split multi-byte characters across chunks (very common on GBK/GB18030
// consoles), and a stateless iconv.decode per chunk would emit
// replacement bytes for the leading half. StringDecoder and
// iconv.getDecoder both preserve partial-byte state across write() calls
// and flush any trailing bytes on end(), which is what we need.
function createStatefulDecoder(encoding) {
const enc = encoding || "utf8";
if (Buffer.isEncoding(enc)) {
return new StringDecoder(enc);
}
try {
return iconv.getDecoder(enc);
} catch {
return new StringDecoder("utf8");
}
}
function detectShellKind(shellPath, platform = process.platform) {
return classifyLocalShellType(shellPath, platform);
}
@@ -943,6 +963,7 @@ function execViaRawPty(serialPort, command, options) {
let overallTimer = null;
let idleTimer = null;
const cleanupFns = [];
const decoder = createStatefulDecoder(encoding);
function safeWrite(data) {
try {
@@ -962,7 +983,12 @@ function execViaRawPty(serialPort, command, options) {
trackForCancellation.delete(cancelKey);
}
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
// Flush any bytes the decoder is still holding (e.g. the leading
// half of a multi-byte char that arrived right before finish).
let tail = "";
try { tail = decoder.end() || ""; } catch { /* ignore */ }
const complete = (stdout || "") + tail;
let cleaned = stripAnsi(complete).replace(/\r/g, "");
// Strip echoed command from the beginning of output.
// Network devices typically echo back the typed command on the first line,
@@ -1011,8 +1037,11 @@ function execViaRawPty(serialPort, command, options) {
const MAX_OUTPUT_BYTES = 512 * 1024; // 512 KB
const onData = (data) => {
// latin1 for serial ports (matches terminalBridge.cjs decoder); utf8 for SSH PTY streams.
const chunk = typeof data === "string" ? data : data.toString(encoding);
// Encoding follows the session: utf8 for SSH PTY streams, whatever the
// user picked for serial (utf-8/gb18030/...). The decoder is stateful
// so multi-byte characters split across chunks get stitched back
// together instead of emitting replacement bytes.
const chunk = typeof data === "string" ? data : decoder.write(data);
chunkCount++;
// Cancel the no-response fallback on first data
if (noResponseTimer) {

View File

@@ -182,7 +182,10 @@ async function getShellEnv() {
// On macOS/Linux, spawn a login shell to capture the real environment.
try {
const shell = process.env.SHELL || "/bin/zsh";
let shell = process.env.SHELL || "/bin/zsh";
if (!path.isAbsolute(shell) || !existsSync(shell)) {
shell = "/bin/zsh";
}
const envOutput = execFileSync(shell, ['-ilc', 'env'], {
encoding: "utf8",
timeout: 10000,

View File

@@ -9,6 +9,7 @@ const https = require("node:https");
const http = require("node:http");
const path = require("node:path");
const { URL } = require("node:url");
const { randomUUID } = require("node:crypto");
const { spawn, execFileSync } = require("node:child_process");
const fs = require("node:fs");
const { existsSync } = fs;
@@ -1854,7 +1855,7 @@ function registerHandlers(ipcMain) {
try {
const shellEnv = await getShellEnv();
const codexCliPath = resolveCliFromPath("codex", shellEnv) || "codex";
const sessionId = `codex_login_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const sessionId = `codex_login_${randomUUID()}`;
const child = spawn(codexCliPath, ["login"], {
stdio: ["ignore", "pipe", "pipe"],
env: shellEnv,

View File

@@ -589,12 +589,21 @@ function createTray() {
const resolvedIconPath = resolveTrayIconPath();
if (resolvedIconPath) {
trayIcon = nativeImage.createFromPath(resolvedIconPath);
// Resize for tray (16x16 on most platforms, 22x22 on some Linux)
if (process.platform === "darwin") {
trayIcon = trayIcon.resize({ width: 16, height: 16 });
trayIcon.setTemplateImage(true);
} else {
trayIcon = trayIcon.resize({ width: 16, height: 16 });
// Windows/Linux: attach the @2x representation so the OS can pick
// the right pixel size per DPI scale. Force-resizing to 16x16 here
// produces blurry icons on HiDPI displays where the tray slot is
// rendered larger than 16px.
const hiDpiPath = resolvedIconPath.replace(/\.png$/i, "@2x.png");
if (fs.existsSync(hiDpiPath)) {
trayIcon.addRepresentation({
scaleFactor: 2,
buffer: fs.readFileSync(hiDpiPath),
});
}
}
}

View File

@@ -97,6 +97,7 @@ function createElectronStub() {
return this;
},
setTemplateImage() {},
addRepresentation() {},
};
},
createEmpty() {

View File

@@ -10,6 +10,7 @@ const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo";
const GOOGLE_DRIVE_API = "https://www.googleapis.com/drive/v3";
const GOOGLE_DRIVE_UPLOAD_API = "https://www.googleapis.com/upload/drive/v3";
const DEFAULT_SYNC_FILE_NAME = "netcatty-vault.json";
const { randomUUID } = require("node:crypto");
const isNonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
@@ -251,7 +252,7 @@ function registerHandlers(ipcMain, electronModule) {
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
if (!syncedFile) throw new Error("Missing syncedFile");
const boundary = `----netcatty_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const boundary = `----netcatty_${randomUUID()}`;
const metadata = JSON.stringify({
name: fileName,
parents: ["appDataFolder"],

View File

@@ -6,6 +6,8 @@
// Keyboard-interactive authentication pending requests
// Map of requestId -> { finishCallback, webContentsId, sessionId, createdAt, timeoutId }
const { randomUUID } = require("node:crypto");
const keyboardInteractiveRequests = new Map();
// TTL for abandoned requests (5 minutes)
@@ -15,7 +17,7 @@ const REQUEST_TTL_MS = 5 * 60 * 1000;
* Generate a unique request ID for keyboard-interactive requests
*/
function generateRequestId(prefix = 'ki') {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}-${randomUUID()}`;
}
/**

View File

@@ -6,6 +6,8 @@
// Passphrase request pending map
// Map of requestId -> { resolveCallback, rejectCallback, webContentsId, keyPath, createdAt, timeoutId }
const { randomUUID } = require("node:crypto");
const passphraseRequests = new Map();
// TTL for abandoned requests (2 minutes)
@@ -15,7 +17,7 @@ const REQUEST_TTL_MS = 2 * 60 * 1000;
* Generate a unique request ID for passphrase requests
*/
function generateRequestId(prefix = 'pp') {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}-${randomUUID()}`;
}
/**

View File

@@ -6,6 +6,7 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { randomUUID } = require("node:crypto");
const { pipeline } = require("node:stream/promises");
const { TextDecoder } = require("node:util");
const SftpClient = require("ssh2-sftp-client");
@@ -546,7 +547,7 @@ function buildStagedRemotePath(remotePath) {
const dir = lastSeparatorIndex >= 0 ? remotePath.slice(0, lastSeparatorIndex + 1) : "";
const baseName = lastSeparatorIndex >= 0 ? remotePath.slice(lastSeparatorIndex + 1) : remotePath;
const safeBaseName = baseName || "upload";
const stagedName = `.netcatty-upload-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}-${safeBaseName}.part`;
const stagedName = `.netcatty-upload-${randomUUID().slice(0, 8)}-${safeBaseName}.part`;
return dir ? `${dir}${stagedName}` : stagedName;
}
@@ -555,7 +556,7 @@ function buildBackupRemotePath(remotePath) {
const dir = lastSeparatorIndex >= 0 ? remotePath.slice(0, lastSeparatorIndex + 1) : "";
const baseName = lastSeparatorIndex >= 0 ? remotePath.slice(lastSeparatorIndex + 1) : remotePath;
const safeBaseName = baseName || "upload";
const backupName = `.netcatty-backup-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}-${safeBaseName}.bak`;
const backupName = `.netcatty-backup-${randomUUID().slice(0, 8)}-${safeBaseName}.bak`;
return dir ? `${dir}${backupName}` : backupName;
}
@@ -794,7 +795,7 @@ async function openSftpForSession(_event, payload) {
throwIfAborted(payload?.abortSignal);
const { sshClient } = ensureRemoteSftpSupport(sessionId);
const sftpId = `${sessionId}-sftp-${Math.random().toString(16).slice(2, 10)}`;
const sftpId = `${sessionId}-sftp-${randomUUID()}`;
const client = createSessionBackedSftpClient(sessionId, sshClient);
try {
await requireSftpChannel(client, {
@@ -1359,7 +1360,7 @@ async function connectSudoSftp(client, password) {
*/
async function openSftp(event, options) {
const client = new SftpClient();
const connId = options.sessionId || `${Date.now()}-sftp-${Math.random().toString(16).slice(2)}`;
const connId = options.sessionId || randomUUID();
// Get default keys early to use for both chain and target
const defaultKeys = await findAllDefaultPrivateKeysFromHelper();

View File

@@ -204,8 +204,9 @@ function detectPowerShell() {
}
// Fallback: well-known path.
const systemRoot = process.env.SystemRoot || "C:\\Windows";
const fallback = path.join(
process.env.SystemRoot || "C:\\Windows",
systemRoot,
"System32",
"WindowsPowerShell",
"v1.0",
@@ -266,7 +267,7 @@ function detectPwsh() {
"7",
"pwsh.exe",
);
if (fs.existsSync(fallback)) {
if (fs.existsSync(fallback) && fallback.toLowerCase().includes("powershell")) {
return {
id: "pwsh",
name: "PowerShell 7",
@@ -286,12 +287,14 @@ function detectPwsh() {
* @returns {object[]} Array of DiscoveredShell objects (may be empty).
*/
function detectWslDistros() {
const wslExe = path.join(
process.env.SystemRoot || "C:\\Windows",
"System32",
"wsl.exe",
);
if (!fs.existsSync(wslExe)) return [];
const systemRoot = process.env.SystemRoot || "C:\\Windows";
const wslExe = path.join(systemRoot, "System32", "wsl.exe");
if (
!fs.existsSync(wslExe) ||
!path.dirname(wslExe).toLowerCase().endsWith("system32")
) {
return [];
}
const distros = [];

View File

@@ -14,6 +14,17 @@ const passphraseHandler = require("./passphraseHandler.cjs");
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
const SSH_KEY_PATTERN = /^id_[\w-]+$/;
async function readFileNoFollow(filePath) {
const lstat = await fs.promises.lstat(filePath);
if (!lstat.isFile() && !lstat.isSymbolicLink()) return null;
const fd = await fs.promises.open(filePath, "r", 0o0);
try {
return await fs.promises.readFile(fd, { encoding: "utf8" });
} finally {
await fd.close();
}
}
/**
* Quick check if file content looks like an SSH private key.
* Rejects non-key files that happen to match the id_* filename pattern.
@@ -107,9 +118,8 @@ async function findDefaultPrivateKey() {
for (const name of sorted) {
const keyPath = path.join(sshDir, name);
try {
const stat = await fs.promises.stat(keyPath);
if (!stat.isFile()) continue; // Skip directories, FIFOs, sockets, etc.
const privateKey = await fs.promises.readFile(keyPath, "utf8");
const privateKey = await readFileNoFollow(keyPath);
if (!privateKey) continue;
if (!looksLikePrivateKey(privateKey)) continue;
if (isKeyEncrypted(privateKey)) continue;
return { privateKey, keyPath, keyName: name };
@@ -144,9 +154,8 @@ async function findAllDefaultPrivateKeys(options = {}) {
const promises = sorted.map(async (name) => {
const keyPath = path.join(sshDir, name);
try {
const stat = await fs.promises.stat(keyPath);
if (!stat.isFile()) return null;
const privateKey = await fs.promises.readFile(keyPath, "utf8");
const privateKey = await readFileNoFollow(keyPath);
if (!privateKey) return null;
if (!looksLikePrivateKey(privateKey)) return null;
const encrypted = isKeyEncrypted(privateKey);
if (encrypted && !includeEncrypted) {
@@ -659,4 +668,5 @@ module.exports = {
applyAuthToConnOpts,
safeSend,
requestPassphrasesForEncryptedKeys,
readFileNoFollow,
};

View File

@@ -6,7 +6,9 @@
const net = require("node:net");
const fs = require("node:fs");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const os = require("node:os");
const crypto = require("node:crypto");
const { exec } = require("node:child_process");
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
@@ -21,6 +23,7 @@ const {
requestPassphrasesForEncryptedKeys,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
getSshAgentSocket,
readFileNoFollow,
} = require("./sshAuthHelper.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
@@ -122,9 +125,8 @@ async function findDefaultPrivateKey() {
for (const name of sorted) {
const keyPath = path.join(sshDir, name);
try {
const stat = await fs.promises.stat(keyPath);
if (!stat.isFile()) continue;
const privateKey = await fs.promises.readFile(keyPath, "utf8");
const privateKey = await readFileNoFollow(keyPath);
if (!privateKey) continue;
if (!looksLikePrivateKey(privateKey)) {
log("Skipping non-key file", { keyPath, keyName: name });
continue;
@@ -168,9 +170,8 @@ async function findAllDefaultPrivateKeys() {
const promises = sorted.map(async (name) => {
const keyPath = path.join(sshDir, name);
try {
const stat = await fs.promises.stat(keyPath);
if (!stat.isFile()) return null;
const privateKey = await fs.promises.readFile(keyPath, "utf8");
const privateKey = await readFileNoFollow(keyPath);
if (!privateKey) return null;
if (!looksLikePrivateKey(privateKey)) {
log("Skipping non-key file", { keyPath, keyName: name });
return null;
@@ -251,6 +252,19 @@ const log = (msg, data) => {
console.log("[SSH]", msg, data ? JSON.stringify(data, null, 2) : "");
};
// FIPS-enabled OpenSSL builds disable MD5. Feature-detect once so the legacy
// algorithm list can skip hmac-md5 on those builds — ssh2 validates exact
// algorithm lists strictly and would otherwise throw "Unsupported algorithm"
// before the SSH handshake even starts.
let _md5Supported = null;
function md5Supported() {
if (_md5Supported === null) {
try { _md5Supported = crypto.getHashes().includes("md5"); }
catch { _md5Supported = false; }
}
return _md5Supported;
}
/**
* Build SSH algorithm configuration.
* When legacyEnabled is true, legacy algorithms are appended to each list
@@ -285,6 +299,32 @@ function buildAlgorithms(legacyEnabled) {
'rsa-sha2-512', 'rsa-sha2-256',
'ssh-rsa', 'ssh-dss',
];
// Legacy HMACs — required by very old servers (e.g. FreeBSD 6.1 OpenSSH
// ~2006, issue #807). Without hmac-sha1/md5 in the offered list, the
// handshake exchange-hash MAC never agrees and the host-key signature
// verification that depends on it fails with
// "Handshake failed: signature verification failed" — which looks like
// a host-key problem but is really a MAC negotiation mismatch.
//
// hmac-md5 is only appended when the local OpenSSL build actually
// supports MD5. FIPS-enabled Node builds disable MD5 entirely, and
// ssh2 strictly validates exact algorithm lists — listing an unavailable
// algorithm would throw "Unsupported algorithm" before any SSH
// negotiation, turning the legacy toggle into a hard failure for FIPS
// users. hmac-sha1 is allowed for HMAC even under FIPS 140-2 so it
// stays unconditionally.
// hmac-sha1-etm@openssh.com is in ssh2's default MAC set — keep it so
// hosts that only accept EtM SHA-1 MACs don't regress to "no matching
// C->S MAC" when legacy mode replaces the default list.
algorithms.hmac = [
'hmac-sha2-256-etm@openssh.com', 'hmac-sha2-512-etm@openssh.com',
'hmac-sha2-256', 'hmac-sha2-512',
'hmac-sha1-etm@openssh.com',
'hmac-sha1',
];
if (md5Supported()) {
algorithms.hmac.push('hmac-md5');
}
}
return algorithms;
@@ -648,9 +688,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
* Start an SSH session
*/
async function startSSHSession(event, options) {
const sessionId =
options.sessionId ||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
const sessionId = options.sessionId || randomUUID();
const cols = options.cols || 80;
const rows = options.rows || 24;
@@ -1558,7 +1596,7 @@ async function execCommand(event, payload) {
const baseTimeoutMs = payload.timeout || 10000;
const timeoutMs = enableKeyboardInteractive ? Math.max(baseTimeoutMs, 120000) : baseTimeoutMs;
const sender = event.sender;
const sessionId = payload.sessionId || `exec-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const sessionId = payload.sessionId || randomUUID();
const defaultKeys = enableKeyboardInteractive ? await findAllDefaultPrivateKeysFromHelper() : [];
return new Promise((resolve, reject) => {

View File

@@ -6,10 +6,12 @@
const os = require("node:os");
const fs = require("node:fs");
const net = require("node:net");
const { randomUUID } = require("node:crypto");
const path = require("node:path");
const { StringDecoder } = require("node:string_decoder");
const pty = require("node-pty");
const { SerialPort } = require("serialport");
const iconv = require("iconv-lite");
const ptyProcessTree = require("./ptyProcessTree.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
@@ -22,18 +24,18 @@ const { discoverShells } = require("./shellDiscovery.cjs");
let sessions = null;
let electronModule = null;
// Map user-facing charset names to Node.js StringDecoder/Buffer encoding names.
// Falls back to utf8 for unrecognized charsets (StringDecoder only supports a
// small set; for CJK encodings like GB18030/Big5 we'd need iconv-lite, which
// is out of scope for this change — utf8 is still the safer default).
function charsetToNodeEncoding(charset) {
if (!charset) return 'utf8';
const normalized = String(charset).trim().toLowerCase().replace(/[^a-z0-9]/g, '');
if (['utf8', 'utf-8'].includes(normalized)) return 'utf8';
if (['latin1', 'iso88591', 'iso-8859-1', 'binary'].includes(normalized)) return 'latin1';
if (normalized === 'ascii') return 'ascii';
if (['utf16le', 'ucs2'].includes(normalized)) return 'utf16le';
return 'utf8';
// Normalize user-facing charset names into an iconv-lite encoding identifier.
// iconv-lite accepts a wide range of aliases directly ("utf-8", "gbk", etc.),
// so mostly this just lowercases + collapses non-alphanumerics and maps a few
// obvious GB* variants to gb18030 which is the superset we ship the encoding
// switcher with. Anything iconv doesn't recognize falls back to utf-8.
function normalizeTerminalEncoding(charset) {
if (!charset) return 'utf-8';
const raw = String(charset).trim().toLowerCase();
const normalized = raw.replace(/[^a-z0-9]/g, '');
if (['utf8', 'utf-8'].includes(normalized)) return 'utf-8';
if (normalized === 'gb18030' || normalized === 'gbk' || normalized === 'gb2312') return 'gb18030';
return iconv.encodingExists(raw) ? raw : 'utf-8';
}
const DEFAULT_UTF8_LOCALE = "en_US.UTF-8";
@@ -151,8 +153,9 @@ function findExecutable(name) {
console.warn(`Could not find ${name} via where.exe:`, err.message);
}
// Fallback to common locations
const path = require("node:path");
if (!/^[a-zA-Z0-9._-]+$/.test(name)) return name;
const commonPaths = [];
if (name === "pwsh") {
@@ -250,9 +253,7 @@ const applyLocaleDefaults = (env) => {
* Start a local terminal session
*/
function startLocalSession(event, payload) {
const sessionId =
payload?.sessionId ||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
const sessionId = payload?.sessionId || randomUUID();
const defaultShell = getDefaultLocalShell();
// payload.shell may be a discovered shell ID (e.g., "wsl-ubuntu") — resolve it
let resolvedShell = payload?.shell;
@@ -401,9 +402,7 @@ function startLocalSession(event, payload) {
* Start a Telnet session using native Node.js net module
*/
async function startTelnetSession(event, options) {
const sessionId =
options.sessionId ||
`telnet-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const sessionId = options.sessionId || randomUUID();
const hostname = options.hostname;
const port = options.port || 23;
@@ -556,6 +555,8 @@ async function startTelnetSession(event, options) {
lastIdlePrompt: "",
lastIdlePromptAt: 0,
_promptTrackTail: "",
encoding: initialTelnetEncoding,
decoderRef: telnetDecoderRef,
};
session.flushPendingData = flushTelnet;
sessions.set(sessionId, session);
@@ -574,7 +575,11 @@ async function startTelnetSession(event, options) {
resolve({ sessionId });
});
const telnetDecoder = new StringDecoder(charsetToNodeEncoding(options.charset));
// Wrap the iconv decoder in a mutable ref so the encoding switcher
// (setSessionEncoding IPC) can swap in a fresh decoder mid-session
// without having to rewrite the closures below.
const initialTelnetEncoding = normalizeTerminalEncoding(options.charset);
const telnetDecoderRef = { current: iconv.getDecoder(initialTelnetEncoding) };
const telnetWebContentsId = event.sender.id;
const { bufferData: bufferTelnetData, flush: flushTelnet } = createPtyBuffer((data) => {
@@ -585,7 +590,7 @@ async function startTelnetSession(event, options) {
const telnetZmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const decoded = telnetDecoder.write(buf);
const decoded = telnetDecoderRef.current.write(buf);
if (!decoded) return;
const session = sessions.get(sessionId);
if (session) trackSessionIdlePrompt(session, decoded);
@@ -681,9 +686,7 @@ async function startTelnetSession(event, options) {
* Start a Mosh session using system mosh-client
*/
async function startMoshSession(event, options) {
const sessionId =
options.sessionId ||
`mosh-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const sessionId = options.sessionId || randomUUID();
const cols = options.cols || 80;
const rows = options.rows || 24;
@@ -847,9 +850,7 @@ async function listSerialPorts() {
* Note: SerialPort library can open PTY devices directly, they just won't appear in list()
*/
async function startSerialSession(event, options) {
const sessionId =
options.sessionId ||
`serial-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const sessionId = options.sessionId || randomUUID();
const portPath = options.path;
const baudRate = options.baudRate || 115200;
@@ -883,15 +884,19 @@ async function startSerialSession(event, options) {
console.log(`[Serial] Connected to ${portPath}`);
const serialEncoding = charsetToNodeEncoding(options.charset);
const serialDecoder = new StringDecoder(serialEncoding);
const initialSerialEncoding = normalizeTerminalEncoding(options.charset);
const serialDecoderRef = { current: iconv.getDecoder(initialSerialEncoding) };
const session = {
serialPort,
type: 'serial',
protocol: 'serial',
shellKind: 'raw',
serialEncoding,
encoding: initialSerialEncoding,
// Kept for backward compatibility with aiBridge / mcpServerBridge
// which read session.serialEncoding for exec calls.
serialEncoding: initialSerialEncoding,
decoderRef: serialDecoderRef,
webContentsId: event.sender.id,
};
sessions.set(sessionId, session);
@@ -910,7 +915,7 @@ async function startSerialSession(event, options) {
const serialZmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const decoded = serialDecoder.write(buf);
const decoded = serialDecoderRef.current.write(buf);
if (!decoded) return;
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data: decoded });
@@ -1055,6 +1060,32 @@ function closeSession(event, payload) {
sessions.delete(payload.sessionId);
}
/**
* Set terminal decoder encoding for an active telnet or serial session.
* SSH sessions are handled by sshBridge's own setEncoding IPC — this one
* only responds to sessions that carry a decoderRef (telnet + serial).
*/
function setSessionEncoding(_event, { sessionId, encoding }) {
const session = sessions?.get(sessionId);
if (!session || !session.decoderRef) {
return { ok: false, encoding: encoding || 'utf-8' };
}
const enc = normalizeTerminalEncoding(encoding);
if (!iconv.encodingExists(enc)) {
return { ok: false, encoding: enc };
}
session.encoding = enc;
// Keep serialEncoding mirror in sync so aiBridge / mcpServerBridge exec
// calls pick up the new encoding too.
if (session.type === 'serial') {
session.serialEncoding = enc;
}
// iconv stateful decoders carry partial-byte state from the previous
// encoding, so swap in a fresh decoder rather than reconfiguring.
session.decoderRef.current = iconv.getDecoder(enc);
return { ok: true, encoding: enc };
}
/**
* Register IPC handlers for terminal operations
*/
@@ -1067,6 +1098,7 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:local:defaultShell", getDefaultShell);
ipcMain.handle("netcatty:local:validatePath", validatePath);
ipcMain.handle("netcatty:shells:discover", () => discoverShells());
ipcMain.handle("netcatty:terminal:setEncoding", setSessionEncoding);
ipcMain.on("netcatty:write", writeToSession);
ipcMain.on("netcatty:resize", resizeSession);
ipcMain.on("netcatty:close", closeSession);

View File

@@ -1184,8 +1184,76 @@ if (!gotLock) {
}
});
app.on("before-quit", () => {
// Quit guard state:
// - quitConfirmed: once true, before-quit falls through without re-checking.
// Set right before we call app.quit() after a successful dirty-editor check,
// so the re-entered before-quit doesn't loop back into another check.
// - quitGuardChannelBusy: prevents a second check from being started while the
// first round-trip is still in flight.
// Note: both are intentionally NOT reset on the dirty=true path — if the user
// cancels quit to save, a subsequent Cmd+Q re-enters with quitConfirmed=false
// and quitGuardChannelBusy=false (reset in the once/timeout handlers), which
// kicks off a fresh check as expected.
let quitGuardChannelBusy = false;
let quitConfirmed = false;
// 5s timeout: long enough for the renderer to show a toast before reporting
// back, short enough that a hung renderer doesn't strand the app forever.
const QUIT_GUARD_TIMEOUT_MS = 5000;
// Commit the window manager to "we're quitting" state. Must only run once
// we've decided to actually proceed — if we set it unconditionally on every
// before-quit, a dirty-cancelled quit leaves isQuitting=true and changes
// later window-close behavior (e.g. close-to-tray hooks that gate on
// !isQuitting would stop firing).
const commitQuit = () => {
getWindowManager().setIsQuitting(true);
quitConfirmed = true;
app.quit();
};
app.on("before-quit", (event) => {
// Fast path: we've already confirmed the quit once (commitQuit ran) and
// app.quit() re-fired before-quit. Let it through.
if (quitConfirmed) return;
// A check is already in flight — swallow this event; the in-flight handler
// will issue commitQuit() when it completes if appropriate.
if (quitGuardChannelBusy) {
event.preventDefault();
return;
}
const { ipcMain: _ipcMain } = electronModule;
const win = BrowserWindow.getAllWindows()[0];
// No window — nothing to check; commit to quit directly.
if (!win || win.isDestroyed?.()) {
commitQuit();
return;
}
quitGuardChannelBusy = true;
event.preventDefault();
win.webContents.send("app:query-dirty-editors");
// Timeout fallback: if the renderer never replies (crash, unhandled
// exception in the listener, etc.) we'd otherwise be stuck with
// quitGuardChannelBusy=true and the app un-quittable.
const timeoutId = setTimeout(() => {
_ipcMain.removeAllListeners("app:dirty-editors-result");
quitGuardChannelBusy = false;
commitQuit();
}, QUIT_GUARD_TIMEOUT_MS);
_ipcMain.once("app:dirty-editors-result", (_evt, { hasDirty }) => {
clearTimeout(timeoutId);
quitGuardChannelBusy = false;
if (!hasDirty) {
commitQuit();
}
// If hasDirty === true the renderer has shown a toast; stay put. Do not
// touch isQuitting so tray/close-to-tray gating keeps working.
});
});
// Cleanup all PTY sessions and port forwarding tunnels before quitting

View File

@@ -1,5 +1,6 @@
const { ipcRenderer, contextBridge, webUtils } = require("electron");
const os = require("node:os");
const { randomUUID } = require("node:crypto");
const dataListeners = new Map();
const exitListeners = new Map();
@@ -598,8 +599,14 @@ const api = {
closeSession: (sessionId) => {
ipcRenderer.send("netcatty:close", { sessionId });
},
setSessionEncoding: (sessionId, encoding) =>
ipcRenderer.invoke("netcatty:ssh:setEncoding", { sessionId, encoding }),
setSessionEncoding: async (sessionId, encoding) => {
// Try the SSH handler first; it returns { ok: false } for non-SSH
// sessions (no session.stream). Telnet and serial sessions fall
// through to terminalBridge's handler.
const ssh = await ipcRenderer.invoke("netcatty:ssh:setEncoding", { sessionId, encoding });
if (ssh?.ok) return ssh;
return ipcRenderer.invoke("netcatty:terminal:setEncoding", { sessionId, encoding });
},
onZmodemEvent: (sessionId, cb) => {
if (!zmodemListeners.has(sessionId)) zmodemListeners.set(sessionId, new Set());
zmodemListeners.get(sessionId).add(cb);
@@ -894,6 +901,16 @@ const api = {
// Tell main process the renderer has mounted/painted (used to avoid initial blank screen).
rendererReady: () => ipcRenderer.send("netcatty:renderer:ready"),
// Quit guard: main process asks whether any editor tabs have unsaved changes.
// Returns an unsubscribe function so React effects can clean up on unmount.
onCheckDirtyEditors: (listener) => {
const handler = () => listener();
ipcRenderer.on("app:query-dirty-editors", handler);
return () => ipcRenderer.removeListener("app:query-dirty-editors", handler);
},
// Renderer reports the dirty-check result back to the main process.
reportDirtyEditorsResult: (hasDirty) => ipcRenderer.send("app:dirty-editors-result", { hasDirty }),
// Port Forwarding API
startPortForward: async (options) => {
@@ -928,7 +945,7 @@ const api = {
},
// Chain progress listener for jump host connections
onChainProgress: (cb) => {
const id = Date.now().toString() + Math.random().toString(16).slice(2);
const id = randomUUID();
chainProgressListeners.set(id, cb);
return () => {
chainProgressListeners.delete(id);

View File

@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
export default [
js.configs.recommended,
{
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**"],
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**", ".worktrees/**"],
},
{
files: ["**/*.{ts,tsx}"],

6
global.d.ts vendored
View File

@@ -587,6 +587,12 @@ declare global {
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
rendererReady?(): void;
// Quit guard: subscribe to main-process quit requests that query for dirty editors.
// Listener is called with no arguments; return value is an unsubscribe function.
onCheckDirtyEditors?(listener: () => void): () => void;
// Report the dirty-check result back to the main process.
reportDirtyEditorsResult?(hasDirty: boolean): void;
onLanguageChanged?(cb: (language: string) => void): () => void;
// Chain progress listener for jump host connections

442
package-lock.json generated
View File

@@ -74,7 +74,7 @@
"@withfig/autocomplete-types": "^1.31.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^40.1.0",
"electron": "^40.8.5",
"electron-builder": "^26.0.12",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -83,7 +83,7 @@
"tailwindcss": "^4.1.17",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vite": "^7.2.7",
"vite": "^7.3.2",
"wait-on": "^9.0.3"
},
"optionalDependencies": {
@@ -1475,9 +1475,9 @@
}
},
"node_modules/@electron/asar/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1486,9 +1486,9 @@
}
},
"node_modules/@electron/asar/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -2356,9 +2356,9 @@
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2367,9 +2367,9 @@
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -2430,9 +2430,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2451,9 +2451,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -2660,9 +2660,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -3027,29 +3027,6 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -4431,9 +4408,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
"integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
"cpu": [
"arm"
],
@@ -4445,9 +4422,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
"integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
"cpu": [
"arm64"
],
@@ -4459,9 +4436,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
"integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
"cpu": [
"arm64"
],
@@ -4473,9 +4450,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
"integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
"cpu": [
"x64"
],
@@ -4487,9 +4464,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
"integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
"cpu": [
"arm64"
],
@@ -4501,9 +4478,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
"integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
"cpu": [
"x64"
],
@@ -4515,9 +4492,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
"integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
"cpu": [
"arm"
],
@@ -4529,9 +4506,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
"integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
"cpu": [
"arm"
],
@@ -4543,9 +4520,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
"integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
"cpu": [
"arm64"
],
@@ -4557,9 +4534,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
"integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
"cpu": [
"arm64"
],
@@ -4571,9 +4548,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
"integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
"cpu": [
"loong64"
],
@@ -4585,9 +4562,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
"integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
"cpu": [
"loong64"
],
@@ -4599,9 +4576,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
"integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
"cpu": [
"ppc64"
],
@@ -4613,9 +4590,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
"integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
"cpu": [
"ppc64"
],
@@ -4627,9 +4604,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
"integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
"cpu": [
"riscv64"
],
@@ -4641,9 +4618,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
"integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
"cpu": [
"riscv64"
],
@@ -4655,9 +4632,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
"integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
"cpu": [
"s390x"
],
@@ -4669,9 +4646,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
"integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
"cpu": [
"x64"
],
@@ -4683,9 +4660,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
"integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
"cpu": [
"x64"
],
@@ -4697,9 +4674,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
"integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
"cpu": [
"x64"
],
@@ -4711,9 +4688,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
"integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
"cpu": [
"arm64"
],
@@ -4725,9 +4702,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
"integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
"cpu": [
"arm64"
],
@@ -4739,9 +4716,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
"integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
"cpu": [
"ia32"
],
@@ -4753,9 +4730,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
"integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
"cpu": [
"x64"
],
@@ -4767,9 +4744,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
"integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
"cpu": [
"x64"
],
@@ -6762,9 +6739,9 @@
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7260,6 +7237,29 @@
"semver": "bin/semver.js"
}
},
"node_modules/app-builder-lib/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/app-builder-lib/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/app-builder-lib/node_modules/ci-info": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz",
@@ -7325,16 +7325,16 @@
}
},
"node_modules/app-builder-lib/node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -7589,9 +7589,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -8628,9 +8628,9 @@
}
},
"node_modules/dir-compare/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8639,9 +8639,9 @@
}
},
"node_modules/dir-compare/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -8823,9 +8823,9 @@
}
},
"node_modules/electron": {
"version": "40.1.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-40.1.0.tgz",
"integrity": "sha512-2j/kvw7uF0H1PnzYBzw2k2Q6q16J8ToKrtQzZfsAoXbbMY0l5gQi2DLOauIZLzwp4O01n8Wt/74JhSRwG0yj9A==",
"version": "40.8.5",
"resolved": "https://registry.npmjs.org/electron/-/electron-40.8.5.tgz",
"integrity": "sha512-pgTY/VPQKaiU4sTjfU96iyxCXrFm4htVPCMRT4b7q9ijNTRgtLmLvcmzp2G4e7xDrq9p7OLHSmu1rBKFf6Y1/A==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -9410,9 +9410,9 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9444,9 +9444,9 @@
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -9832,9 +9832,9 @@
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -9920,16 +9920,16 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"dev": true,
"funding": [
{
@@ -10253,9 +10253,9 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10264,9 +10264,9 @@
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -10663,9 +10663,9 @@
}
},
"node_modules/hono": {
"version": "4.12.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"version": "4.12.14",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -11667,9 +11667,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
@@ -12846,12 +12846,12 @@
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -13685,9 +13685,9 @@
"license": "ISC"
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -13724,9 +13724,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14484,9 +14484,9 @@
}
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
"integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14500,31 +14500,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.1",
"@rollup/rollup-android-arm64": "4.57.1",
"@rollup/rollup-darwin-arm64": "4.57.1",
"@rollup/rollup-darwin-x64": "4.57.1",
"@rollup/rollup-freebsd-arm64": "4.57.1",
"@rollup/rollup-freebsd-x64": "4.57.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1",
"@rollup/rollup-linux-x64-musl": "4.57.1",
"@rollup/rollup-openbsd-x64": "4.57.1",
"@rollup/rollup-openharmony-arm64": "4.57.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1",
"@rollup/rollup-android-arm-eabi": "4.60.2",
"@rollup/rollup-android-arm64": "4.60.2",
"@rollup/rollup-darwin-arm64": "4.60.2",
"@rollup/rollup-darwin-x64": "4.60.2",
"@rollup/rollup-freebsd-arm64": "4.60.2",
"@rollup/rollup-freebsd-x64": "4.60.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
"@rollup/rollup-linux-arm-musleabihf": "4.60.2",
"@rollup/rollup-linux-arm64-gnu": "4.60.2",
"@rollup/rollup-linux-arm64-musl": "4.60.2",
"@rollup/rollup-linux-loong64-gnu": "4.60.2",
"@rollup/rollup-linux-loong64-musl": "4.60.2",
"@rollup/rollup-linux-ppc64-gnu": "4.60.2",
"@rollup/rollup-linux-ppc64-musl": "4.60.2",
"@rollup/rollup-linux-riscv64-gnu": "4.60.2",
"@rollup/rollup-linux-riscv64-musl": "4.60.2",
"@rollup/rollup-linux-s390x-gnu": "4.60.2",
"@rollup/rollup-linux-x64-gnu": "4.60.2",
"@rollup/rollup-linux-x64-musl": "4.60.2",
"@rollup/rollup-openbsd-x64": "4.60.2",
"@rollup/rollup-openharmony-arm64": "4.60.2",
"@rollup/rollup-win32-arm64-msvc": "4.60.2",
"@rollup/rollup-win32-ia32-msvc": "4.60.2",
"@rollup/rollup-win32-x64-gnu": "4.60.2",
"@rollup/rollup-win32-x64-msvc": "4.60.2",
"fsevents": "~2.3.2"
}
},
@@ -15331,9 +15331,9 @@
}
},
"node_modules/tar": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@@ -15498,9 +15498,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -16068,9 +16068,9 @@
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -16161,9 +16161,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -16363,9 +16363,9 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"

4
package.json Executable file → Normal file
View File

@@ -94,7 +94,7 @@
"@withfig/autocomplete-types": "^1.31.0",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^40.1.0",
"electron": "^40.8.5",
"electron-builder": "^26.0.12",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -103,7 +103,7 @@
"tailwindcss": "^4.1.17",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vite": "^7.2.7",
"vite": "^7.3.2",
"wait-on": "^9.0.3"
},
"optionalDependencies": {

BIN
public/icon-win.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

50
public/icon-win.svg Normal file
View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="100 100 824 824" width="1024" height="1024">
<defs>
<clipPath id="round">
<rect x="100.0" y="100.0" width="824" height="824" rx="185" ry="185" />
</clipPath>
</defs>
<g clip-path="url(#round)">
<rect x="100.0" y="100.0" width="824" height="824" fill="#002551" />
<g transform="translate(161.80 161.80) scale(0.5585)">
<g><path style="opacity:1" fill="#f9f9f9" d="M 618.5,240.5 C 647.925,240.677 677.258,242.344 706.5,245.5C 753.323,252.113 798.49,265.113 842,284.5C 870.064,257.538 902.23,236.704 938.5,222C 966.969,211.263 988.469,219.096 1003,245.5C 1011.08,263.079 1016.75,281.412 1020,300.5C 1022.13,320.204 1024.29,339.871 1026.5,359.5C 1026.17,379.674 1026.5,399.674 1027.5,419.5C 1072.74,473.648 1102.74,535.314 1117.5,604.5C 1117.29,607.495 1117.96,610.162 1119.5,612.5C 1126.08,656.83 1126.08,701.163 1119.5,745.5C 1118.23,747.905 1117.57,750.572 1117.5,753.5C 1107.38,802.706 1088.05,847.872 1059.5,889C 1053.04,888.572 1046.71,887.405 1040.5,885.5C 1036.79,883.864 1032.79,883.198 1028.5,883.5C 1011.79,881.938 995.122,882.271 978.5,884.5C 975.572,884.565 972.905,885.232 970.5,886.5C 928.686,895.489 896.519,918.156 874,954.5C 864.791,970.962 859.958,988.628 859.5,1007.5C 793.269,1029.39 725.269,1041.72 655.5,1044.5C 633.833,1044.5 612.167,1044.5 590.5,1044.5C 524.821,1041.8 460.821,1029.63 398.5,1008C 396.254,996.177 393.421,984.344 390,972.5C 387.524,964.881 384.024,957.881 379.5,951.5C 363.815,925.334 341.815,906.667 313.5,895.5C 297.343,888.573 280.343,884.406 262.5,883C 248.055,882.038 233.722,882.538 219.5,884.5C 216.572,884.565 213.905,885.232 211.5,886.5C 211.167,886.5 210.833,886.5 210.5,886.5C 207.848,886.41 205.515,887.076 203.5,888.5C 200.823,889.614 198.156,889.614 195.5,888.5C 149.432,819.968 128.098,744.301 131.5,661.5C 131.502,654.48 131.835,647.48 132.5,640.5C 133.461,638.735 133.795,636.735 133.5,634.5C 135.136,630.79 135.802,626.79 135.5,622.5C 137.764,609.333 140.431,596.333 143.5,583.5C 144.924,581.485 145.59,579.152 145.5,576.5C 156.228,537.714 172.395,501.381 194,467.5C 204.685,451.452 215.852,435.786 227.5,420.5C 228.042,388.62 229.375,356.62 231.5,324.5C 234.549,300.253 240.382,276.586 249,253.5C 253.868,241.906 261.035,232.073 270.5,224C 279.336,218.042 289.002,216.042 299.5,218C 314.655,220.607 328.988,225.607 342.5,233C 368.29,247.23 391.957,264.396 413.5,284.5C 478.68,255.797 547.014,241.13 618.5,240.5 Z"/></g>
<g><path style="opacity:1" fill="#1f2657" d="M 706.5,245.5 C 677.258,242.344 647.925,240.677 618.5,240.5C 649.662,238.284 680.995,239.784 712.5,245C 710.527,245.495 708.527,245.662 706.5,245.5 Z"/></g>
<g><path style="opacity:1" fill="#18214c" d="M 231.5,324.5 C 229.375,356.62 228.042,388.62 227.5,420.5C 226.104,392.965 226.604,365.298 229,337.5C 229.17,331.677 230.003,327.344 231.5,324.5 Z"/></g>
<g><path style="opacity:1" fill="#0c1943" d="M 1026.5,359.5 C 1027.92,371.971 1028.59,384.637 1028.5,397.5C 1028.5,405.008 1028.17,412.341 1027.5,419.5C 1026.5,399.674 1026.17,379.674 1026.5,359.5 Z"/></g>
<g><path style="opacity:1" fill="#505c83" d="M 817.5,544.5 C 815.162,546.04 812.495,546.706 809.5,546.5C 811.905,545.232 814.572,544.565 817.5,544.5 Z"/></g>
<g><path style="opacity:1" fill="#919ab0" d="M 445.5,545.5 C 448.152,545.41 450.485,546.076 452.5,547.5C 449.848,547.59 447.515,546.924 445.5,545.5 Z"/></g>
<g><path style="opacity:1" fill="#022551" d="M 445.5,545.5 C 447.515,546.924 449.848,547.59 452.5,547.5C 479.103,555.885 499.269,572.218 513,596.5C 515.435,607.525 511.268,614.191 500.5,616.5C 497.302,616.378 494.302,615.545 491.5,614C 485.302,604.13 477.969,595.13 469.5,587C 459.207,579.735 447.873,574.902 435.5,572.5C 415.88,568.656 398.213,573.156 382.5,586C 380.905,585.383 379.572,585.716 378.5,587C 378.957,587.414 379.291,587.914 379.5,588.5C 376.839,591.423 374.005,593.423 371,594.5C 369.606,600.126 366.772,603.96 362.5,606C 363.517,607.049 363.684,608.216 363,609.5C 355.276,616.472 347.943,616.139 341,608.5C 339.805,603.4 340.638,598.733 343.5,594.5C 344.086,594.709 344.586,595.043 345,595.5C 344.718,590.888 346.551,587.055 350.5,584C 351.515,582.627 351.515,581.46 350.5,580.5C 375.329,550.884 406.995,539.218 445.5,545.5 Z"/></g>
<g><path style="opacity:1" fill="#032551" d="M 817.5,544.5 C 862.791,541.392 895.958,559.726 917,599.5C 917.138,612.028 910.971,617.528 898.5,616C 897.167,615.333 895.833,614.667 894.5,614C 884.255,595.245 869.255,582.078 849.5,574.5C 843.812,571.54 837.645,570.207 831,570.5C 822.066,570.919 813.233,572.086 804.5,574C 798.217,577.721 792.05,581.554 786,585.5C 785.667,585.167 785.333,584.833 785,584.5C 782.92,587.065 781.087,589.732 779.5,592.5C 774.384,597.792 770.218,603.792 767,610.5C 759.55,618.016 751.883,618.349 744,611.5C 742.878,609.593 742.045,607.593 741.5,605.5C 741.508,602.455 741.841,599.455 742.5,596.5C 757.037,569.397 779.371,552.73 809.5,546.5C 812.495,546.706 815.162,546.04 817.5,544.5 Z"/></g>
<g><path style="opacity:1" fill="#0c1a4d" d="M 849.5,574.5 C 822.908,568.314 799.574,574.314 779.5,592.5C 781.087,589.732 782.92,587.065 785,584.5C 785.333,584.833 785.667,585.167 786,585.5C 792.05,581.554 798.217,577.721 804.5,574C 813.233,572.086 822.066,570.919 831,570.5C 837.645,570.207 843.812,571.54 849.5,574.5 Z"/></g>
<g><path style="opacity:1" fill="#98a2bf" d="M 423.5,572.5 C 419.684,573.482 415.684,574.149 411.5,574.5C 415.183,572.75 419.183,572.083 423.5,572.5 Z"/></g>
<g><path style="opacity:1" fill="#9ea6be" d="M 145.5,576.5 C 145.59,579.152 144.924,581.485 143.5,583.5C 143.41,580.848 144.076,578.515 145.5,576.5 Z"/></g>
<g><path style="opacity:1" fill="#132152" d="M 435.5,572.5 C 431.5,572.5 427.5,572.5 423.5,572.5C 419.183,572.083 415.183,572.75 411.5,574.5C 389.242,579.57 372.909,592.403 362.5,613C 356.408,617.241 350.075,617.574 343.5,614C 337.996,608.137 337.163,601.637 341,594.5C 343.929,589.631 347.096,584.965 350.5,580.5C 351.515,581.46 351.515,582.627 350.5,584C 346.551,587.055 344.718,590.888 345,595.5C 344.586,595.043 344.086,594.709 343.5,594.5C 340.638,598.733 339.805,603.4 341,608.5C 347.943,616.139 355.276,616.472 363,609.5C 363.684,608.216 363.517,607.049 362.5,606C 366.772,603.96 369.606,600.126 371,594.5C 374.005,593.423 376.839,591.423 379.5,588.5C 379.291,587.914 378.957,587.414 378.5,587C 379.572,585.716 380.905,585.383 382.5,586C 398.213,573.156 415.88,568.656 435.5,572.5 Z"/></g>
<g><path style="opacity:1" fill="#6c7794" d="M 742.5,596.5 C 741.841,599.455 741.508,602.455 741.5,605.5C 740.848,604.551 740.514,603.385 740.5,602C 740.393,599.779 741.06,597.946 742.5,596.5 Z"/></g>
<g><path style="opacity:1" fill="#6f7b97" d="M 1117.5,604.5 C 1118.77,606.905 1119.43,609.572 1119.5,612.5C 1117.96,610.162 1117.29,607.495 1117.5,604.5 Z"/></g>
<g><path style="opacity:1" fill="#a8aec5" d="M 135.5,622.5 C 135.802,626.79 135.136,630.79 133.5,634.5C 133.717,630.295 134.383,626.295 135.5,622.5 Z"/></g>
<g><path style="opacity:1" fill="#677393" d="M 653.5,662.5 C 634.473,662.218 615.473,662.551 596.5,663.5C 597.263,662.732 598.263,662.232 599.5,662C 617.671,661.171 635.671,661.338 653.5,662.5 Z"/></g>
<g><path style="opacity:1" fill="#032551" d="M 653.5,662.5 C 664.536,665.228 669.036,672.228 667,683.5C 665.861,687.112 664.194,690.446 662,693.5C 656.35,700.317 650.184,706.65 643.5,712.5C 643.058,737.755 654.725,754.922 678.5,764C 709.272,768.521 729.105,756.021 738,726.5C 747.413,717.842 755.746,718.842 763,729.5C 759.409,758.463 743.909,778.297 716.5,789C 713.111,789.776 709.778,790.609 706.5,791.5C 697.533,792.383 688.533,792.716 679.5,792.5C 657.328,788.994 639.828,777.994 627,759.5C 607.084,786.202 580.584,797.035 547.5,792C 516.901,784.235 497.901,765.068 490.5,734.5C 493.257,721.955 500.59,718.121 512.5,723C 517.164,727.124 519.998,732.291 521,738.5C 533.515,761.003 552.348,769.17 577.5,763C 599.78,754.048 610.947,737.548 611,713.5C 604.698,706.197 598.032,699.197 591,692.5C 586.824,686.46 585.491,679.794 587,672.5C 589.072,668.26 592.238,665.26 596.5,663.5C 615.473,662.551 634.473,662.218 653.5,662.5 Z"/></g>
<g><path style="opacity:1" fill="#01103f" d="M 132.5,640.5 C 131.835,647.48 131.502,654.48 131.5,661.5C 130.669,675.994 130.169,690.661 130,705.5C 128.188,682.722 128.854,660.055 132,637.5C 132.483,638.448 132.649,639.448 132.5,640.5 Z"/></g>
<g><path style="opacity:1" fill="#7c869d" d="M 1119.5,745.5 C 1119.71,748.495 1119.04,751.162 1117.5,753.5C 1117.57,750.572 1118.23,747.905 1119.5,745.5 Z"/></g>
<g><path style="opacity:1" fill="#7581a0" d="M 706.5,791.5 C 705.737,792.268 704.737,792.768 703.5,793C 695.323,793.823 687.323,793.656 679.5,792.5C 688.533,792.716 697.533,792.383 706.5,791.5 Z"/></g>
<g><path style="opacity:1" fill="#a7aec3" d="M 1028.5,883.5 C 1032.79,883.198 1036.79,883.864 1040.5,885.5C 1036.29,885.283 1032.29,884.617 1028.5,883.5 Z"/></g>
<g><path style="opacity:1" fill="#f9f9f9" d="M 233.5,904.5 C 242.833,904.5 252.167,904.5 261.5,904.5C 263.833,904.5 266.167,904.5 268.5,904.5C 304.989,908.827 334.489,925.494 357,954.5C 374.323,977.781 379.323,1003.45 372,1031.5C 365.153,1050.01 351.986,1060.85 332.5,1064C 324.173,1064.5 315.84,1064.67 307.5,1064.5C 307.947,1050.43 307.447,1036.43 306,1022.5C 296.93,1011.58 288.263,1011.91 280,1023.5C 279.833,1038.51 279.333,1053.51 278.5,1068.5C 271.841,1075.83 263.508,1080 253.5,1081C 248.845,1081.5 244.179,1081.67 239.5,1081.5C 237.485,1080.08 235.152,1079.41 232.5,1079.5C 225.481,1077.32 219.315,1073.66 214,1068.5C 213.667,1053.5 213.333,1038.5 213,1023.5C 208.464,1016.16 201.964,1013.66 193.5,1016C 190.333,1017.83 187.833,1020.33 186,1023.5C 185.5,1037.83 185.333,1052.16 185.5,1066.5C 160.376,1072.2 140.21,1064.86 125,1044.5C 120.792,1037.38 118.292,1029.71 117.5,1021.5C 117.482,1013.15 117.815,1004.82 118.5,996.5C 129.171,955.493 154.504,927.826 194.5,913.5C 200.166,912.61 205.5,910.943 210.5,908.5C 211.568,907.566 212.901,907.232 214.5,907.5C 221.111,907.453 227.444,906.453 233.5,904.5 Z"/></g>
<g><path style="opacity:1" fill="#f8f8f9" d="M 1133.5,985.5 C 1133.41,988.152 1134.08,990.485 1135.5,992.5C 1136.26,1002.48 1136.59,1012.48 1136.5,1022.5C 1133.68,1047.82 1119.68,1062.66 1094.5,1067C 1086.48,1067.61 1078.48,1067.44 1070.5,1066.5C 1070.67,1052.83 1070.5,1039.16 1070,1025.5C 1066.12,1016.96 1059.62,1013.79 1050.5,1016C 1047.33,1017.83 1044.83,1020.33 1043,1023.5C 1042.67,1038.17 1042.33,1052.83 1042,1067.5C 1035.97,1075.1 1028.14,1079.43 1018.5,1080.5C 1013.2,1081.27 1007.87,1081.61 1002.5,1081.5C 991.789,1080.39 982.955,1075.73 976,1067.5C 975.667,1052.83 975.333,1038.17 975,1023.5C 971.569,1017.53 966.402,1014.87 959.5,1015.5C 953.942,1016.72 950.275,1020.06 948.5,1025.5C 947.505,1037.99 947.171,1050.66 947.5,1063.5C 946.209,1063.26 945.209,1063.6 944.5,1064.5C 903.542,1067.19 882.208,1048.02 880.5,1007C 880.658,1002.81 880.991,998.641 881.5,994.5C 883.277,991.495 884.277,988.162 884.5,984.5C 894.73,953.43 914.73,930.93 944.5,917C 978.246,903.385 1012.91,900.718 1048.5,909C 1082.5,918.575 1108.67,938.409 1127,968.5C 1129.86,973.928 1132.03,979.595 1133.5,985.5 Z"/></g>
<g><path style="opacity:1" fill="#adb2c9" d="M 233.5,904.5 C 227.444,906.453 221.111,907.453 214.5,907.5C 220.536,905.419 226.869,904.419 233.5,904.5 Z"/></g>
<g><path style="opacity:1" fill="#bec4d7" d="M 210.5,908.5 C 205.5,910.943 200.166,912.61 194.5,913.5C 199.5,911.057 204.834,909.39 210.5,908.5 Z"/></g>
<g><path style="opacity:1" fill="#9ba0b8" d="M 884.5,984.5 C 884.277,988.162 883.277,991.495 881.5,994.5C 881.723,990.838 882.723,987.505 884.5,984.5 Z"/></g>
<g><path style="opacity:1" fill="#9aa5bc" d="M 1133.5,985.5 C 1134.92,987.515 1135.59,989.848 1135.5,992.5C 1134.08,990.485 1133.41,988.152 1133.5,985.5 Z"/></g>
<g><path style="opacity:1" fill="#adb1c6" d="M 118.5,996.5 C 117.815,1004.82 117.482,1013.15 117.5,1021.5C 116.835,1018.69 116.502,1015.69 116.5,1012.5C 116.429,1006.93 117.096,1001.6 118.5,996.5 Z"/></g>
<g><path style="opacity:1" fill="#c9d0dc" d="M 1135.5,992.5 C 1136.96,998.434 1137.63,1004.6 1137.5,1011C 1137.5,1015.02 1137.17,1018.85 1136.5,1022.5C 1136.59,1012.48 1136.26,1002.48 1135.5,992.5 Z"/></g>
<g><path style="opacity:1" fill="#b5bfcb" d="M 948.5,1025.5 C 948.5,1038.5 948.5,1051.5 948.5,1064.5C 947.167,1064.5 945.833,1064.5 944.5,1064.5C 945.209,1063.6 946.209,1063.26 947.5,1063.5C 947.171,1050.66 947.505,1037.99 948.5,1025.5 Z"/></g>
<g><path style="opacity:1" fill="#8193aa" d="M 232.5,1079.5 C 235.152,1079.41 237.485,1080.08 239.5,1081.5C 236.848,1081.59 234.515,1080.92 232.5,1079.5 Z"/></g>
</g>
</g>
<rect x="104.0" y="104.0"
width="816" height="816"
rx="181.0" ry="181.0"
fill="none" stroke="none" stroke-opacity="0"
stroke-width="8" />
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 56 KiB

50
public/icon.svg Normal file
View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="44 44 936 936" width="1024" height="1024">
<defs>
<clipPath id="round">
<rect x="100.0" y="100.0" width="824" height="824" rx="185" ry="185" />
</clipPath>
</defs>
<g clip-path="url(#round)">
<rect x="100.0" y="100.0" width="824" height="824" fill="#002551" />
<g transform="translate(161.80 161.80) scale(0.5585)">
<g><path style="opacity:1" fill="#f9f9f9" d="M 618.5,240.5 C 647.925,240.677 677.258,242.344 706.5,245.5C 753.323,252.113 798.49,265.113 842,284.5C 870.064,257.538 902.23,236.704 938.5,222C 966.969,211.263 988.469,219.096 1003,245.5C 1011.08,263.079 1016.75,281.412 1020,300.5C 1022.13,320.204 1024.29,339.871 1026.5,359.5C 1026.17,379.674 1026.5,399.674 1027.5,419.5C 1072.74,473.648 1102.74,535.314 1117.5,604.5C 1117.29,607.495 1117.96,610.162 1119.5,612.5C 1126.08,656.83 1126.08,701.163 1119.5,745.5C 1118.23,747.905 1117.57,750.572 1117.5,753.5C 1107.38,802.706 1088.05,847.872 1059.5,889C 1053.04,888.572 1046.71,887.405 1040.5,885.5C 1036.79,883.864 1032.79,883.198 1028.5,883.5C 1011.79,881.938 995.122,882.271 978.5,884.5C 975.572,884.565 972.905,885.232 970.5,886.5C 928.686,895.489 896.519,918.156 874,954.5C 864.791,970.962 859.958,988.628 859.5,1007.5C 793.269,1029.39 725.269,1041.72 655.5,1044.5C 633.833,1044.5 612.167,1044.5 590.5,1044.5C 524.821,1041.8 460.821,1029.63 398.5,1008C 396.254,996.177 393.421,984.344 390,972.5C 387.524,964.881 384.024,957.881 379.5,951.5C 363.815,925.334 341.815,906.667 313.5,895.5C 297.343,888.573 280.343,884.406 262.5,883C 248.055,882.038 233.722,882.538 219.5,884.5C 216.572,884.565 213.905,885.232 211.5,886.5C 211.167,886.5 210.833,886.5 210.5,886.5C 207.848,886.41 205.515,887.076 203.5,888.5C 200.823,889.614 198.156,889.614 195.5,888.5C 149.432,819.968 128.098,744.301 131.5,661.5C 131.502,654.48 131.835,647.48 132.5,640.5C 133.461,638.735 133.795,636.735 133.5,634.5C 135.136,630.79 135.802,626.79 135.5,622.5C 137.764,609.333 140.431,596.333 143.5,583.5C 144.924,581.485 145.59,579.152 145.5,576.5C 156.228,537.714 172.395,501.381 194,467.5C 204.685,451.452 215.852,435.786 227.5,420.5C 228.042,388.62 229.375,356.62 231.5,324.5C 234.549,300.253 240.382,276.586 249,253.5C 253.868,241.906 261.035,232.073 270.5,224C 279.336,218.042 289.002,216.042 299.5,218C 314.655,220.607 328.988,225.607 342.5,233C 368.29,247.23 391.957,264.396 413.5,284.5C 478.68,255.797 547.014,241.13 618.5,240.5 Z"/></g>
<g><path style="opacity:1" fill="#1f2657" d="M 706.5,245.5 C 677.258,242.344 647.925,240.677 618.5,240.5C 649.662,238.284 680.995,239.784 712.5,245C 710.527,245.495 708.527,245.662 706.5,245.5 Z"/></g>
<g><path style="opacity:1" fill="#18214c" d="M 231.5,324.5 C 229.375,356.62 228.042,388.62 227.5,420.5C 226.104,392.965 226.604,365.298 229,337.5C 229.17,331.677 230.003,327.344 231.5,324.5 Z"/></g>
<g><path style="opacity:1" fill="#0c1943" d="M 1026.5,359.5 C 1027.92,371.971 1028.59,384.637 1028.5,397.5C 1028.5,405.008 1028.17,412.341 1027.5,419.5C 1026.5,399.674 1026.17,379.674 1026.5,359.5 Z"/></g>
<g><path style="opacity:1" fill="#505c83" d="M 817.5,544.5 C 815.162,546.04 812.495,546.706 809.5,546.5C 811.905,545.232 814.572,544.565 817.5,544.5 Z"/></g>
<g><path style="opacity:1" fill="#919ab0" d="M 445.5,545.5 C 448.152,545.41 450.485,546.076 452.5,547.5C 449.848,547.59 447.515,546.924 445.5,545.5 Z"/></g>
<g><path style="opacity:1" fill="#022551" d="M 445.5,545.5 C 447.515,546.924 449.848,547.59 452.5,547.5C 479.103,555.885 499.269,572.218 513,596.5C 515.435,607.525 511.268,614.191 500.5,616.5C 497.302,616.378 494.302,615.545 491.5,614C 485.302,604.13 477.969,595.13 469.5,587C 459.207,579.735 447.873,574.902 435.5,572.5C 415.88,568.656 398.213,573.156 382.5,586C 380.905,585.383 379.572,585.716 378.5,587C 378.957,587.414 379.291,587.914 379.5,588.5C 376.839,591.423 374.005,593.423 371,594.5C 369.606,600.126 366.772,603.96 362.5,606C 363.517,607.049 363.684,608.216 363,609.5C 355.276,616.472 347.943,616.139 341,608.5C 339.805,603.4 340.638,598.733 343.5,594.5C 344.086,594.709 344.586,595.043 345,595.5C 344.718,590.888 346.551,587.055 350.5,584C 351.515,582.627 351.515,581.46 350.5,580.5C 375.329,550.884 406.995,539.218 445.5,545.5 Z"/></g>
<g><path style="opacity:1" fill="#032551" d="M 817.5,544.5 C 862.791,541.392 895.958,559.726 917,599.5C 917.138,612.028 910.971,617.528 898.5,616C 897.167,615.333 895.833,614.667 894.5,614C 884.255,595.245 869.255,582.078 849.5,574.5C 843.812,571.54 837.645,570.207 831,570.5C 822.066,570.919 813.233,572.086 804.5,574C 798.217,577.721 792.05,581.554 786,585.5C 785.667,585.167 785.333,584.833 785,584.5C 782.92,587.065 781.087,589.732 779.5,592.5C 774.384,597.792 770.218,603.792 767,610.5C 759.55,618.016 751.883,618.349 744,611.5C 742.878,609.593 742.045,607.593 741.5,605.5C 741.508,602.455 741.841,599.455 742.5,596.5C 757.037,569.397 779.371,552.73 809.5,546.5C 812.495,546.706 815.162,546.04 817.5,544.5 Z"/></g>
<g><path style="opacity:1" fill="#0c1a4d" d="M 849.5,574.5 C 822.908,568.314 799.574,574.314 779.5,592.5C 781.087,589.732 782.92,587.065 785,584.5C 785.333,584.833 785.667,585.167 786,585.5C 792.05,581.554 798.217,577.721 804.5,574C 813.233,572.086 822.066,570.919 831,570.5C 837.645,570.207 843.812,571.54 849.5,574.5 Z"/></g>
<g><path style="opacity:1" fill="#98a2bf" d="M 423.5,572.5 C 419.684,573.482 415.684,574.149 411.5,574.5C 415.183,572.75 419.183,572.083 423.5,572.5 Z"/></g>
<g><path style="opacity:1" fill="#9ea6be" d="M 145.5,576.5 C 145.59,579.152 144.924,581.485 143.5,583.5C 143.41,580.848 144.076,578.515 145.5,576.5 Z"/></g>
<g><path style="opacity:1" fill="#132152" d="M 435.5,572.5 C 431.5,572.5 427.5,572.5 423.5,572.5C 419.183,572.083 415.183,572.75 411.5,574.5C 389.242,579.57 372.909,592.403 362.5,613C 356.408,617.241 350.075,617.574 343.5,614C 337.996,608.137 337.163,601.637 341,594.5C 343.929,589.631 347.096,584.965 350.5,580.5C 351.515,581.46 351.515,582.627 350.5,584C 346.551,587.055 344.718,590.888 345,595.5C 344.586,595.043 344.086,594.709 343.5,594.5C 340.638,598.733 339.805,603.4 341,608.5C 347.943,616.139 355.276,616.472 363,609.5C 363.684,608.216 363.517,607.049 362.5,606C 366.772,603.96 369.606,600.126 371,594.5C 374.005,593.423 376.839,591.423 379.5,588.5C 379.291,587.914 378.957,587.414 378.5,587C 379.572,585.716 380.905,585.383 382.5,586C 398.213,573.156 415.88,568.656 435.5,572.5 Z"/></g>
<g><path style="opacity:1" fill="#6c7794" d="M 742.5,596.5 C 741.841,599.455 741.508,602.455 741.5,605.5C 740.848,604.551 740.514,603.385 740.5,602C 740.393,599.779 741.06,597.946 742.5,596.5 Z"/></g>
<g><path style="opacity:1" fill="#6f7b97" d="M 1117.5,604.5 C 1118.77,606.905 1119.43,609.572 1119.5,612.5C 1117.96,610.162 1117.29,607.495 1117.5,604.5 Z"/></g>
<g><path style="opacity:1" fill="#a8aec5" d="M 135.5,622.5 C 135.802,626.79 135.136,630.79 133.5,634.5C 133.717,630.295 134.383,626.295 135.5,622.5 Z"/></g>
<g><path style="opacity:1" fill="#677393" d="M 653.5,662.5 C 634.473,662.218 615.473,662.551 596.5,663.5C 597.263,662.732 598.263,662.232 599.5,662C 617.671,661.171 635.671,661.338 653.5,662.5 Z"/></g>
<g><path style="opacity:1" fill="#032551" d="M 653.5,662.5 C 664.536,665.228 669.036,672.228 667,683.5C 665.861,687.112 664.194,690.446 662,693.5C 656.35,700.317 650.184,706.65 643.5,712.5C 643.058,737.755 654.725,754.922 678.5,764C 709.272,768.521 729.105,756.021 738,726.5C 747.413,717.842 755.746,718.842 763,729.5C 759.409,758.463 743.909,778.297 716.5,789C 713.111,789.776 709.778,790.609 706.5,791.5C 697.533,792.383 688.533,792.716 679.5,792.5C 657.328,788.994 639.828,777.994 627,759.5C 607.084,786.202 580.584,797.035 547.5,792C 516.901,784.235 497.901,765.068 490.5,734.5C 493.257,721.955 500.59,718.121 512.5,723C 517.164,727.124 519.998,732.291 521,738.5C 533.515,761.003 552.348,769.17 577.5,763C 599.78,754.048 610.947,737.548 611,713.5C 604.698,706.197 598.032,699.197 591,692.5C 586.824,686.46 585.491,679.794 587,672.5C 589.072,668.26 592.238,665.26 596.5,663.5C 615.473,662.551 634.473,662.218 653.5,662.5 Z"/></g>
<g><path style="opacity:1" fill="#01103f" d="M 132.5,640.5 C 131.835,647.48 131.502,654.48 131.5,661.5C 130.669,675.994 130.169,690.661 130,705.5C 128.188,682.722 128.854,660.055 132,637.5C 132.483,638.448 132.649,639.448 132.5,640.5 Z"/></g>
<g><path style="opacity:1" fill="#7c869d" d="M 1119.5,745.5 C 1119.71,748.495 1119.04,751.162 1117.5,753.5C 1117.57,750.572 1118.23,747.905 1119.5,745.5 Z"/></g>
<g><path style="opacity:1" fill="#7581a0" d="M 706.5,791.5 C 705.737,792.268 704.737,792.768 703.5,793C 695.323,793.823 687.323,793.656 679.5,792.5C 688.533,792.716 697.533,792.383 706.5,791.5 Z"/></g>
<g><path style="opacity:1" fill="#a7aec3" d="M 1028.5,883.5 C 1032.79,883.198 1036.79,883.864 1040.5,885.5C 1036.29,885.283 1032.29,884.617 1028.5,883.5 Z"/></g>
<g><path style="opacity:1" fill="#f9f9f9" d="M 233.5,904.5 C 242.833,904.5 252.167,904.5 261.5,904.5C 263.833,904.5 266.167,904.5 268.5,904.5C 304.989,908.827 334.489,925.494 357,954.5C 374.323,977.781 379.323,1003.45 372,1031.5C 365.153,1050.01 351.986,1060.85 332.5,1064C 324.173,1064.5 315.84,1064.67 307.5,1064.5C 307.947,1050.43 307.447,1036.43 306,1022.5C 296.93,1011.58 288.263,1011.91 280,1023.5C 279.833,1038.51 279.333,1053.51 278.5,1068.5C 271.841,1075.83 263.508,1080 253.5,1081C 248.845,1081.5 244.179,1081.67 239.5,1081.5C 237.485,1080.08 235.152,1079.41 232.5,1079.5C 225.481,1077.32 219.315,1073.66 214,1068.5C 213.667,1053.5 213.333,1038.5 213,1023.5C 208.464,1016.16 201.964,1013.66 193.5,1016C 190.333,1017.83 187.833,1020.33 186,1023.5C 185.5,1037.83 185.333,1052.16 185.5,1066.5C 160.376,1072.2 140.21,1064.86 125,1044.5C 120.792,1037.38 118.292,1029.71 117.5,1021.5C 117.482,1013.15 117.815,1004.82 118.5,996.5C 129.171,955.493 154.504,927.826 194.5,913.5C 200.166,912.61 205.5,910.943 210.5,908.5C 211.568,907.566 212.901,907.232 214.5,907.5C 221.111,907.453 227.444,906.453 233.5,904.5 Z"/></g>
<g><path style="opacity:1" fill="#f8f8f9" d="M 1133.5,985.5 C 1133.41,988.152 1134.08,990.485 1135.5,992.5C 1136.26,1002.48 1136.59,1012.48 1136.5,1022.5C 1133.68,1047.82 1119.68,1062.66 1094.5,1067C 1086.48,1067.61 1078.48,1067.44 1070.5,1066.5C 1070.67,1052.83 1070.5,1039.16 1070,1025.5C 1066.12,1016.96 1059.62,1013.79 1050.5,1016C 1047.33,1017.83 1044.83,1020.33 1043,1023.5C 1042.67,1038.17 1042.33,1052.83 1042,1067.5C 1035.97,1075.1 1028.14,1079.43 1018.5,1080.5C 1013.2,1081.27 1007.87,1081.61 1002.5,1081.5C 991.789,1080.39 982.955,1075.73 976,1067.5C 975.667,1052.83 975.333,1038.17 975,1023.5C 971.569,1017.53 966.402,1014.87 959.5,1015.5C 953.942,1016.72 950.275,1020.06 948.5,1025.5C 947.505,1037.99 947.171,1050.66 947.5,1063.5C 946.209,1063.26 945.209,1063.6 944.5,1064.5C 903.542,1067.19 882.208,1048.02 880.5,1007C 880.658,1002.81 880.991,998.641 881.5,994.5C 883.277,991.495 884.277,988.162 884.5,984.5C 894.73,953.43 914.73,930.93 944.5,917C 978.246,903.385 1012.91,900.718 1048.5,909C 1082.5,918.575 1108.67,938.409 1127,968.5C 1129.86,973.928 1132.03,979.595 1133.5,985.5 Z"/></g>
<g><path style="opacity:1" fill="#adb2c9" d="M 233.5,904.5 C 227.444,906.453 221.111,907.453 214.5,907.5C 220.536,905.419 226.869,904.419 233.5,904.5 Z"/></g>
<g><path style="opacity:1" fill="#bec4d7" d="M 210.5,908.5 C 205.5,910.943 200.166,912.61 194.5,913.5C 199.5,911.057 204.834,909.39 210.5,908.5 Z"/></g>
<g><path style="opacity:1" fill="#9ba0b8" d="M 884.5,984.5 C 884.277,988.162 883.277,991.495 881.5,994.5C 881.723,990.838 882.723,987.505 884.5,984.5 Z"/></g>
<g><path style="opacity:1" fill="#9aa5bc" d="M 1133.5,985.5 C 1134.92,987.515 1135.59,989.848 1135.5,992.5C 1134.08,990.485 1133.41,988.152 1133.5,985.5 Z"/></g>
<g><path style="opacity:1" fill="#adb1c6" d="M 118.5,996.5 C 117.815,1004.82 117.482,1013.15 117.5,1021.5C 116.835,1018.69 116.502,1015.69 116.5,1012.5C 116.429,1006.93 117.096,1001.6 118.5,996.5 Z"/></g>
<g><path style="opacity:1" fill="#c9d0dc" d="M 1135.5,992.5 C 1136.96,998.434 1137.63,1004.6 1137.5,1011C 1137.5,1015.02 1137.17,1018.85 1136.5,1022.5C 1136.59,1012.48 1136.26,1002.48 1135.5,992.5 Z"/></g>
<g><path style="opacity:1" fill="#b5bfcb" d="M 948.5,1025.5 C 948.5,1038.5 948.5,1051.5 948.5,1064.5C 947.167,1064.5 945.833,1064.5 944.5,1064.5C 945.209,1063.6 946.209,1063.26 947.5,1063.5C 947.171,1050.66 947.505,1037.99 948.5,1025.5 Z"/></g>
<g><path style="opacity:1" fill="#8193aa" d="M 232.5,1079.5 C 235.152,1079.41 237.485,1080.08 239.5,1081.5C 236.848,1081.59 234.515,1080.92 232.5,1079.5 Z"/></g>
</g>
</g>
<rect x="104.0" y="104.0"
width="816" height="816"
rx="181.0" ry="181.0"
fill="none" stroke="#ffffff" stroke-opacity="0.4"
stroke-width="8" />
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 522 B

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="227 238 570 570" width="22" height="22">
<mask id="cutouts">
<rect x="0" y="0" width="1024" height="1024" fill="white"/>
<g transform="translate(161.80 161.80) scale(0.5585)" fill="black">
<!-- Left squinty eye / brow (from logo.svg path #18) -->
<path d="M 445.5,545.5 C 447.515,546.924 449.848,547.59 452.5,547.5C 479.103,555.885 499.269,572.218 513,596.5C 515.435,607.525 511.268,614.191 500.5,616.5C 497.302,616.378 494.302,615.545 491.5,614C 485.302,604.13 477.969,595.13 469.5,587C 459.207,579.735 447.873,574.902 435.5,572.5C 415.88,568.656 398.213,573.156 382.5,586C 380.905,585.383 379.572,585.716 378.5,587C 378.957,587.414 379.291,587.914 379.5,588.5C 376.839,591.423 374.005,593.423 371,594.5C 369.606,600.126 366.772,603.96 362.5,606C 363.517,607.049 363.684,608.216 363,609.5C 355.276,616.472 347.943,616.139 341,608.5C 339.805,603.4 340.638,598.733 343.5,594.5C 344.086,594.709 344.586,595.043 345,595.5C 344.718,590.888 346.551,587.055 350.5,584C 351.515,582.627 351.515,581.46 350.5,580.5C 375.329,550.884 406.995,539.218 445.5,545.5 Z"/>
<!-- Right squinty eye / brow (from logo.svg path #19) -->
<path d="M 817.5,544.5 C 862.791,541.392 895.958,559.726 917,599.5C 917.138,612.028 910.971,617.528 898.5,616C 897.167,615.333 895.833,614.667 894.5,614C 884.255,595.245 869.255,582.078 849.5,574.5C 843.812,571.54 837.645,570.207 831,570.5C 822.066,570.919 813.233,572.086 804.5,574C 798.217,577.721 792.05,581.554 786,585.5C 785.667,585.167 785.333,584.833 785,584.5C 782.92,587.065 781.087,589.732 779.5,592.5C 774.384,597.792 770.218,603.792 767,610.5C 759.55,618.016 751.883,618.349 744,611.5C 742.878,609.593 742.045,607.593 741.5,605.5C 741.508,602.455 741.841,599.455 742.5,596.5C 757.037,569.397 779.371,552.73 809.5,546.5C 812.495,546.706 815.162,546.04 817.5,544.5 Z"/>
<!-- Nose + mouth curve (from logo.svg path #28) -->
<path d="M 653.5,662.5 C 664.536,665.228 669.036,672.228 667,683.5C 665.861,687.112 664.194,690.446 662,693.5C 656.35,700.317 650.184,706.65 643.5,712.5C 643.058,737.755 654.725,754.922 678.5,764C 709.272,768.521 729.105,756.021 738,726.5C 747.413,717.842 755.746,718.842 763,729.5C 759.409,758.463 743.909,778.297 716.5,789C 713.111,789.776 709.778,790.609 706.5,791.5C 697.533,792.383 688.533,792.716 679.5,792.5C 657.328,788.994 639.828,777.994 627,759.5C 607.084,786.202 580.584,797.035 547.5,792C 516.901,784.235 497.901,765.068 490.5,734.5C 493.257,721.955 500.59,718.121 512.5,723C 517.164,727.124 519.998,732.291 521,738.5C 533.515,761.003 552.348,769.17 577.5,763C 599.78,754.048 610.947,737.548 611,713.5C 604.698,706.197 598.032,699.197 591,692.5C 586.824,686.46 585.491,679.794 587,672.5C 589.072,668.26 592.238,665.26 596.5,663.5C 615.473,662.551 634.473,662.218 653.5,662.5 Z"/>
</g>
</mask>
<g mask="url(#cutouts)" fill="black">
<g transform="translate(161.80 161.80) scale(0.5585)">
<!-- Head + ears -->
<path d="M 618.5,240.5 C 647.925,240.677 677.258,242.344 706.5,245.5C 753.323,252.113 798.49,265.113 842,284.5C 870.064,257.538 902.23,236.704 938.5,222C 966.969,211.263 988.469,219.096 1003,245.5C 1011.08,263.079 1016.75,281.412 1020,300.5C 1022.13,320.204 1024.29,339.871 1026.5,359.5C 1026.17,379.674 1026.5,399.674 1027.5,419.5C 1072.74,473.648 1102.74,535.314 1117.5,604.5C 1117.29,607.495 1117.96,610.162 1119.5,612.5C 1126.08,656.83 1126.08,701.163 1119.5,745.5C 1118.23,747.905 1117.57,750.572 1117.5,753.5C 1107.38,802.706 1088.05,847.872 1059.5,889C 1053.04,888.572 1046.71,887.405 1040.5,885.5C 1036.79,883.864 1032.79,883.198 1028.5,883.5C 1011.79,881.938 995.122,882.271 978.5,884.5C 975.572,884.565 972.905,885.232 970.5,886.5C 928.686,895.489 896.519,918.156 874,954.5C 864.791,970.962 859.958,988.628 859.5,1007.5C 793.269,1029.39 725.269,1041.72 655.5,1044.5C 633.833,1044.5 612.167,1044.5 590.5,1044.5C 524.821,1041.8 460.821,1029.63 398.5,1008C 396.254,996.177 393.421,984.344 390,972.5C 387.524,964.881 384.024,957.881 379.5,951.5C 363.815,925.334 341.815,906.667 313.5,895.5C 297.343,888.573 280.343,884.406 262.5,883C 248.055,882.038 233.722,882.538 219.5,884.5C 216.572,884.565 213.905,885.232 211.5,886.5C 211.167,886.5 210.833,886.5 210.5,886.5C 207.848,886.41 205.515,887.076 203.5,888.5C 200.823,889.614 198.156,889.614 195.5,888.5C 149.432,819.968 128.098,744.301 131.5,661.5C 131.502,654.48 131.835,647.48 132.5,640.5C 133.461,638.735 133.795,636.735 133.5,634.5C 135.136,630.79 135.802,626.79 135.5,622.5C 137.764,609.333 140.431,596.333 143.5,583.5C 144.924,581.485 145.59,579.152 145.5,576.5C 156.228,537.714 172.395,501.381 194,467.5C 204.685,451.452 215.852,435.786 227.5,420.5C 228.042,388.62 229.375,356.62 231.5,324.5C 234.549,300.253 240.382,276.586 249,253.5C 253.868,241.906 261.035,232.073 270.5,224C 279.336,218.042 289.002,216.042 299.5,218C 314.655,220.607 328.988,225.607 342.5,233C 368.29,247.23 391.957,264.396 413.5,284.5C 478.68,255.797 547.014,241.13 618.5,240.5 Z"/>
<!-- Left paw -->
<path d="M 233.5,904.5 C 242.833,904.5 252.167,904.5 261.5,904.5C 263.833,904.5 266.167,904.5 268.5,904.5C 304.989,908.827 334.489,925.494 357,954.5C 374.323,977.781 379.323,1003.45 372,1031.5C 365.153,1050.01 351.986,1060.85 332.5,1064C 324.173,1064.5 315.84,1064.67 307.5,1064.5C 307.947,1050.43 307.447,1036.43 306,1022.5C 296.93,1011.58 288.263,1011.91 280,1023.5C 279.833,1038.51 279.333,1053.51 278.5,1068.5C 271.841,1075.83 263.508,1080 253.5,1081C 248.845,1081.5 244.179,1081.67 239.5,1081.5C 237.485,1080.08 235.152,1079.41 232.5,1079.5C 225.481,1077.32 219.315,1073.66 214,1068.5C 213.667,1053.5 213.333,1038.5 213,1023.5C 208.464,1016.16 201.964,1013.66 193.5,1016C 190.333,1017.83 187.833,1020.33 186,1023.5C 185.5,1037.83 185.333,1052.16 185.5,1066.5C 160.376,1072.2 140.21,1064.86 125,1044.5C 120.792,1037.38 118.292,1029.71 117.5,1021.5C 117.482,1013.15 117.815,1004.82 118.5,996.5C 129.171,955.493 154.504,927.826 194.5,913.5C 200.166,912.61 205.5,910.943 210.5,908.5C 211.568,907.566 212.901,907.232 214.5,907.5C 221.111,907.453 227.444,906.453 233.5,904.5 Z"/>
<!-- Right paw -->
<path d="M 1133.5,985.5 C 1133.41,988.152 1134.08,990.485 1135.5,992.5C 1136.26,1002.48 1136.59,1012.48 1136.5,1022.5C 1133.68,1047.82 1119.68,1062.66 1094.5,1067C 1086.48,1067.61 1078.48,1067.44 1070.5,1066.5C 1070.67,1052.83 1070.5,1039.16 1070,1025.5C 1066.12,1016.96 1059.62,1013.79 1050.5,1016C 1047.33,1017.83 1044.83,1020.33 1043,1023.5C 1042.67,1038.17 1042.33,1052.83 1042,1067.5C 1035.97,1075.1 1028.14,1079.43 1018.5,1080.5C 1013.2,1081.27 1007.87,1081.61 1002.5,1081.5C 991.789,1080.39 982.955,1075.73 976,1067.5C 975.667,1052.83 975.333,1038.17 975,1023.5C 971.569,1017.53 966.402,1014.87 959.5,1015.5C 953.942,1016.72 950.275,1020.06 948.5,1025.5C 947.505,1037.99 947.171,1050.66 947.5,1063.5C 946.209,1063.26 945.209,1063.6 944.5,1064.5C 903.542,1067.19 882.208,1048.02 880.5,1007C 880.658,1002.81 880.991,998.641 881.5,994.5C 883.277,991.495 884.277,988.162 884.5,984.5C 894.73,953.43 914.73,930.93 944.5,917C 978.246,903.385 1012.91,900.718 1048.5,909C 1082.5,918.575 1108.67,938.409 1127,968.5C 1129.86,973.928 1132.03,979.595 1133.5,985.5 Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 B

After

Width:  |  Height:  |  Size: 1023 B