Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc9cdd1760 | ||
|
|
c16f0d5b70 | ||
|
|
3239057f8f | ||
|
|
23364f412e | ||
|
|
b24807bd5f | ||
|
|
0f3fff92f4 | ||
|
|
1eb91a35eb | ||
|
|
aa0a574091 | ||
|
|
2708772bf9 | ||
|
|
436b6dd470 | ||
|
|
5c47656444 | ||
|
|
be47c5edfd | ||
|
|
037ed78570 | ||
|
|
31e8f9df5f | ||
|
|
3704064006 | ||
|
|
f79a44e0a8 | ||
|
|
5a8e24ccbe | ||
|
|
a1f4eee9a3 | ||
|
|
b6ba6deb09 | ||
|
|
cd3160b1cd | ||
|
|
60abff1717 | ||
|
|
cc767bd544 | ||
|
|
221e7566c3 | ||
|
|
e58e2cdaf7 | ||
|
|
ed1dc8502c | ||
|
|
bd08ce8be2 | ||
|
|
7a5c0c1f13 | ||
|
|
3a49a67c1f | ||
|
|
88d6bed6db | ||
|
|
6cc48b3266 | ||
|
|
0fad306c25 | ||
|
|
8beb62b12c | ||
|
|
f2cfc0be8f | ||
|
|
99c9dd3cc2 | ||
|
|
dd48baac8a | ||
|
|
e63318e04a | ||
|
|
adf9e7d603 | ||
|
|
3c8296843f | ||
|
|
7489516df3 | ||
|
|
6dcdeb89cb | ||
|
|
97e58db3c1 | ||
|
|
a606e3ab20 | ||
|
|
925e51e435 | ||
|
|
722d9aeff1 |
@@ -1,274 +0,0 @@
|
|||||||
name: Publish AUR Package
|
|
||||||
|
|
||||||
# Builds and pushes the `triple-c-bin` AUR package (packaging/arch/PKGBUILD)
|
|
||||||
# for a given release, or the latest one if none is given. Manual dispatch
|
|
||||||
# only — deliberately not triggered by `release` or `push`, for the same
|
|
||||||
# reason sync-release.yml (removed in triple-c#32) never worked safely as an
|
|
||||||
# automatic trigger: this repo's releases are assembled by build-app.yml
|
|
||||||
# across three separate platform jobs, and there is no single automatic event
|
|
||||||
# that fires only once everything (including the Linux .deb this workflow
|
|
||||||
# needs) is actually uploaded. A human deciding "this release is ready, go
|
|
||||||
# package it" is the correct trigger, the same reasoning
|
|
||||||
# backfill-releases.yml already uses for its own manual-only GitHub sync.
|
|
||||||
#
|
|
||||||
# ## What this does and does not do
|
|
||||||
#
|
|
||||||
# It renders `packaging/arch/PKGBUILD` for one specific version (real
|
|
||||||
# download URL, real sha256sums — never guessed; see the resolve-asset step)
|
|
||||||
# and pushes the rendered PKGBUILD plus a regenerated `.SRCINFO` to AUR. It
|
|
||||||
# does NOT commit anything back to this repo — `packaging/arch/PKGBUILD` stays
|
|
||||||
# a hand-maintained template with a placeholder version, and every real,
|
|
||||||
# published version lives only in AUR's own git history, which is where a
|
|
||||||
# PKGBUILD's revision history is expected to live. A corollary worth knowing:
|
|
||||||
# a hand-edit made directly in the AUR repo (outside this workflow) is
|
|
||||||
# silently overwritten the next time this runs, since every run renders fresh
|
|
||||||
# from this repo's template rather than starting from AUR's current state.
|
|
||||||
#
|
|
||||||
# ## Required secret
|
|
||||||
#
|
|
||||||
# `AUR_SSH_PRIVATE_KEY` — an SSH private key registered against an AUR
|
|
||||||
# account that has already created (or been given co-maintainer access to)
|
|
||||||
# the `triple-c-bin` package. This workflow cannot create that AUR account or
|
|
||||||
# register the key for you — both are manual, one-time steps on
|
|
||||||
# https://aur.archlinux.org. Until this secret exists, every run fails at the
|
|
||||||
# "Push to AUR" step with a clear error rather than silently doing nothing.
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: >-
|
|
||||||
Release version to package, without a leading "v" (e.g. "0.4.14").
|
|
||||||
Leave empty to use the latest published GitHub release.
|
|
||||||
required: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
GITHUB_REPO: shadowdao/triple-c
|
|
||||||
AUR_REPO: ssh://aur@aur.archlinux.org/triple-c-bin.git
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Resolve version and find the Linux asset
|
|
||||||
id: resolve
|
|
||||||
env:
|
|
||||||
VERSION_INPUT: ${{ inputs.version }}
|
|
||||||
GH_PAT: ${{ secrets.GH_PAT }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Authenticated when the secret is available (it is, everywhere
|
|
||||||
# else in this repo's workflows) to avoid the unauthenticated
|
|
||||||
# 60-requests/hour-per-IP cap; still works without it, just at that
|
|
||||||
# lower limit, since this hits nothing but a public repo's public
|
|
||||||
# releases.
|
|
||||||
AUTH=()
|
|
||||||
[ -n "${GH_PAT}" ] && AUTH=(-H "Authorization: Bearer ${GH_PAT}")
|
|
||||||
|
|
||||||
if [ -z "${VERSION_INPUT}" ]; then
|
|
||||||
echo "No version given — resolving the latest GitHub release"
|
|
||||||
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/latest")
|
|
||||||
else
|
|
||||||
echo "Using requested version ${VERSION_INPUT}"
|
|
||||||
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${VERSION_INPUT}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name')
|
|
||||||
VERSION="${TAG#v}"
|
|
||||||
echo "Resolved to ${TAG}"
|
|
||||||
|
|
||||||
# Discovered from the real release, not assumed: Tauri names the
|
|
||||||
# asset after `productName` verbatim ("Triple-C"), not the
|
|
||||||
# lowercase Cargo binary name, and asset naming is exactly the kind
|
|
||||||
# of thing that silently drifts if a future Tauri upgrade changes
|
|
||||||
# bundler defaults — a hardcoded pattern here would then 404
|
|
||||||
# forever until someone noticed. `head -1` guards against a release
|
|
||||||
# somehow carrying more than one matching asset, which would
|
|
||||||
# otherwise pass the emptiness check below and then break the
|
|
||||||
# download step with two URLs on one line.
|
|
||||||
DEB_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .browser_download_url' | head -1)
|
|
||||||
DEB_NAME=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .name' | head -1)
|
|
||||||
if [ -z "$DEB_URL" ] || [ "$DEB_URL" = "null" ]; then
|
|
||||||
echo "No *_amd64.deb asset found on release ${TAG}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Found asset: ${DEB_NAME}"
|
|
||||||
|
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "deb_url=${DEB_URL}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "deb_name=${DEB_NAME}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Download the release asset and compute real checksums
|
|
||||||
id: checksums
|
|
||||||
env:
|
|
||||||
DEB_URL: ${{ steps.resolve.outputs.deb_url }}
|
|
||||||
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
|
||||||
TAG: ${{ steps.resolve.outputs.tag }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl -fsSL -o "${DEB_NAME}" "${DEB_URL}"
|
|
||||||
curl -fsSL -o LICENSE "https://raw.githubusercontent.com/${GITHUB_REPO}/${TAG}/LICENSE"
|
|
||||||
|
|
||||||
echo "deb_sha256=$(sha256sum "${DEB_NAME}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "license_sha256=$(sha256sum LICENSE | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Render PKGBUILD
|
|
||||||
id: render
|
|
||||||
env:
|
|
||||||
VERSION: ${{ steps.resolve.outputs.version }}
|
|
||||||
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
|
||||||
DEB_SHA256: ${{ steps.checksums.outputs.deb_sha256 }}
|
|
||||||
LICENSE_SHA256: ${{ steps.checksums.outputs.license_sha256 }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p rendered
|
|
||||||
cp packaging/arch/PKGBUILD rendered/PKGBUILD
|
|
||||||
cd rendered
|
|
||||||
|
|
||||||
# Plain string replacement throughout, not sed — the source URL
|
|
||||||
# contains slashes and the repo name does too, and getting a sed
|
|
||||||
# delimiter choice AND its escaping right for that is exactly the
|
|
||||||
# kind of thing that looks correct, passes review, and breaks the
|
|
||||||
# next time someone touches it. `re.sub` with `count=1` and an
|
|
||||||
# exact `.format`-free literal match is boring and that's the
|
|
||||||
# point: every substitution below fails loudly (an assertion /
|
|
||||||
# the checks after) rather than silently no-op'ing if the
|
|
||||||
# template's shape ever drifts from what this expects.
|
|
||||||
#
|
|
||||||
# pkgrel resets to 1 for a new pkgver — a packaging-only fix to the
|
|
||||||
# same upstream version (a dependency bump, say) is what pkgrel is
|
|
||||||
# for, and this workflow always republishes the current PKGBUILD
|
|
||||||
# verbatim rather than incrementing anything, so 1 is always
|
|
||||||
# correct for what this workflow does. It is NOT correct for a
|
|
||||||
# dependency-only fix republished at the *same* pkgver: pkgrel
|
|
||||||
# would be forced back to 1, and no existing installation sees an
|
|
||||||
# upgrade. That case needs a manual pkgrel bump in the template
|
|
||||||
# before dispatching, which this workflow has no input for.
|
|
||||||
python3 - "$VERSION" "$DEB_NAME" "$DEB_SHA256" "$LICENSE_SHA256" "$GITHUB_REPO" <<'PY'
|
|
||||||
import re, sys
|
|
||||||
version, deb_name, deb_sha, license_sha, github_repo = sys.argv[1:6]
|
|
||||||
|
|
||||||
with open("PKGBUILD") as f:
|
|
||||||
text = f.read()
|
|
||||||
|
|
||||||
text, n = re.subn(r"(?m)^pkgver=.*$", f"pkgver={version}", text, count=1)
|
|
||||||
assert n == 1, "pkgver=... line not found"
|
|
||||||
text, n = re.subn(r"(?m)^pkgrel=.*$", "pkgrel=1", text, count=1)
|
|
||||||
assert n == 1, "pkgrel=... line not found"
|
|
||||||
|
|
||||||
old_source = (
|
|
||||||
f'source=("Triple-C_${{pkgver}}_amd64.deb::'
|
|
||||||
f'https://github.com/{github_repo}/releases/download/v${{pkgver}}/'
|
|
||||||
f'Triple-C_${{pkgver}}_amd64.deb"'
|
|
||||||
)
|
|
||||||
new_source = (
|
|
||||||
f'source=("{deb_name}::'
|
|
||||||
f'https://github.com/{github_repo}/releases/download/v{version}/{deb_name}"'
|
|
||||||
)
|
|
||||||
assert old_source in text, "source=() line does not match the expected template shape"
|
|
||||||
text = text.replace(old_source, new_source, 1)
|
|
||||||
|
|
||||||
old_sums = "sha256sums=('SKIP'\n 'SKIP')"
|
|
||||||
assert old_sums in text, "sha256sums=() placeholders not found"
|
|
||||||
text = text.replace(old_sums, f"sha256sums=('{deb_sha}'\n '{license_sha}')", 1)
|
|
||||||
|
|
||||||
with open("PKGBUILD", "w") as f:
|
|
||||||
f.write(text)
|
|
||||||
PY
|
|
||||||
|
|
||||||
grep -q "pkgver=${VERSION}$" PKGBUILD
|
|
||||||
! grep -q "SKIP" PKGBUILD
|
|
||||||
|
|
||||||
- name: Validate with makepkg and namcap
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# A bind mount (`docker run -v "$PWD/...":/work`) is the more
|
|
||||||
# obvious way to write this, and was the first draft — but on a
|
|
||||||
# containerized Gitea act_runner job, `$PWD` is a path inside this
|
|
||||||
# job's own container, which the daemon's host cannot resolve; the
|
|
||||||
# mount would silently attach an empty directory instead of failing
|
|
||||||
# loudly. `docker cp` moves real bytes across that boundary
|
|
||||||
# regardless of where the daemon actually lives, which is what
|
|
||||||
# makes this work under both a bind-mount-capable runner and a
|
|
||||||
# containerized one.
|
|
||||||
docker pull archlinux:latest
|
|
||||||
CID=$(docker create -w /work archlinux:latest bash -c '
|
|
||||||
set -euo pipefail
|
|
||||||
pacman -Syu --noconfirm --needed base-devel namcap sudo git openssh >/dev/null
|
|
||||||
useradd -m builder
|
|
||||||
chown -R builder:builder /work
|
|
||||||
echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder
|
|
||||||
sudo -u builder bash -c "cd /work && makepkg --printsrcinfo > .SRCINFO"
|
|
||||||
sudo -u builder bash -c "cd /work && makepkg -s --noconfirm"
|
|
||||||
echo "--- namcap ---"
|
|
||||||
NAMCAP_OUT=$(sudo -u builder bash -c "cd /work && namcap PKGBUILD *.pkg.tar.*" || true)
|
|
||||||
echo "$NAMCAP_OUT"
|
|
||||||
# Matches "triple-c-bin E:", "PKGBUILD (triple-c-bin) E:" and any
|
|
||||||
# split-package variant ("triple-c-bin-debug E:") alike — namcap
|
|
||||||
# uses more than one line shape for its two rule families, and
|
|
||||||
# namcap itself exits 0 regardless of what it reports, so this
|
|
||||||
# grep is the only thing standing between an E: and a green job.
|
|
||||||
if echo "$NAMCAP_OUT" | grep -q " E: "; then
|
|
||||||
echo "namcap reported an error — see above" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
')
|
|
||||||
mkdir -p rendered
|
|
||||||
docker cp rendered/. "${CID}:/work"
|
|
||||||
# `docker start -a` streams output and its exit code is the
|
|
||||||
# container's own — the same failure this would have hit with a
|
|
||||||
# bind mount still fails the job the same way.
|
|
||||||
docker start -a "${CID}"
|
|
||||||
docker cp "${CID}:/work/.SRCINFO" rendered/.SRCINFO
|
|
||||||
docker rm -f "${CID}" >/dev/null
|
|
||||||
|
|
||||||
- name: Push to AUR
|
|
||||||
env:
|
|
||||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
|
||||||
VERSION: ${{ steps.resolve.outputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
|
|
||||||
echo "AUR_SSH_PRIVATE_KEY is not set — see this workflow file's header comment for" >&2
|
|
||||||
echo "the one-time AUR account setup this needs before it can publish anything." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
# Created with the final mode before any bytes land in it, rather
|
|
||||||
# than a plain redirect followed by chmod, which leaves the key
|
|
||||||
# world-readable for whatever window falls between the two calls.
|
|
||||||
install -m 600 /dev/null ~/.ssh/aur
|
|
||||||
echo "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur
|
|
||||||
# TOFU, not verification — accepted here because pinning AUR's
|
|
||||||
# actual host key needs a value fetched from somewhere trusted
|
|
||||||
# ahead of time, which this workflow doesn't have, and getting a
|
|
||||||
# pinned value wrong fails every future run rather than just this
|
|
||||||
# one. A keyscan failure below surfaces later as an opaque
|
|
||||||
# "Host key verification failed" rather than a clear one here.
|
|
||||||
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
export GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o IdentitiesOnly=yes -o UserKnownHostsFile=~/.ssh/known_hosts"
|
|
||||||
|
|
||||||
git clone "${AUR_REPO}" aur-repo
|
|
||||||
cp rendered/PKGBUILD rendered/.SRCINFO aur-repo/
|
|
||||||
cd aur-repo
|
|
||||||
git config user.name "Triple-C CI"
|
|
||||||
git config user.email "noreply@triple-c.invalid"
|
|
||||||
git add PKGBUILD .SRCINFO
|
|
||||||
if git diff --cached --quiet; then
|
|
||||||
echo "No change from what's already published on AUR for ${VERSION}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
git commit -m "triple-c-bin: update to ${VERSION}"
|
|
||||||
# AUR itself uses `master`, which is what a fresh, not-yet-created
|
|
||||||
# AUR package's empty repo advertises on clone — but the *local*
|
|
||||||
# branch name after cloning an empty repo falls back to whatever
|
|
||||||
# this runner's `init.defaultBranch` is if the server sends no
|
|
||||||
# symref, so naming the destination explicitly is what keeps this
|
|
||||||
# working if that default is ever `main` instead of `master`.
|
|
||||||
git push origin HEAD:master
|
|
||||||
@@ -552,6 +552,151 @@ survived 92 commits and fourteen days in the public GitHub mirror, past five aud
|
|||||||
independent reviews, because every one of them read the code under change and this sat in a test
|
independent reviews, because every one of them read the code under change and this sat in a test
|
||||||
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
|
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
|
||||||
|
|
||||||
|
## Settings export/import
|
||||||
|
|
||||||
|
`commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export`
|
||||||
|
(triple-c#35). Exports the *host* environment — global `AppSettings` plus the global secrets that
|
||||||
|
live in the OS keychain instead: the shared Claude Code OAuth login and the model gateway's two
|
||||||
|
keys. Per-project settings, per-project secrets, and anything in a project's Docker volumes are
|
||||||
|
deliberately out of scope — this is not a project backup.
|
||||||
|
|
||||||
|
- **`AppSettings` is not entirely the non-secret shape it looks like, and a review of this feature
|
||||||
|
caught the one place that isn't.** `WebTerminalSettings::access_token` is a live bearer
|
||||||
|
credential for a server that binds every interface — exporting `AppSettings` wholesale would
|
||||||
|
have carried it along as if it were as inert as a port number, and importing it would have
|
||||||
|
applied `web_terminal.enabled` and the token together with no more warning than any other
|
||||||
|
setting, letting a crafted export silently stand up a LAN-listening terminal on the next launch.
|
||||||
|
`export_settings`/`apply_settings_import` carve this one field out into `ExportedSecrets`
|
||||||
|
instead, with the same "only overwrite what the import actually has" treatment as the other
|
||||||
|
three secrets — except "leave it alone" has to be done by hand in `apply_settings_import`, since
|
||||||
|
unlike the keychain secrets this one lives inside the `AppSettings` blob that gets replaced
|
||||||
|
wholesale. `SettingsImportPreview::enables_web_terminal` also exists because of this: `enabled`
|
||||||
|
and the token are independent fields, and "this turns on a listening service" must not hide
|
||||||
|
inside a generic "settings replaced" summary. Read this as the standing example of the class of
|
||||||
|
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
|
||||||
|
like config but is actually a live credential would have the same problem.
|
||||||
|
- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives
|
||||||
|
a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC
|
||||||
|
brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual
|
||||||
|
encryption. A wrong password fails GCM's authentication tag rather than producing silent
|
||||||
|
garbage. The salt and nonce are not secret and are written in the clear in the file's own
|
||||||
|
header — the salt's job is only to make two exports of the same password derive different keys,
|
||||||
|
and the nonce's only requirement is per-encryption uniqueness, which a fresh random draw on
|
||||||
|
every export already gives it.
|
||||||
|
- **The save/open dialogs are opened from Rust**, the same boundary `file_commands.rs`'s
|
||||||
|
`pick_save_path`/`pick_files_to_upload` draw and document at length: a frontend-driven dialog
|
||||||
|
handing Rust a host path string is the exact shape of bug that produced this app's past
|
||||||
|
criticals. `preview_settings_import` resolves the chosen path itself and remembers it
|
||||||
|
(`AppState::pending_settings_import`) so `apply_settings_import` re-reads the same file without
|
||||||
|
a path ever crossing back over IPC. It also pins a hash of the file's ciphertext next to that
|
||||||
|
path, and `apply_settings_import` refuses to proceed if the file on disk no longer matches it —
|
||||||
|
otherwise confirming a preview would not actually be binding on what gets applied, which matters
|
||||||
|
given this feature's own threat model: a file shared between people may sit in a synced or
|
||||||
|
otherwise shared directory that changes between the two calls.
|
||||||
|
- **The decrypted payload is not cached between preview and apply — only the password is reused.**
|
||||||
|
The frontend holds the password in React state and passes it to both calls; nothing in Rust
|
||||||
|
holds decrypted plaintext — secrets included — in memory for longer than one command's
|
||||||
|
execution, so `apply_settings_import` always re-decrypts rather than reusing anything
|
||||||
|
`preview_settings_import` computed. `preview_settings_import` returns counts and presence flags
|
||||||
|
only (`SettingsImportPreview`), never a secret value, so it's safe to hand to the frontend and
|
||||||
|
render directly.
|
||||||
|
- **Import replaces settings wholesale, but only writes secrets actually present in the file.**
|
||||||
|
An import is "restore this environment," so the settings half is a full replace, not a
|
||||||
|
field-by-field merge. Secrets are different on purpose: an absent secret in the export means
|
||||||
|
"the source machine never had this configured," not "delete this on import" — a user who wants
|
||||||
|
to clear a secret already has dedicated UI for that (signing out of shared auth, clearing the
|
||||||
|
gateway key). Secrets are restored *before* the settings replace runs, not after — replacing
|
||||||
|
settings is what triggers `reconcile_gateway`, and restoring the other way round leaves a real
|
||||||
|
window where a gateway recreation happens against the destination's old keys.
|
||||||
|
- **A restored gateway secret nudges a running gateway container to recreate itself, even when
|
||||||
|
nothing about the gateway's *shape* changed.** `reconcile_gateway`'s `gateway_shape_changed` only
|
||||||
|
compares port/provider/base URL/models — deliberately, since that's what's rendered into the
|
||||||
|
container's config — so a secret-only change (same shape, new key) is invisible to it. Left
|
||||||
|
alone, a running container would keep serving the old key material indefinitely after an import
|
||||||
|
that restored a new one. `apply_settings_import` tracks whether either gateway secret was
|
||||||
|
actually written and, if the gateway is enabled and its container both exists and is running,
|
||||||
|
calls `docker::gateway::ensure_gateway_running` directly afterward — its own fingerprint already
|
||||||
|
includes the secret rotation id (`storage::secure::get_gateway_secret_version`), so it recreates
|
||||||
|
exactly when it should and no more.
|
||||||
|
- **A keychain write failing during import is reported back, not only logged.** Each of the three
|
||||||
|
`secure::store_*` calls collects its error into `SettingsImportOutcome::secret_restore_warnings`
|
||||||
|
in addition to logging it — an import that silently restores two of three secrets but not the
|
||||||
|
third must not read as unqualified success just because the settings half of the import (which
|
||||||
|
runs after, and is validated before any of this) went through. `apply_settings_import` returns
|
||||||
|
`SettingsImportOutcome { settings, secret_restore_warnings }` rather than bare `AppSettings` for
|
||||||
|
this reason; `ImportSettingsModal` shows any warnings alongside the "Settings imported" message.
|
||||||
|
- **The imported settings are validated *before* any secret is written, not just before the
|
||||||
|
settings replace.** `apply_settings_import` calls
|
||||||
|
`settings_commands::validate_settings_update(¤t, &settings)` — the same checks
|
||||||
|
`update_settings` runs internally, pulled out into its own function specifically so this caller
|
||||||
|
can run them first — and only proceeds to the three keychain writes if that passes. A review
|
||||||
|
caught the earlier ordering: writing secrets first meant a rejected import (a bad env var name, a
|
||||||
|
disallowed host path) still left the keychain overwritten with the file's secrets while the
|
||||||
|
settings themselves stayed unchanged, a silently half-applied state the error message gave no
|
||||||
|
hint of.
|
||||||
|
- **`read_and_decrypt` checks `format_version` before attempting to parse the full payload, not
|
||||||
|
after.** A version bump that isn't deserialize-compatible is exactly the case that check exists
|
||||||
|
for, and parsing the full struct first would fail on the shape mismatch before the version check
|
||||||
|
ever ran. Neither error path interpolates what `serde_json` actually says into the message
|
||||||
|
shown to the user — its type-mismatch errors quote the offending value inline, and the plaintext
|
||||||
|
here can hold a live credential.
|
||||||
|
- **The 8-character password minimum is enforced in `export_settings` itself, not only in the
|
||||||
|
export modal.** The frontend minimum is a UX nudge; the Rust command is the actual boundary a
|
||||||
|
weak password has to cross, and Argon2id's memory-hardness buys little against an attacker who
|
||||||
|
can just try a short password directly. Measured with `.chars().count()` (Unicode scalar values)
|
||||||
|
rather than `.len()` (bytes), to stay as close as this pair of languages allows to the frontend's
|
||||||
|
`.length` check (UTF-16 code units) — the two only diverge on astral-plane characters. The
|
||||||
|
derived key and both plaintext buffers — the payload built for export, and whatever `decrypt`
|
||||||
|
recovers on import — are wrapped in `zeroize::Zeroizing` for the same reason every other secret
|
||||||
|
in this codebase gets handled carefully — cheap insurance (`zeroize` is already pulled in
|
||||||
|
transitively via `aes-gcm`) for material that exists only to hold or produce live credentials.
|
||||||
|
- **The preview also discloses non-blank custom base URLs** (`global_ollama`, `global_llamacpp`,
|
||||||
|
`global_openai_compatible`, `gateway.api_base`) so an import that would redirect model traffic to
|
||||||
|
a different server is visible in the confirmation dialog rather than discovered later — these are
|
||||||
|
endpoints, not secrets, so `SettingsImportPreview` carries and `describeImport` renders the actual
|
||||||
|
URL rather than just a presence flag. `describeImportWarnings` additionally calls out a web
|
||||||
|
terminal token that arrives with the terminal left *off*: `start_web_terminal` only mints a fresh
|
||||||
|
token when none is already set, so a planted token would otherwise activate silently the next
|
||||||
|
time someone turns the terminal on, with no import-time signal that it wasn't freshly generated.
|
||||||
|
- **The preview also discloses a custom Docker image, and warns on one every time — not just on
|
||||||
|
change.** `custom_image_name`/`image_source` weren't in scope for the base-URL disclosure above,
|
||||||
|
but a review pointed out they're a sharper version of the same problem: this is the image *every*
|
||||||
|
project container is created from (`models::container_config::resolve_image_name`), so a crafted
|
||||||
|
export pointing it at an attacker-controlled image is a path to running arbitrary code with
|
||||||
|
whatever a project's containers are allowed to reach, not merely a redirected API endpoint.
|
||||||
|
`describeImportWarnings` fires on `image_source == Custom` unconditionally rather than only when
|
||||||
|
it differs from the destination's current value, since re-importing the same risky configuration
|
||||||
|
is still worth surfacing every time a user confirms an import.
|
||||||
|
- **Every free-form string a preview surfaces is sanitized and length-capped before it's built.**
|
||||||
|
`SettingsImportPreview::from_payload`'s `sanitize_for_preview` strips control characters and caps
|
||||||
|
at 100 characters (`MAX_PREVIEW_STRING_LEN`) for every base URL and the custom image name — a
|
||||||
|
review noted that, unlike the count- and boolean-derived fields the preview started with, these
|
||||||
|
are verbatim strings from a not-yet-trusted decrypted payload rendered directly into the
|
||||||
|
confirmation dialog. Unbounded, a single pathological value (very long, or holding embedded
|
||||||
|
newlines) could push the security warnings above the scroll fold in the dialog that exists
|
||||||
|
specifically to make them unmissable — the frontend's `<li>`/warning boxes also get `break-all`
|
||||||
|
as a second layer against the same failure mode.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
Linux ships as `.deb`, `.rpm` and AppImage, all three built by `build-app.yml` (releases) and
|
||||||
|
`build-app-preview.yml` (the PR check). **There is deliberately no Arch package.** A
|
||||||
|
`triple-c-bin` `PKGBUILD` and a `publish-arch-package.yml` existed and were removed; they live on
|
||||||
|
`hold/arch-packaging`. Do not re-add them without the piece that was always missing: the package
|
||||||
|
was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as
|
||||||
|
the AppImage, for a second artifact to keep working. Being `workflow_dispatch`-only it also
|
||||||
|
reached 1 release in 28, while `HOW-TO-USE.md` told Arch users to download it from every release.
|
||||||
|
An AUR account and its SSH key as a repo secret are what would make it worth having; until then
|
||||||
|
the AppImage is the Arch story.
|
||||||
|
|
||||||
|
`scripts/install-appimage.sh` is the desktop-integration half, and it exists because an AppImage
|
||||||
|
has no installer: it extracts the bundled icons into `~/.local/share/icons/hicolor` and writes a
|
||||||
|
`.desktop` entry. It **rewrites** the `Exec` line rather than copying the bundled entry — the
|
||||||
|
bundled one is `Exec=triple-c`, which resolves only inside the AppImage's own mount, so a
|
||||||
|
verbatim copy yields a launcher entry that starts nothing. It keeps `StartupWMClass` exactly as
|
||||||
|
the bundle sets it, which is what lets the shell match the window to the entry. Extraction uses
|
||||||
|
`--appimage-extract`, which needs no FUSE, so the script works before `fuse2` is installed.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Installation](#installation)
|
||||||
- [Prerequisites](#prerequisites)
|
- [Prerequisites](#prerequisites)
|
||||||
- [First Launch](#first-launch)
|
- [First Launch](#first-launch)
|
||||||
- [The Interface](#the-interface)
|
- [The Interface](#the-interface)
|
||||||
@@ -32,6 +33,63 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Download the build for your platform from [GitHub Releases](https://github.com/shadowdao/triple-c/releases/latest).
|
||||||
|
|
||||||
|
| Platform | File | Install |
|
||||||
|
|----------|------|---------|
|
||||||
|
| **Windows** | `Triple-C_<version>_x64-setup.exe` or `.msi` | Run the installer. |
|
||||||
|
| **macOS** | `Triple-C_<version>_universal.dmg` | Open the `.dmg` and drag Triple-C to Applications. |
|
||||||
|
| **Debian / Ubuntu** | `Triple-C_<version>_amd64.deb` | `sudo apt install ./Triple-C_<version>_amd64.deb` |
|
||||||
|
| **Fedora / RHEL** | `Triple-C-<version>-1.x86_64.rpm` | `sudo dnf install ./Triple-C-<version>-1.x86_64.rpm` |
|
||||||
|
| **Arch / CachyOS / other Linux** | `Triple-C_<version>_amd64.AppImage` | `chmod +x` it, then run it directly. See the AppImage notes below. |
|
||||||
|
|
||||||
|
> **macOS note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it — right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`.
|
||||||
|
|
||||||
|
> **AppImage note:** Two things are worth knowing. Running an AppImage needs FUSE 2, which Arch and CachyOS do not install by default — `sudo pacman -S fuse2` once, or run it with `--appimage-extract-and-run` to sidestep FUSE entirely. And an AppImage is just an executable file: nothing registers it with the desktop, so it will not appear in your app launcher on its own. Run [`scripts/install-appimage.sh`](scripts/install-appimage.sh) to add a launcher entry and icons — see [Adding an AppImage to the app launcher](#adding-an-appimage-to-the-app-launcher).
|
||||||
|
|
||||||
|
> **No Arch package.** There was a `triple-c-bin` `.pkg.tar.zst` attached to some releases, built by a maintainer-triggered workflow. It was never on the AUR, so installing it meant downloading a file and running `pacman -U` — no better than the AppImage — and being manual-only it reached 1 release in 28, which made the promise of it worse than not making it. The `PKGBUILD` and its workflow are preserved on the `hold/arch-packaging` branch if an AUR package is ever worth doing properly.
|
||||||
|
|
||||||
|
### Adding an AppImage to the app launcher
|
||||||
|
|
||||||
|
An AppImage is a single executable file and nothing else. It carries a `.desktop`
|
||||||
|
entry and icons *inside* itself, but nothing on your system ever reads them,
|
||||||
|
because nothing installed it — so it will not show up in your app launcher, and
|
||||||
|
running it from a file manager gives you a generic icon in the taskbar.
|
||||||
|
|
||||||
|
Put the AppImage somewhere stable first — `~/Apps` or `~/.local/bin`, not
|
||||||
|
`~/Downloads` — because the launcher entry points at wherever the file is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/Apps
|
||||||
|
mv ~/Downloads/Triple-C_*_amd64.AppImage ~/Apps/
|
||||||
|
./scripts/install-appimage.sh ~/Apps/Triple-C_0.4.17_amd64.AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
That copies the bundled icons into `~/.local/share/icons/hicolor` and writes
|
||||||
|
`~/.local/share/applications/triple-c.desktop` pointing at the file you named.
|
||||||
|
No sudo, nothing outside your home directory, and the AppImage itself is never
|
||||||
|
copied or moved. To remove the entry again:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install-appimage.sh --uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
The script rewrites the `Exec` line rather than reusing the bundled `.desktop`
|
||||||
|
verbatim: the bundled one says `Exec=triple-c`, which resolves only inside the
|
||||||
|
running AppImage's own mount, so a launcher entry copied straight out of the
|
||||||
|
bundle would appear in the menu and then fail to start anything.
|
||||||
|
|
||||||
|
Two follow-ups worth knowing:
|
||||||
|
|
||||||
|
- **Upgrading.** The entry names one specific file. If you replace the AppImage
|
||||||
|
with a newer version under a different filename, re-run the script against the
|
||||||
|
new one. Keeping a stable name (`~/Apps/Triple-C.AppImage`) avoids this.
|
||||||
|
- **The icon may not appear until you log out.** That is the desktop shell's
|
||||||
|
icon cache, not a failed install — see
|
||||||
|
[App Icon Missing After Installing (Linux)](#app-icon-missing-after-installing-linux).
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
@@ -1536,3 +1594,9 @@ cp ~/.claude.json ~/.claude.json.bak && jq 'with_entries(select(.key | startswit
|
|||||||
```
|
```
|
||||||
|
|
||||||
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.
|
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.
|
||||||
|
|
||||||
|
### App Icon Missing After Installing (Linux)
|
||||||
|
|
||||||
|
If Triple-C's icon shows as generic or blank right after installing — in the app menu, taskbar, and window titlebar alike — **log out and back in.**
|
||||||
|
|
||||||
|
Desktop shells (GNOME Shell, KDE Plasma) cache the list of installed apps and their resolved icons in memory when the shell starts, for performance. A freshly installed package's icon files land on disk correctly and its install hooks do rebuild the on-disk icon cache, but an already-running shell doesn't always notice — on X11 there used to be a way to soft-restart just the shell (GNOME's Alt+F2 → `r`) to force a reload, but under Wayland the shell *is* the compositor, so restarting it means ending the session. Logging out and back in starts a fresh shell that reads the current on-disk state, which picks the icon up.
|
||||||
|
|||||||
@@ -418,12 +418,6 @@ triple-c/
|
|||||||
│ ├── build-stt.yml # Build the STT image
|
│ ├── build-stt.yml # Build the STT image
|
||||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||||
│ ├── cleanup-releases.yml # Prune old releases
|
│ ├── cleanup-releases.yml # Prune old releases
|
||||||
│ └── publish-aur-package.yml # Publish triple-c-bin to the AUR (packaging/arch/)
|
|
||||||
│
|
|
||||||
├── packaging/
|
|
||||||
│ └── arch/ # AUR triple-c-bin package — see packaging/arch/README.md
|
|
||||||
│ ├── PKGBUILD
|
|
||||||
│ └── README.md
|
|
||||||
│
|
│
|
||||||
└── app/ # Tauri v2 desktop application
|
└── app/ # Tauri v2 desktop application
|
||||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||||
|
|||||||
Generated
+144
@@ -8,6 +8,41 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aead"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes-gcm"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||||
|
dependencies = [
|
||||||
|
"aead",
|
||||||
|
"aes",
|
||||||
|
"cipher",
|
||||||
|
"ctr",
|
||||||
|
"ghash",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -47,6 +82,18 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -280,6 +327,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -310,6 +363,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -569,6 +631,16 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -694,6 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core 0.6.4",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -753,6 +826,15 @@ version = "0.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctr"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.20.11"
|
version = "0.20.11"
|
||||||
@@ -923,6 +1005,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1550,6 +1633,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ghash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||||
|
dependencies = [
|
||||||
|
"opaque-debug",
|
||||||
|
"polyval",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gio"
|
name = "gio"
|
||||||
version = "0.18.4"
|
version = "0.18.4"
|
||||||
@@ -2114,6 +2207,15 @@ dependencies = [
|
|||||||
"cfb",
|
"cfb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -2831,6 +2933,12 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.3.3"
|
version = "5.3.3"
|
||||||
@@ -2913,6 +3021,17 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathdiff"
|
name = "pathdiff"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -3194,6 +3313,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polyval"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"opaque-debug",
|
||||||
|
"universal-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -5149,6 +5280,8 @@ dependencies = [
|
|||||||
name = "triple-c"
|
name = "triple-c"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bollard",
|
"bollard",
|
||||||
@@ -5174,6 +5307,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5287,6 +5421,16 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "universal-hash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ tower-http = { version = "0.6", features = ["cors"] }
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
local-ip-address = "0.6"
|
local-ip-address = "0.6"
|
||||||
|
argon2 = "0.5"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
zeroize = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ pub mod help_commands;
|
|||||||
pub mod inspect_commands;
|
pub mod inspect_commands;
|
||||||
pub mod install_helper_commands;
|
pub mod install_helper_commands;
|
||||||
pub mod migration_commands;
|
pub mod migration_commands;
|
||||||
|
pub mod notes_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
|
pub mod settings_export_commands;
|
||||||
pub mod stt_commands;
|
pub mod stt_commands;
|
||||||
pub mod terminal_commands;
|
pub mod terminal_commands;
|
||||||
pub mod update_commands;
|
pub mod update_commands;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use crate::models::Note;
|
||||||
|
use crate::storage::notes_store;
|
||||||
|
|
||||||
|
/// Every project's notes, oldest concept first: pinned notes, then most
|
||||||
|
/// recently edited.
|
||||||
|
///
|
||||||
|
/// Sorted here rather than in the webview so the dock and the tab — two views
|
||||||
|
/// of the same list — cannot drift into two different orders.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_notes(project_id: String) -> Result<Vec<Note>, String> {
|
||||||
|
let mut notes = notes_store::load(&project_id)?;
|
||||||
|
notes.sort_by(|a, b| {
|
||||||
|
b.pinned
|
||||||
|
.cmp(&a.pinned)
|
||||||
|
.then_with(|| b.updated_at.cmp(&a.updated_at))
|
||||||
|
});
|
||||||
|
Ok(notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or replace one note.
|
||||||
|
///
|
||||||
|
/// There is deliberately no whole-list setter. A bulk write is exactly the
|
||||||
|
/// clobbering this store's per-project file exists to avoid, and every caller
|
||||||
|
/// here is editing one note.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_note(project_id: String, note: Note) -> Result<Note, String> {
|
||||||
|
notes_store::upsert(&project_id, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_note(project_id: String, note_id: String) -> Result<(), String> {
|
||||||
|
notes_store::delete(&project_id, ¬e_id)
|
||||||
|
}
|
||||||
@@ -722,6 +722,15 @@ pub async fn remove_project(
|
|||||||
// holding an entire snapshot image that nothing will ever reference again.
|
// holding an entire snapshot image that nothing will ever reference again.
|
||||||
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
||||||
|
|
||||||
|
// A project's notes are the one piece of its state that is purely the
|
||||||
|
// user's prose, so removal takes them with it rather than leaving an
|
||||||
|
// orphan file keyed by an id nothing will ever look up again. Logged and
|
||||||
|
// not propagated: an orphaned notes file is harmless, and a project that
|
||||||
|
// cannot be removed is not.
|
||||||
|
if let Err(e) = crate::storage::notes_store::clear(&project_id) {
|
||||||
|
log::warn!("Could not remove notes for project {}: {}", project_id, e);
|
||||||
|
}
|
||||||
|
|
||||||
// Stop and remove container if it exists. Everything named in `report`
|
// Stop and remove container if it exists. Everything named in `report`
|
||||||
// below is what will be unreachable the moment this function drops the
|
// below is what will be unreachable the moment this function drops the
|
||||||
// project record — see [`ProjectRemovalReport`] and
|
// project record — see [`ProjectRemovalReport`] and
|
||||||
|
|||||||
@@ -10,19 +10,24 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, Str
|
|||||||
Ok(state.settings_store.get())
|
Ok(state.settings_store.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
/// Everything `update_settings` refuses a save over, run against the store's
|
||||||
pub async fn update_settings(
|
/// *current* value and the incoming one.
|
||||||
settings: AppSettings,
|
///
|
||||||
state: State<'_, AppState>,
|
/// Pulled out so a caller that does other, harder-to-undo work alongside a
|
||||||
) -> Result<AppSettings, String> {
|
/// settings save — `settings_export_commands::apply_settings_import`
|
||||||
let before = state.settings_store.get();
|
/// restores three keychain secrets in the same command — can run this
|
||||||
|
/// *first* and bail before touching anything, rather than discovering the
|
||||||
|
/// rejection only when `update_settings` itself runs partway through.
|
||||||
|
pub fn validate_settings_update(
|
||||||
|
before: &AppSettings,
|
||||||
|
incoming: &AppSettings,
|
||||||
|
) -> Result<(), String> {
|
||||||
// The global half of the same rule the project half gets in
|
// The global half of the same rule the project half gets in
|
||||||
// `update_project`: a global custom env var is merged into every project's
|
// `update_project`: a global custom env var is merged into every project's
|
||||||
// container environment, so an unchecked name here reaches all of them.
|
// container environment, so an unchecked name here reaches all of them.
|
||||||
crate::models::validate_env_vars_update(
|
crate::models::validate_env_vars_update(
|
||||||
&before.global_custom_env_vars,
|
&before.global_custom_env_vars,
|
||||||
&settings.global_custom_env_vars,
|
&incoming.global_custom_env_vars,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// The same for the two host paths this struct owns. `update_project`
|
// The same for the two host paths this struct owns. `update_project`
|
||||||
@@ -40,14 +45,37 @@ pub async fn update_settings(
|
|||||||
crate::commands::project_commands::validate_mounted_host_path(
|
crate::commands::project_commands::validate_mounted_host_path(
|
||||||
"SSH key path",
|
"SSH key path",
|
||||||
before.default_ssh_key_path.as_deref(),
|
before.default_ssh_key_path.as_deref(),
|
||||||
settings.default_ssh_key_path.as_deref(),
|
incoming.default_ssh_key_path.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
crate::commands::project_commands::validate_mounted_host_path(
|
crate::commands::project_commands::validate_mounted_host_path(
|
||||||
"CA certificate path",
|
"CA certificate path",
|
||||||
before.ca_cert_path.as_deref(),
|
before.ca_cert_path.as_deref(),
|
||||||
settings.ca_cert_path.as_deref(),
|
incoming.ca_cert_path.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// Third host path this struct owns, same reasoning: any project with
|
||||||
|
// `allow_docker_access` bind-mounts this path in as the Docker socket
|
||||||
|
// (`project_commands.rs`'s container creation), so an unchecked value
|
||||||
|
// here is a read-write bind mount of whatever it names into every such
|
||||||
|
// project's container.
|
||||||
|
crate::commands::project_commands::validate_mounted_host_path(
|
||||||
|
"Docker socket path",
|
||||||
|
before.docker_socket_path.as_deref(),
|
||||||
|
incoming.docker_socket_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_settings(
|
||||||
|
settings: AppSettings,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<AppSettings, String> {
|
||||||
|
let before = state.settings_store.get();
|
||||||
|
|
||||||
|
validate_settings_update(&before, &settings)?;
|
||||||
|
|
||||||
let saved = state.settings_store.update(settings)?;
|
let saved = state.settings_store.update(settings)?;
|
||||||
|
|
||||||
// Persisting a setting is not the same as applying it. The gateway is the
|
// Persisting a setting is not the same as applying it. The gateway is the
|
||||||
@@ -122,7 +150,10 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
|||||||
GatewayAction::StopIfRunning => {
|
GatewayAction::StopIfRunning => {
|
||||||
log::info!("Model gateway disabled in settings — stopping the container");
|
log::info!("Model gateway disabled in settings — stopping the container");
|
||||||
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
||||||
log::error!("Failed to stop the model gateway after it was disabled: {}", e);
|
log::error!(
|
||||||
|
"Failed to stop the model gateway after it was disabled: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GatewayAction::RestartIfRunning => {
|
GatewayAction::RestartIfRunning => {
|
||||||
@@ -138,10 +169,7 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn pull_image(
|
pub async fn pull_image(image_name: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
image_name: String,
|
|
||||||
app_handle: tauri::AppHandle,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
docker::pull_image(&image_name, move |msg| {
|
docker::pull_image(&image_name, move |msg| {
|
||||||
let _ = app_handle.emit("image-pull-progress", msg);
|
let _ = app_handle.emit("image-pull-progress", msg);
|
||||||
@@ -334,7 +362,10 @@ mod tests {
|
|||||||
let before = enabled_gateway();
|
let before = enabled_gateway();
|
||||||
let mut after = before.clone();
|
let mut after = before.clone();
|
||||||
after.enabled = false;
|
after.enabled = false;
|
||||||
assert_eq!(gateway_action(&before, &after), GatewayAction::StopIfRunning);
|
assert_eq!(
|
||||||
|
gateway_action(&before, &after),
|
||||||
|
GatewayAction::StopIfRunning
|
||||||
|
);
|
||||||
// Still true when it was already off — a stray running container is
|
// Still true when it was already off — a stray running container is
|
||||||
// still a container that shouldn't be up.
|
// still a container that shouldn't be up.
|
||||||
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
|
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
|
||||||
|
|||||||
@@ -0,0 +1,654 @@
|
|||||||
|
//! Settings export/import — see triple-c#35.
|
||||||
|
//!
|
||||||
|
//! Exports the *host* environment (global `AppSettings` plus the global
|
||||||
|
//! secrets kept in the OS keychain: the shared Claude Code OAuth login and
|
||||||
|
//! the model gateway's two keys), encrypted with a user-chosen password —
|
||||||
|
//! see `storage::settings_crypto` for the actual cryptography. Deliberately
|
||||||
|
//! out of scope: per-project settings, per-project secrets, and anything
|
||||||
|
//! living in a project's Docker volumes.
|
||||||
|
//!
|
||||||
|
//! **The save/open dialogs are opened from Rust**, the same pattern
|
||||||
|
//! `file_commands.rs`'s `pick_save_path`/`pick_files_to_upload` already
|
||||||
|
//! establish and document at length: a frontend-driven dialog handing Rust a
|
||||||
|
//! host path string is the exact shape of bug that produced this app's past
|
||||||
|
//! criticals, so the boundary here is drawn the same place. The frontend can
|
||||||
|
//! ask for a picker; it cannot name a host path as an *input*. `preview_
|
||||||
|
//! settings_import` resolves the chosen path itself and remembers it
|
||||||
|
//! (`AppState::pending_settings_import`) so `apply_settings_import` re-reads
|
||||||
|
//! the same file without the path ever crossing back over IPC.
|
||||||
|
//!
|
||||||
|
//! The *decrypted payload* is not cached between preview and apply — the
|
||||||
|
//! password the frontend passes to each call is what it already held for
|
||||||
|
//! the first, not a fresh secret extracted from the user, but nothing here
|
||||||
|
//! keeps the plaintext itself — export/import secrets included — around for
|
||||||
|
//! longer than one command's execution; `apply_settings_import` re-decrypts
|
||||||
|
//! the file rather than reusing anything `preview_settings_import` computed.
|
||||||
|
//!
|
||||||
|
//! **This is new attack surface**: a settings export is a file one person
|
||||||
|
//! can hand another and ask them to import, together with a password, and
|
||||||
|
//! `apply_settings_import` applies whatever `AppSettings` it decrypts to
|
||||||
|
//! wholesale — see the module doc on `models::settings_export` for the
|
||||||
|
//! `web_terminal.access_token` carve-out a review of this feature found,
|
||||||
|
//! and treat that as the standing example of the class of thing to keep
|
||||||
|
//! checking for here, not a one-off fixed bug.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::models::{
|
||||||
|
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
|
||||||
|
SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
|
||||||
|
};
|
||||||
|
use crate::storage::{secure, settings_crypto};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// What `preview_settings_import` pins so `apply_settings_import` can tell
|
||||||
|
/// whether the file it's about to re-read is the same one the user actually
|
||||||
|
/// saw a preview of. Confirming a preview is only meaningful if it's binding
|
||||||
|
/// on what gets applied — without this, a file replaced on disk between the
|
||||||
|
/// two calls (this app's own stated threat model is a file shared between
|
||||||
|
/// people, which may sit in a synced or shared directory) would decrypt and
|
||||||
|
/// apply silently different content than what the confirmation dialog showed.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PendingSettingsImport {
|
||||||
|
path: PathBuf,
|
||||||
|
ciphertext_hash: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_ciphertext(data: &[u8]) -> [u8; 32] {
|
||||||
|
Sha256::digest(data).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_EXTENSION: &str = "triplec";
|
||||||
|
|
||||||
|
/// Enforced here, not only in the export modal: the frontend's minimum is a
|
||||||
|
/// UX nudge, but `export_settings` is the actual boundary a weak password
|
||||||
|
/// has to cross, and Argon2id's memory-hardness buys little against an
|
||||||
|
/// attacker who can just try a three-character password directly.
|
||||||
|
const MIN_PASSWORD_LEN: usize = 8;
|
||||||
|
|
||||||
|
fn suggested_export_name() -> String {
|
||||||
|
// Timestamped so exporting more than once doesn't silently overwrite an
|
||||||
|
// earlier file just because the save dialog defaults to the same name.
|
||||||
|
format!(
|
||||||
|
"triple-c-settings-{}.{}",
|
||||||
|
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
|
||||||
|
FILE_EXTENSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pick_export_save_path(window: &tauri::Window, suggested: &str) -> Option<PathBuf> {
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
window
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_parent(window)
|
||||||
|
.set_title("Export Triple-C settings")
|
||||||
|
.set_file_name(suggested)
|
||||||
|
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||||
|
.save_file(move |picked| {
|
||||||
|
let _ = tx.send(picked);
|
||||||
|
});
|
||||||
|
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pick_import_open_path(window: &tauri::Window) -> Option<PathBuf> {
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
window
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_parent(window)
|
||||||
|
.set_title("Import Triple-C settings")
|
||||||
|
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||||
|
.pick_file(move |picked| {
|
||||||
|
let _ = tx.send(picked);
|
||||||
|
});
|
||||||
|
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gather the current global secrets, and hand back the `AppSettings` to
|
||||||
|
/// export with the web-terminal token blanked out of it — see the module
|
||||||
|
/// doc comment on `models::settings_export` for why that field cannot
|
||||||
|
/// travel through `settings` like the rest of this struct.
|
||||||
|
///
|
||||||
|
/// A missing keychain secret reads as `None` — a keychain read failure is
|
||||||
|
/// treated as "nothing to export" for that one entry rather than aborting
|
||||||
|
/// the whole export, matching how the rest of this app degrades a keychain
|
||||||
|
/// error to "absent" (`has_claude_oauth_token`, `has_gateway_api_key`)
|
||||||
|
/// rather than surfacing it as a hard failure.
|
||||||
|
fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSecrets) {
|
||||||
|
let mut settings = current;
|
||||||
|
let web_terminal_access_token = settings.web_terminal.access_token.take();
|
||||||
|
|
||||||
|
let secrets = ExportedSecrets {
|
||||||
|
claude_oauth_token: secure::get_claude_oauth_token().unwrap_or_default(),
|
||||||
|
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
|
||||||
|
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
|
||||||
|
web_terminal_access_token,
|
||||||
|
};
|
||||||
|
|
||||||
|
(settings, secrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the current global settings and secrets to a password-encrypted
|
||||||
|
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
|
||||||
|
/// deliberately distinguishable from one so the frontend shows nothing
|
||||||
|
/// rather than a "failed" toast for a plain cancel.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_settings(
|
||||||
|
password: String,
|
||||||
|
window: tauri::Window,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
// `.chars().count()` — Unicode scalar values, not bytes — to stay as
|
||||||
|
// close as this pair of languages allows to the frontend's `.length`
|
||||||
|
// check (UTF-16 code units); the two only diverge on astral-plane
|
||||||
|
// characters, which no reasonable password touches.
|
||||||
|
if password.chars().count() < MIN_PASSWORD_LEN {
|
||||||
|
return Err(format!(
|
||||||
|
"Use a password of at least {} characters.",
|
||||||
|
MIN_PASSWORD_LEN
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(dest) = pick_export_save_path(&window, &suggested_export_name()).await else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let (settings, secrets) = split_settings_and_secrets(state.settings_store.get());
|
||||||
|
if secrets.is_empty() {
|
||||||
|
log::info!("Exporting settings with no global secrets configured on this machine");
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = SettingsExportPayload {
|
||||||
|
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
||||||
|
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
settings,
|
||||||
|
secrets,
|
||||||
|
};
|
||||||
|
|
||||||
|
let plaintext = Zeroizing::new(
|
||||||
|
serde_json::to_vec(&payload)
|
||||||
|
.map_err(|e| format!("Failed to prepare settings for export: {}", e))?,
|
||||||
|
);
|
||||||
|
let encrypted = settings_crypto::encrypt(&plaintext, &password)?;
|
||||||
|
|
||||||
|
std::fs::write(&dest, &encrypted).map_err(|e| format!("Failed to write export file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a file picker, decrypt the chosen file with `password`, and return a
|
||||||
|
/// preview (counts and presence flags only — never a secret value) for a
|
||||||
|
/// confirmation UI. `Ok(None)` means the picker was dismissed.
|
||||||
|
///
|
||||||
|
/// Remembers the resolved path *and a hash of the file's ciphertext* in
|
||||||
|
/// `AppState::pending_settings_import` for `apply_settings_import` to check
|
||||||
|
/// against — does **not** remember the decrypted payload itself, so the
|
||||||
|
/// password must be supplied again to actually apply it — seeing the preview
|
||||||
|
/// is not the same as committing to it. The hash exists so it also can't be
|
||||||
|
/// swapped out from under that commitment: `apply_settings_import` refuses to
|
||||||
|
/// proceed if the file on disk no longer matches what was just previewed.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn preview_settings_import(
|
||||||
|
password: String,
|
||||||
|
window: tauri::Window,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Option<SettingsImportPreview>, String> {
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err("A password is required to open a settings export.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(path) = pick_import_open_path(&window).await else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||||
|
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
|
||||||
|
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
|
||||||
|
path,
|
||||||
|
ciphertext_hash: hash_ciphertext(&encrypted),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Some(preview))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the import a prior `preview_settings_import` call resolved a path
|
||||||
|
/// for. Fails if no preview is pending — this is not a general "decrypt and
|
||||||
|
/// apply this file" entry point, deliberately: seeing the preview first is
|
||||||
|
/// required, not just encouraged, since it is the only place a user is told
|
||||||
|
/// what an import is about to touch before it touches it. That requirement
|
||||||
|
/// is only real if the file can't change out from under it, so this also
|
||||||
|
/// refuses to proceed if the file's ciphertext no longer matches the hash
|
||||||
|
/// `preview_settings_import` pinned — a file replaced on disk between the
|
||||||
|
/// two calls (this feature's own threat model is a file shared between
|
||||||
|
/// people, which may sit in a synced or shared directory) must not be able
|
||||||
|
/// to apply silently different content than what the confirmation dialog
|
||||||
|
/// showed.
|
||||||
|
///
|
||||||
|
/// Global settings are replaced wholesale — an import is "restore this
|
||||||
|
/// environment," not a field-by-field merge. Global secrets are handled
|
||||||
|
/// differently and on purpose: **only secrets actually present in the
|
||||||
|
/// import are written**; a secret the export doesn't have is left alone on
|
||||||
|
/// this machine rather than cleared, because an absent secret in the export
|
||||||
|
/// means "the source machine never had this configured," not "delete this
|
||||||
|
/// on import." A user who wants to clear a secret already has dedicated UI
|
||||||
|
/// for that (signing out of shared auth, clearing the gateway key).
|
||||||
|
///
|
||||||
|
/// Order matters here, twice over.
|
||||||
|
///
|
||||||
|
/// First: the imported settings are **validated before any secret is
|
||||||
|
/// written**, using the same checks `update_settings` itself runs
|
||||||
|
/// (`settings_commands::validate_settings_update`). Restoring a secret is
|
||||||
|
/// hard to undo unnoticed — a stale env-var-name rejection or a disallowed
|
||||||
|
/// host path used to be caught only when `update_settings` ran, by which
|
||||||
|
/// point the three keychain secrets below were already overwritten with the
|
||||||
|
/// file's, each with a fresh rotation id, silently flagging every project
|
||||||
|
/// container for recreation — while the error the user saw talked only
|
||||||
|
/// about the rejected setting and said nothing about the credentials that
|
||||||
|
/// had already moved. Failing this check first makes a rejected import
|
||||||
|
/// leave nothing touched, matching what "the import failed" is supposed to
|
||||||
|
/// mean.
|
||||||
|
///
|
||||||
|
/// Second, among the things that *do* get written: secrets are restored
|
||||||
|
/// **before** the settings replace runs (which is what triggers
|
||||||
|
/// `reconcile_gateway`), so a gateway recreation that replace provokes sees
|
||||||
|
/// the final key material rather than racing it — restoring the other way
|
||||||
|
/// round left a real window where the running gateway and the keychain
|
||||||
|
/// briefly disagreed. A gateway *secret* alone (same shape, new key) is
|
||||||
|
/// invisible to `reconcile_gateway`'s shape comparison, so this additionally
|
||||||
|
/// nudges a running gateway container to recreate itself whenever a secret
|
||||||
|
/// this import carried was actually written — otherwise the running
|
||||||
|
/// container keeps serving the old key material indefinitely while every
|
||||||
|
/// project container is handed the new one.
|
||||||
|
///
|
||||||
|
/// A keychain write failing is reported back rather than only logged: an
|
||||||
|
/// import that silently restores two of three secrets but not the third
|
||||||
|
/// must not read as unqualified success.
|
||||||
|
///
|
||||||
|
/// The pending import is only cleared on success. A failure here (rejected
|
||||||
|
/// by the validation above, a stale-file mismatch, or some other error)
|
||||||
|
/// leaves it pending so the frontend can let the user retry `apply` without
|
||||||
|
/// making them pick the file and re-enter the password again — the
|
||||||
|
/// preview's job was confirming *what* to import, not spending the one
|
||||||
|
/// attempt at applying it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn apply_settings_import(
|
||||||
|
password: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<SettingsImportOutcome, String> {
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err("A password is required to import settings.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending = state
|
||||||
|
.pending_settings_import
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "No import is pending — choose a file first.".to_string())?;
|
||||||
|
|
||||||
|
let encrypted = std::fs::read(&pending.path)
|
||||||
|
.map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||||
|
if hash_ciphertext(&encrypted) != pending.ciphertext_hash {
|
||||||
|
return Err(
|
||||||
|
"This file changed since you reviewed it — choose it again to see an up-to-date preview."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||||
|
|
||||||
|
let current = state.settings_store.get();
|
||||||
|
|
||||||
|
// The web-terminal token lives inside `AppSettings` itself rather than
|
||||||
|
// the keychain, so "leave an absent secret alone" has to be done by
|
||||||
|
// hand here: carry the destination's current token forward when the
|
||||||
|
// import doesn't have one, instead of letting the wholesale replace
|
||||||
|
// below blank it (every export writes `None` there — see
|
||||||
|
// `split_settings_and_secrets`).
|
||||||
|
let mut settings = payload.settings;
|
||||||
|
settings.web_terminal.access_token = non_blank(payload.secrets.web_terminal_access_token)
|
||||||
|
.or_else(|| current.web_terminal.access_token.clone());
|
||||||
|
|
||||||
|
crate::commands::settings_commands::validate_settings_update(¤t, &settings)?;
|
||||||
|
|
||||||
|
let mut secret_restore_warnings = Vec::new();
|
||||||
|
let mut gateway_secret_changed = false;
|
||||||
|
|
||||||
|
if let Some(token) = non_blank(payload.secrets.claude_oauth_token) {
|
||||||
|
if let Err(e) = secure::store_claude_oauth_token(&token) {
|
||||||
|
log::warn!(
|
||||||
|
"Settings import: could not restore the shared Claude login: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
secret_restore_warnings
|
||||||
|
.push(format!("Could not restore your shared Claude login: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(key) = non_blank(payload.secrets.gateway_api_key) {
|
||||||
|
match secure::store_gateway_api_key(&key) {
|
||||||
|
Ok(()) => gateway_secret_changed = true,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Settings import: could not restore the gateway provider API key: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
secret_restore_warnings.push(format!(
|
||||||
|
"Could not restore the gateway provider API key: {}",
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(key) = non_blank(payload.secrets.gateway_master_key) {
|
||||||
|
match secure::store_gateway_master_key(&key) {
|
||||||
|
Ok(()) => gateway_secret_changed = true,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Settings import: could not restore the gateway master key: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
secret_restore_warnings
|
||||||
|
.push(format!("Could not restore the gateway master key: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved =
|
||||||
|
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
|
||||||
|
|
||||||
|
// `reconcile_gateway` (inside `update_settings`) only reacts to a changed
|
||||||
|
// *shape* — port, provider, base URL, models — because that's what's
|
||||||
|
// rendered into the container's config. A secret changing with the shape
|
||||||
|
// held constant is invisible to it, so a running gateway container would
|
||||||
|
// otherwise keep serving the old key material forever after an import
|
||||||
|
// that restored a new one, while `docker::gateway`'s own fingerprint
|
||||||
|
// (which does include the secret rotation id) means the *next* unrelated
|
||||||
|
// settings save would suddenly and confusingly recreate it instead.
|
||||||
|
if gateway_secret_changed && saved.gateway.enabled {
|
||||||
|
match crate::docker::gateway::gateway_container_presence().await {
|
||||||
|
Ok((true, true)) => {
|
||||||
|
if let Err(e) = crate::docker::gateway::ensure_gateway_running(&saved.gateway).await
|
||||||
|
{
|
||||||
|
log::error!(
|
||||||
|
"Settings import: could not apply the restored gateway credentials to the running gateway container: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => log::debug!("Settings import: gateway reconcile skipped ({})", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pending_settings_import.lock().await.take();
|
||||||
|
|
||||||
|
Ok(SettingsImportOutcome {
|
||||||
|
settings: saved,
|
||||||
|
secret_restore_warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_blank(value: Option<String>) -> Option<String> {
|
||||||
|
value.filter(|v| !v.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the field `read_and_decrypt` needs before deciding whether the rest
|
||||||
|
/// of the payload is even worth attempting to parse.
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct FormatVersionProbe {
|
||||||
|
format_version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and decrypt an export file at `path`, then parse it — see
|
||||||
|
/// `read_and_decrypt_bytes` for why the format-version check runs before the
|
||||||
|
/// full parse. Every real caller already has the file's bytes in hand by the
|
||||||
|
/// time it needs this (`preview_settings_import`/`apply_settings_import`
|
||||||
|
/// both hash the ciphertext first) and calls `read_and_decrypt_bytes`
|
||||||
|
/// directly to avoid reading the file twice; this path-based wrapper only
|
||||||
|
/// exists now for tests that don't need that.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn read_and_decrypt(path: &Path, password: &str) -> Result<SettingsExportPayload, String> {
|
||||||
|
let encrypted =
|
||||||
|
std::fs::read(path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||||
|
read_and_decrypt_bytes(&encrypted, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt and parse an already-read export file's bytes, checking the
|
||||||
|
/// format version **before** attempting to deserialize the full payload.
|
||||||
|
///
|
||||||
|
/// That ordering is not just tidiness: a version bump that isn't
|
||||||
|
/// deserialize-compatible (a field's type changes, not just a new
|
||||||
|
/// `#[serde(default)]`-covered one) is exactly the case this check exists
|
||||||
|
/// for, and parsing the full struct first would fail on the shape mismatch
|
||||||
|
/// before the version check ever ran, surfacing a raw parse error instead
|
||||||
|
/// of "update Triple-C" — and, more seriously, `serde_json`'s type-mismatch
|
||||||
|
/// errors quote the offending value inline. This file is not attacker
|
||||||
|
/// content in the usual sense (it must still decrypt under the right
|
||||||
|
/// password), but the plaintext it decrypts to can hold a live credential,
|
||||||
|
/// so neither error path below ever interpolates what `serde_json`
|
||||||
|
/// actually says — only a fixed, generic message.
|
||||||
|
fn read_and_decrypt_bytes(encrypted: &[u8], password: &str) -> Result<SettingsExportPayload, String> {
|
||||||
|
let plaintext = settings_crypto::decrypt(encrypted, password)?;
|
||||||
|
|
||||||
|
let probe: FormatVersionProbe = serde_json::from_slice(&plaintext)
|
||||||
|
.map_err(|_| "This file doesn't look like a valid settings export.".to_string())?;
|
||||||
|
if probe.format_version > SETTINGS_EXPORT_FORMAT_VERSION {
|
||||||
|
return Err(format!(
|
||||||
|
"This export was made by a newer version of Triple-C (format {}, this app supports up to {}). \
|
||||||
|
Update Triple-C before importing it.",
|
||||||
|
probe.format_version, SETTINGS_EXPORT_FORMAT_VERSION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_slice(&plaintext).map_err(|_| {
|
||||||
|
"This file doesn't look like a valid settings export (unexpected shape).".to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_blank_treats_whitespace_only_as_absent() {
|
||||||
|
assert_eq!(non_blank(Some(" ".to_string())), None);
|
||||||
|
assert_eq!(non_blank(Some("".to_string())), None);
|
||||||
|
assert_eq!(non_blank(None), None);
|
||||||
|
assert_eq!(non_blank(Some(" a ".to_string())), Some(" a ".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ciphertext_hashing_is_deterministic_and_tamper_sensitive() {
|
||||||
|
// What `apply_settings_import` compares against the pinned hash from
|
||||||
|
// `preview_settings_import` to detect a file swapped out from under a
|
||||||
|
// pending import — this only defends anything if identical bytes
|
||||||
|
// always hash identically and any change to those bytes changes the
|
||||||
|
// hash.
|
||||||
|
let bytes = b"pretend this is an encrypted export file";
|
||||||
|
assert_eq!(hash_ciphertext(bytes), hash_ciphertext(bytes));
|
||||||
|
|
||||||
|
let mut tampered = bytes.to_vec();
|
||||||
|
tampered[0] ^= 0xFF;
|
||||||
|
assert_ne!(hash_ciphertext(bytes), hash_ciphertext(&tampered));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_export(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
name: &str,
|
||||||
|
payload: &SettingsExportPayload,
|
||||||
|
password: &str,
|
||||||
|
) -> PathBuf {
|
||||||
|
write_raw_export(dir, name, &serde_json::to_value(payload).unwrap(), password)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `write_export`, but takes an arbitrary `serde_json::Value` rather
|
||||||
|
/// than a real `SettingsExportPayload` — for fixtures that are
|
||||||
|
/// deliberately not shape-compatible, which the typed helper above can't
|
||||||
|
/// produce at all.
|
||||||
|
fn write_raw_export(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
name: &str,
|
||||||
|
value: &serde_json::Value,
|
||||||
|
password: &str,
|
||||||
|
) -> PathBuf {
|
||||||
|
let plaintext = serde_json::to_vec(value).unwrap();
|
||||||
|
let encrypted = settings_crypto::encrypt(&plaintext, password).unwrap();
|
||||||
|
let path = dir.join(name);
|
||||||
|
std::fs::write(&path, &encrypted).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splitting_settings_moves_the_web_terminal_token_out_rather_than_copying_it() {
|
||||||
|
let mut settings = AppSettings::default();
|
||||||
|
settings.web_terminal.access_token = Some("super-secret-token".to_string());
|
||||||
|
|
||||||
|
let (settings, secrets) = split_settings_and_secrets(settings);
|
||||||
|
|
||||||
|
assert_eq!(settings.web_terminal.access_token, None);
|
||||||
|
assert_eq!(
|
||||||
|
secrets.web_terminal_access_token,
|
||||||
|
Some("super-secret-token".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splitting_settings_with_no_token_leaves_it_absent_on_both_sides() {
|
||||||
|
let (settings, secrets) = split_settings_and_secrets(AppSettings::default());
|
||||||
|
|
||||||
|
assert_eq!(settings.web_terminal.access_token, None);
|
||||||
|
assert_eq!(secrets.web_terminal_access_token, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_payload(format_version: u32) -> SettingsExportPayload {
|
||||||
|
SettingsExportPayload {
|
||||||
|
format_version,
|
||||||
|
exported_at: "2026-08-27T00:00:00Z".to_string(),
|
||||||
|
app_version: "0.4.14".to_string(),
|
||||||
|
settings: AppSettings::default(),
|
||||||
|
secrets: ExportedSecrets::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"triple-c-settings-export-test-{}-{}",
|
||||||
|
name,
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_from_a_newer_format_is_refused_before_the_full_shape_is_parsed() {
|
||||||
|
// Shape-incompatible with the *current* `SettingsExportPayload` (a
|
||||||
|
// future version could easily have changed `settings` from an object
|
||||||
|
// to something else) as well as newer — so this only passes under
|
||||||
|
// the probe-first ordering. Parsing the full struct first (the old
|
||||||
|
// behavior) would fail on the shape mismatch and never reach the
|
||||||
|
// version check, producing the "unexpected shape" message instead of
|
||||||
|
// "newer version" / "Update Triple-C".
|
||||||
|
let dir = temp_dir("newer-format");
|
||||||
|
let path = write_raw_export(
|
||||||
|
&dir,
|
||||||
|
"export.triplec",
|
||||||
|
&serde_json::json!({
|
||||||
|
"format_version": SETTINGS_EXPORT_FORMAT_VERSION + 1,
|
||||||
|
"exported_at": "2026-08-27T00:00:00Z",
|
||||||
|
"app_version": "9.9.9",
|
||||||
|
"settings": "this-app-version-stores-settings-differently",
|
||||||
|
"secrets": {},
|
||||||
|
}),
|
||||||
|
"correct password",
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = read_and_decrypt(&path, "correct password").unwrap_err();
|
||||||
|
assert!(err.contains("newer version"), "unexpected message: {}", err);
|
||||||
|
assert!(err.contains("Update Triple-C"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_at_the_current_format_is_accepted() {
|
||||||
|
let dir = temp_dir("current-format");
|
||||||
|
let path = write_export(
|
||||||
|
&dir,
|
||||||
|
"export.triplec",
|
||||||
|
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
|
||||||
|
"correct password",
|
||||||
|
);
|
||||||
|
|
||||||
|
let payload = read_and_decrypt(&path, "correct password").unwrap();
|
||||||
|
assert_eq!(payload.format_version, SETTINGS_EXPORT_FORMAT_VERSION);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_payload_produces_a_generic_error_not_a_raw_serde_message() {
|
||||||
|
// A `format_version` the probe accepts, but a `settings` field of
|
||||||
|
// the wrong *type* rather than just a missing field — this is what
|
||||||
|
// makes `serde_json` produce an "invalid type: string `...`, expected
|
||||||
|
// struct AppSettings" error that quotes the offending value
|
||||||
|
// verbatim. That value here stands in for plaintext that, in a real
|
||||||
|
// export, could be a live credential — the assertion below is only
|
||||||
|
// meaningful against a fixture that actually exercises serde's
|
||||||
|
// value-quoting behavior, which a merely-missing-field fixture does
|
||||||
|
// not.
|
||||||
|
let dir = temp_dir("malformed");
|
||||||
|
let path = write_raw_export(
|
||||||
|
&dir,
|
||||||
|
"export.triplec",
|
||||||
|
&serde_json::json!({
|
||||||
|
"format_version": SETTINGS_EXPORT_FORMAT_VERSION,
|
||||||
|
"exported_at": "2026-08-27T00:00:00Z",
|
||||||
|
"app_version": "0.4.14",
|
||||||
|
"settings": "NOT-A-REAL-CREDENTIAL-abc123",
|
||||||
|
"secrets": {},
|
||||||
|
}),
|
||||||
|
"correct password",
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = read_and_decrypt(&path, "correct password").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
!err.contains("NOT-A-REAL-CREDENTIAL-abc123"),
|
||||||
|
"leaked plaintext into the error: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
assert!(err.contains("doesn't look like a valid settings export"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_wrong_password_is_reported_without_a_version_check_ever_running() {
|
||||||
|
let dir = temp_dir("wrong-password");
|
||||||
|
let path = write_export(
|
||||||
|
&dir,
|
||||||
|
"export.triplec",
|
||||||
|
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
|
||||||
|
"correct password",
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = read_and_decrypt(&path, "wrong password").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("Wrong password"),
|
||||||
|
"unexpected message: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,21 @@ pub struct AppState {
|
|||||||
pub auth_bridge: Arc<AuthBridgeManager>,
|
pub auth_bridge: Arc<AuthBridgeManager>,
|
||||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||||
pub lifecycle: Arc<Lifecycle>,
|
pub lifecycle: Arc<Lifecycle>,
|
||||||
|
/// The file `preview_settings_import` last decrypted successfully, held
|
||||||
|
/// so `apply_settings_import` can re-read and re-decrypt the same file
|
||||||
|
/// without the frontend ever passing a host path back to Rust as an
|
||||||
|
/// argument — see the doc comment on `commands::settings_export_commands`
|
||||||
|
/// for why that direction specifically is the one this app treats as
|
||||||
|
/// dangerous. Deliberately re-decrypted rather than cached in plaintext:
|
||||||
|
/// nothing here holds a decrypted secret in memory for longer than one
|
||||||
|
/// command's execution.
|
||||||
|
///
|
||||||
|
/// Also pins a hash of the file's ciphertext at preview time, so
|
||||||
|
/// `apply_settings_import` can refuse to proceed if the file on disk
|
||||||
|
/// changed underneath the pending import — otherwise confirming a
|
||||||
|
/// preview is not actually binding on what gets applied.
|
||||||
|
pub pending_settings_import:
|
||||||
|
Arc<tokio::sync::Mutex<Option<commands::settings_export_commands::PendingSettingsImport>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -222,6 +237,7 @@ pub fn run() {
|
|||||||
auth_bridge,
|
auth_bridge,
|
||||||
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
})
|
})
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||||
@@ -454,6 +470,10 @@ pub fn run() {
|
|||||||
commands::project_commands::stop_project_container,
|
commands::project_commands::stop_project_container,
|
||||||
commands::project_commands::rebuild_project_container,
|
commands::project_commands::rebuild_project_container,
|
||||||
commands::project_commands::reconcile_project_statuses,
|
commands::project_commands::reconcile_project_statuses,
|
||||||
|
// Notes
|
||||||
|
commands::notes_commands::list_notes,
|
||||||
|
commands::notes_commands::save_note,
|
||||||
|
commands::notes_commands::delete_note,
|
||||||
// Container base-image migration
|
// Container base-image migration
|
||||||
commands::migration_commands::get_container_staleness,
|
commands::migration_commands::get_container_staleness,
|
||||||
commands::migration_commands::migrate_project_to_base,
|
commands::migration_commands::migrate_project_to_base,
|
||||||
@@ -494,6 +514,10 @@ pub fn run() {
|
|||||||
commands::settings_commands::inspect_ca_cert_path,
|
commands::settings_commands::inspect_ca_cert_path,
|
||||||
commands::settings_commands::list_aws_profiles,
|
commands::settings_commands::list_aws_profiles,
|
||||||
commands::settings_commands::detect_host_timezone,
|
commands::settings_commands::detect_host_timezone,
|
||||||
|
// Settings export/import
|
||||||
|
commands::settings_export_commands::export_settings,
|
||||||
|
commands::settings_export_commands::preview_settings_import,
|
||||||
|
commands::settings_export_commands::apply_settings_import,
|
||||||
// Terminal
|
// Terminal
|
||||||
commands::terminal_commands::open_terminal_session,
|
commands::terminal_commands::open_terminal_session,
|
||||||
commands::terminal_commands::terminal_input,
|
commands::terminal_commands::terminal_input,
|
||||||
|
|||||||
@@ -26,12 +26,27 @@
|
|||||||
/// their own init time, which happens inside the Tauri builder that
|
/// their own init time, which happens inside the Tauri builder that
|
||||||
/// function calls into, not at binary load.
|
/// function calls into, not at binary load.
|
||||||
///
|
///
|
||||||
/// A user who has already set this themselves is left alone. That includes
|
/// A user who has already set this themselves is left alone — with one
|
||||||
/// setting it to `0`, on the assumption WebKitGTK treats it as a boolean
|
/// correction. The earlier version of this function left *any* pre-set value
|
||||||
/// rather than presence-only — not verified against WebKitGTK's own source,
|
/// alone, including `0`, on the assumption WebKitGTK reads the variable as a
|
||||||
/// so if it turns out to be presence-only, `=0` still reads as "set" here
|
/// boolean. WebKitGTK reads it as presence-only, so `WEBKIT_DISABLE_DMABUF_
|
||||||
/// and disables DMA-BUF the same as any other value, which is at least the
|
/// RENDERER=0` disabled DMA-BUF exactly like `=1` did, and there was no value
|
||||||
/// safe direction to be wrong in.
|
/// at all a user could set to get the accelerated path back: the escape hatch
|
||||||
|
/// the comment described did not exist. `0`, `false` and empty are now treated
|
||||||
|
/// as an explicit opt-out and the variable is *removed*, which is the only
|
||||||
|
/// thing WebKitGTK reads as "enabled". The default is unchanged — unset still
|
||||||
|
/// means disabled on Linux, so nobody who was not deliberately overriding this
|
||||||
|
/// sees any difference.
|
||||||
|
///
|
||||||
|
/// That matters more than it looks, because the trade described above is not
|
||||||
|
/// the trade actually being made. `@xterm/addon-webgl` does not fall back to
|
||||||
|
/// the canvas renderer here: its constructor throws only when WebGL is
|
||||||
|
/// *absent*, and with DMA-BUF disabled WebGL is still present — served by
|
||||||
|
/// software rasterisation. So the addon loads happily and every terminal frame
|
||||||
|
/// is rendered on the CPU and copied, which is slower than the canvas renderer
|
||||||
|
/// this comment assumed it would degrade to, not faster. See
|
||||||
|
/// `terminal_gpu_rendering` in `AppSettings` for the switch that decides
|
||||||
|
/// whether the addon is loaded at all.
|
||||||
///
|
///
|
||||||
/// This env var also leaks to whatever the app spawns afterwards — notably
|
/// This env var also leaks to whatever the app spawns afterwards — notably
|
||||||
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
|
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
|
||||||
@@ -39,10 +54,77 @@
|
|||||||
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
||||||
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
||||||
/// as a separate, unrelated cause.
|
/// as a separate, unrelated cause.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
const DMABUF_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
|
||||||
|
|
||||||
|
/// What to do with `WEBKIT_DISABLE_DMABUF_RENDERER`, given whatever it is
|
||||||
|
/// already set to. Split from the mutation so it can be tested without
|
||||||
|
/// touching process-wide environment state from a parallel test runner.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum DmabufAction {
|
||||||
|
/// Not set by the user — apply the workaround.
|
||||||
|
Disable,
|
||||||
|
/// Explicitly opted out. WebKitGTK reads presence, not value, so the only
|
||||||
|
/// way to express "enabled" is for the variable not to exist.
|
||||||
|
Remove,
|
||||||
|
/// Set to something meaning "disabled". Already what we want; leave it.
|
||||||
|
LeaveAlone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn dmabuf_action(current: Option<&str>) -> DmabufAction {
|
||||||
|
match current {
|
||||||
|
None => DmabufAction::Disable,
|
||||||
|
Some(value) => match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"" | "0" | "false" | "no" => DmabufAction::Remove,
|
||||||
|
_ => DmabufAction::LeaveAlone,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn apply_webkit_wayland_workaround() {
|
fn apply_webkit_wayland_workaround() {
|
||||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
let current = std::env::var(DMABUF_VAR).ok();
|
||||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
match dmabuf_action(current.as_deref()) {
|
||||||
|
DmabufAction::Disable => std::env::set_var(DMABUF_VAR, "1"),
|
||||||
|
DmabufAction::Remove => std::env::remove_var(DMABUF_VAR),
|
||||||
|
DmabufAction::LeaveAlone => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
|
mod tests {
|
||||||
|
use super::{dmabuf_action, DmabufAction};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unset_gets_the_workaround() {
|
||||||
|
assert_eq!(dmabuf_action(None), DmabufAction::Disable);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn falsey_values_opt_out_by_removing_the_variable() {
|
||||||
|
// The bug this replaces: these all previously read as "user set it,
|
||||||
|
// leave it alone", and WebKitGTK then disabled DMA-BUF anyway because
|
||||||
|
// it only checks presence. There was no way to ask for the GPU path.
|
||||||
|
for value in ["0", "false", "no", "", " 0 ", "FALSE", "No"] {
|
||||||
|
assert_eq!(
|
||||||
|
dmabuf_action(Some(value)),
|
||||||
|
DmabufAction::Remove,
|
||||||
|
"{value:?} should opt out"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_values_are_left_alone() {
|
||||||
|
for value in ["1", "true", "yes", "anything"] {
|
||||||
|
assert_eq!(
|
||||||
|
dmabuf_action(Some(value)),
|
||||||
|
DmabufAction::LeaveAlone,
|
||||||
|
"{value:?} should be left alone"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,26 @@ pub struct AppSettings {
|
|||||||
pub gateway: GatewaySettings,
|
pub gateway: GatewaySettings,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
||||||
|
/// Whether the terminal loads `@xterm/addon-webgl`.
|
||||||
|
///
|
||||||
|
/// `None` is "auto", and auto is not the same answer on every platform.
|
||||||
|
/// On Linux the app disables WebKitGTK's DMA-BUF renderer at startup (see
|
||||||
|
/// `apply_webkit_wayland_workaround` in `main.rs`, and triple-c#34), which
|
||||||
|
/// does not remove WebGL — it leaves it backed by software rasterisation.
|
||||||
|
/// The addon therefore loads successfully and then renders every frame on
|
||||||
|
/// the CPU, which is slower than the canvas renderer it would otherwise
|
||||||
|
/// have fallen back to. So auto means enabled on macOS and Windows, and
|
||||||
|
/// disabled on Linux.
|
||||||
|
///
|
||||||
|
/// `Some(true)` / `Some(false)` force it either way on any platform. A
|
||||||
|
/// Linux user running X11, or one whose driver stack is unaffected, can
|
||||||
|
/// turn it back on; anyone seeing terminal lag can turn it off without
|
||||||
|
/// waiting for a release. Deliberately `Option<bool>` rather than `bool`:
|
||||||
|
/// the zero value has to mean "we choose", not "off", or every existing
|
||||||
|
/// settings file would silently pin the answer at whatever the default was
|
||||||
|
/// the day it was written.
|
||||||
|
#[serde(default)]
|
||||||
|
pub terminal_gpu_rendering: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_stt_model() -> String {
|
fn default_stt_model() -> String {
|
||||||
@@ -226,6 +246,7 @@ impl Default for AppSettings {
|
|||||||
stt: SttSettings::default(),
|
stt: SttSettings::default(),
|
||||||
gateway: GatewaySettings::default(),
|
gateway: GatewaySettings::default(),
|
||||||
global_claude_code_settings: None,
|
global_claude_code_settings: None,
|
||||||
|
terminal_gpu_rendering: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
pub mod project;
|
|
||||||
pub mod container_config;
|
|
||||||
pub mod app_settings;
|
pub mod app_settings;
|
||||||
|
pub mod container_config;
|
||||||
pub mod gateway_settings;
|
pub mod gateway_settings;
|
||||||
pub mod migration;
|
pub mod migration;
|
||||||
|
pub mod note;
|
||||||
|
pub mod project;
|
||||||
|
pub mod settings_export;
|
||||||
pub mod update_info;
|
pub mod update_info;
|
||||||
|
|
||||||
pub use project::*;
|
|
||||||
pub use container_config::*;
|
|
||||||
pub use app_settings::*;
|
pub use app_settings::*;
|
||||||
|
pub use container_config::*;
|
||||||
pub use gateway_settings::*;
|
pub use gateway_settings::*;
|
||||||
pub use migration::*;
|
pub use migration::*;
|
||||||
|
pub use note::*;
|
||||||
|
pub use project::*;
|
||||||
|
pub use settings_export::*;
|
||||||
pub use update_info::*;
|
pub use update_info::*;
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// One note. A scratchpad entry the user can also fire at a running Claude
|
||||||
|
/// session.
|
||||||
|
///
|
||||||
|
/// Deliberately has no `kind`/`type` field. What makes a note "for the agent"
|
||||||
|
/// is that the user pressed Send, not a mode chosen when it was written — a
|
||||||
|
/// classification decision at writing time is one the user is least willing to
|
||||||
|
/// make, and it would turn one pane into two features.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Note {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
/// Pinned notes sort first, then by `updated_at` descending.
|
||||||
|
#[serde(default)]
|
||||||
|
pub pinned: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Note {
|
||||||
|
pub fn new(title: String, body: String) -> Self {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
Self {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
pinned: false,
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
//! Settings export/import — see triple-c#35.
|
||||||
|
//!
|
||||||
|
//! `SettingsExportPayload` is the whole plaintext export before encryption
|
||||||
|
//! and after decryption (see `storage::settings_crypto`). It bundles
|
||||||
|
//! `AppSettings` — with one field carved out, see below — with the global
|
||||||
|
//! secrets that live in the OS keychain instead: the shared Claude Code
|
||||||
|
//! OAuth login and the model gateway's two keys. Per-project settings,
|
||||||
|
//! per-project secrets, and anything living in a project's Docker volumes
|
||||||
|
//! are deliberately out of scope: this exports the *host* environment, not
|
||||||
|
//! any one project's.
|
||||||
|
//!
|
||||||
|
//! **`AppSettings` is not entirely the non-secret shape it looks like.**
|
||||||
|
//! `WebTerminalSettings::access_token` is a live bearer credential for a
|
||||||
|
//! server that binds every interface, stored as a plain field on the
|
||||||
|
//! struct that is otherwise safe to treat as config. A review of this
|
||||||
|
//! feature caught it: exporting `AppSettings` wholesale would have carried
|
||||||
|
//! that token along as if it were as inert as a port number, and — worse —
|
||||||
|
//! importing it would apply `web_terminal.enabled` and the token together
|
||||||
|
//! with no more warning than any other setting, letting a crafted export
|
||||||
|
//! silently stand up a LAN-listening terminal server with an
|
||||||
|
//! attacker-known token on the next launch. `export_settings` /
|
||||||
|
//! `apply_settings_import` blank this field out of the `settings` they
|
||||||
|
//! read from and write to, and it travels only through
|
||||||
|
//! [`ExportedSecrets::web_terminal_access_token`] instead, with the same
|
||||||
|
//! "only overwrite what the import actually has" treatment as the other
|
||||||
|
//! three secrets.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::{AppSettings, ImageSource};
|
||||||
|
|
||||||
|
/// Bumped when the shape of [`SettingsExportPayload`] changes in a way that
|
||||||
|
/// isn't just an additive, `#[serde(default)]`-covered field — e.g. if a
|
||||||
|
/// field is ever removed or its meaning changes. `apply_settings_import`
|
||||||
|
/// checks this before touching anything.
|
||||||
|
pub const SETTINGS_EXPORT_FORMAT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// The global secrets bundled into an export. Deliberately a separate struct
|
||||||
|
/// from `AppSettings`: these live in the OS keychain, never in
|
||||||
|
/// `settings.json`, and — outside of this export/import flow — the values
|
||||||
|
/// themselves never cross into the frontend; see the doc comments on
|
||||||
|
/// `storage::secure::get_gateway_api_key` and
|
||||||
|
/// `commands::settings_export_commands` for why that boundary matters here
|
||||||
|
/// too.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ExportedSecrets {
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude_oauth_token: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gateway_api_key: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gateway_master_key: Option<String>,
|
||||||
|
/// See the module doc comment — this is `AppSettings::web_terminal
|
||||||
|
/// .access_token`, carved out because it is a live bearer credential,
|
||||||
|
/// not config, despite living on a struct that is otherwise safe to
|
||||||
|
/// export wholesale.
|
||||||
|
#[serde(default)]
|
||||||
|
pub web_terminal_access_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExportedSecrets {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
let blank = |s: &Option<String>| s.as_deref().is_none_or(|v| v.trim().is_empty());
|
||||||
|
blank(&self.claude_oauth_token)
|
||||||
|
&& blank(&self.gateway_api_key)
|
||||||
|
&& blank(&self.gateway_master_key)
|
||||||
|
&& blank(&self.web_terminal_access_token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `apply_settings_import` hands back: the settings that were actually
|
||||||
|
/// saved, plus a human-readable note for each keychain secret this import
|
||||||
|
/// carried but could not be restored. A keychain write failing partway
|
||||||
|
/// through must not read as unqualified success just because the settings
|
||||||
|
/// half of the import went through.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SettingsImportOutcome {
|
||||||
|
pub settings: AppSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub secret_restore_warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full plaintext payload — this is what gets encrypted on export and
|
||||||
|
/// what decryption recovers on import. Never written to disk unencrypted;
|
||||||
|
/// see `storage::settings_crypto`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SettingsExportPayload {
|
||||||
|
pub format_version: u32,
|
||||||
|
/// RFC3339. Purely informational — shown in the import preview so a user
|
||||||
|
/// picking between a few old export files has something to go on.
|
||||||
|
pub exported_at: String,
|
||||||
|
/// The exporting app's `CARGO_PKG_VERSION`. Also informational: every
|
||||||
|
/// field below already round-trips through `#[serde(default)]`-covered
|
||||||
|
/// `AppSettings`, so an older or newer export still deserializes; this is
|
||||||
|
/// for a human to notice "this is from a much older version" if an import
|
||||||
|
/// ever looks wrong, not something the code branches on.
|
||||||
|
pub app_version: String,
|
||||||
|
pub settings: AppSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub secrets: ExportedSecrets,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `preview_settings_import` hands the frontend before anything is
|
||||||
|
/// applied — counts and presence flags only, **never** a secret value itself,
|
||||||
|
/// so this type is safe to return across the IPC boundary and render
|
||||||
|
/// directly. The confirmation UI is built from this.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SettingsImportPreview {
|
||||||
|
pub exported_at: String,
|
||||||
|
pub app_version: String,
|
||||||
|
pub custom_env_var_count: usize,
|
||||||
|
pub gateway_model_count: usize,
|
||||||
|
pub has_claude_code_settings: bool,
|
||||||
|
pub has_claude_oauth_token: bool,
|
||||||
|
pub has_gateway_api_key: bool,
|
||||||
|
pub has_gateway_master_key: bool,
|
||||||
|
pub has_web_terminal_access_token: bool,
|
||||||
|
/// Whether the imported settings turn the web terminal on. Named
|
||||||
|
/// separately from the token above: `enabled` and the token are two
|
||||||
|
/// different fields, either can be true without the other, and
|
||||||
|
/// "this import turns on a service that listens on your network" is
|
||||||
|
/// exactly the kind of change a wholesale settings replace must not
|
||||||
|
/// bury in a generic "settings replaced" line — see the module doc
|
||||||
|
/// comment on why this field exists at all.
|
||||||
|
pub enables_web_terminal: bool,
|
||||||
|
/// Non-blank custom base URLs the import would set, so a redirect of
|
||||||
|
/// model traffic to somewhere other than the usual provider is visible
|
||||||
|
/// at import time rather than discovered later. These are endpoints, not
|
||||||
|
/// secrets — safe to show verbatim, unlike everything above.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ollama_base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub llamacpp_base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub openai_compatible_base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gateway_api_base: Option<String>,
|
||||||
|
/// Whether the import sets a custom Docker image, and its name if so —
|
||||||
|
/// disclosed for the same reason as the base URLs above, and arguably
|
||||||
|
/// more sharply: this is the image *every* project container is created
|
||||||
|
/// from (`models::container_config::resolve_image_name`), so a crafted
|
||||||
|
/// export pointing it at an attacker-controlled image is a path to
|
||||||
|
/// running arbitrary code with whatever a project's containers are
|
||||||
|
/// allowed to reach (the Docker socket, an SSH key, project files) —
|
||||||
|
/// not merely a redirected API endpoint.
|
||||||
|
#[serde(default)]
|
||||||
|
pub image_source: ImageSource,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_image_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
|
||||||
|
/// into a preview a user reads and a UI renders without truncation of its
|
||||||
|
/// own. Applied to every field above that carries free-form text straight
|
||||||
|
/// from the import file rather than a count or a boolean — a base URL or an
|
||||||
|
/// image name a hostile export author controls has had no validation done
|
||||||
|
/// on it yet at preview time, and nothing stops it from being pathological
|
||||||
|
/// (embedded control characters, or long enough to blow out the confirmation
|
||||||
|
/// dialog and push the security warnings below it off screen).
|
||||||
|
const MAX_PREVIEW_STRING_LEN: usize = 100;
|
||||||
|
|
||||||
|
fn sanitize_for_preview(value: &str) -> String {
|
||||||
|
let cleaned: String = value.chars().filter(|c| !c.is_control()).collect();
|
||||||
|
let trimmed = cleaned.trim();
|
||||||
|
if trimmed.chars().count() > MAX_PREVIEW_STRING_LEN {
|
||||||
|
let truncated: String = trimmed.chars().take(MAX_PREVIEW_STRING_LEN).collect();
|
||||||
|
format!("{}…", truncated)
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsImportPreview {
|
||||||
|
pub fn from_payload(payload: &SettingsExportPayload) -> Self {
|
||||||
|
let non_blank = |s: &Option<String>| s.as_deref().is_some_and(|v| !v.trim().is_empty());
|
||||||
|
let sanitized_non_blank = |s: &Option<String>| {
|
||||||
|
s.as_deref()
|
||||||
|
.map(sanitize_for_preview)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
exported_at: payload.exported_at.clone(),
|
||||||
|
app_version: payload.app_version.clone(),
|
||||||
|
custom_env_var_count: payload.settings.global_custom_env_vars.len(),
|
||||||
|
gateway_model_count: payload.settings.gateway.models.len(),
|
||||||
|
has_claude_code_settings: payload.settings.global_claude_code_settings.is_some(),
|
||||||
|
has_claude_oauth_token: non_blank(&payload.secrets.claude_oauth_token),
|
||||||
|
has_gateway_api_key: non_blank(&payload.secrets.gateway_api_key),
|
||||||
|
has_gateway_master_key: non_blank(&payload.secrets.gateway_master_key),
|
||||||
|
has_web_terminal_access_token: non_blank(&payload.secrets.web_terminal_access_token),
|
||||||
|
enables_web_terminal: payload.settings.web_terminal.enabled,
|
||||||
|
ollama_base_url: sanitized_non_blank(&payload.settings.global_ollama.base_url),
|
||||||
|
llamacpp_base_url: sanitized_non_blank(&payload.settings.global_llamacpp.base_url),
|
||||||
|
openai_compatible_base_url: sanitized_non_blank(
|
||||||
|
&payload.settings.global_openai_compatible.base_url,
|
||||||
|
),
|
||||||
|
gateway_api_base: sanitized_non_blank(&payload.settings.gateway.api_base),
|
||||||
|
image_source: payload.settings.image_source.clone(),
|
||||||
|
custom_image_name: sanitized_non_blank(&payload.settings.custom_image_name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::AppSettings;
|
||||||
|
|
||||||
|
fn payload_with(secrets: ExportedSecrets) -> SettingsExportPayload {
|
||||||
|
let settings = AppSettings {
|
||||||
|
global_custom_env_vars: vec![
|
||||||
|
crate::models::EnvVar {
|
||||||
|
key: "A".to_string(),
|
||||||
|
value: "1".to_string(),
|
||||||
|
},
|
||||||
|
crate::models::EnvVar {
|
||||||
|
key: "B".to_string(),
|
||||||
|
value: "2".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..AppSettings::default()
|
||||||
|
};
|
||||||
|
SettingsExportPayload {
|
||||||
|
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
||||||
|
exported_at: "2026-08-27T00:00:00Z".to_string(),
|
||||||
|
app_version: "0.4.14".to_string(),
|
||||||
|
settings,
|
||||||
|
secrets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_preview_never_carries_a_secret_value() {
|
||||||
|
let payload = payload_with(ExportedSecrets {
|
||||||
|
claude_oauth_token: Some("sk-super-secret-token".to_string()),
|
||||||
|
gateway_api_key: Some("sk-another-secret".to_string()),
|
||||||
|
gateway_master_key: Some("sk-triple-c-yet-another".to_string()),
|
||||||
|
web_terminal_access_token: Some("wt-super-secret-token".to_string()),
|
||||||
|
});
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
let serialized = serde_json::to_string(&preview).unwrap();
|
||||||
|
|
||||||
|
assert!(!serialized.contains("sk-super-secret-token"));
|
||||||
|
assert!(!serialized.contains("sk-another-secret"));
|
||||||
|
assert!(!serialized.contains("sk-triple-c-yet-another"));
|
||||||
|
assert!(!serialized.contains("wt-super-secret-token"));
|
||||||
|
assert!(preview.has_claude_oauth_token);
|
||||||
|
assert!(preview.has_gateway_api_key);
|
||||||
|
assert!(preview.has_gateway_master_key);
|
||||||
|
assert!(preview.has_web_terminal_access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_blank_secret_reads_as_absent_in_the_preview() {
|
||||||
|
// A keychain entry that exists but holds only whitespace must not
|
||||||
|
// read as "present" — same "blank counts as absent" rule the
|
||||||
|
// keychain layer itself applies when storing these.
|
||||||
|
let payload = payload_with(ExportedSecrets {
|
||||||
|
claude_oauth_token: Some(" ".to_string()),
|
||||||
|
gateway_api_key: None,
|
||||||
|
gateway_master_key: None,
|
||||||
|
web_terminal_access_token: Some(" ".to_string()),
|
||||||
|
});
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
assert!(!preview.has_claude_oauth_token);
|
||||||
|
assert!(!preview.has_gateway_api_key);
|
||||||
|
assert!(!preview.has_gateway_master_key);
|
||||||
|
assert!(!preview.has_web_terminal_access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enabling_the_web_terminal_is_surfaced_regardless_of_whether_a_token_came_with_it() {
|
||||||
|
// `enabled` and the token are independent fields — a crafted export
|
||||||
|
// could set one without the other, and both are worth a user's
|
||||||
|
// attention: this is the field that exists specifically so "this
|
||||||
|
// import turns on a service that listens on your network" cannot
|
||||||
|
// hide inside a generic "settings replaced" summary.
|
||||||
|
let mut payload = payload_with(ExportedSecrets::default());
|
||||||
|
payload.settings.web_terminal.enabled = true;
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
assert!(preview.enables_web_terminal);
|
||||||
|
assert!(!preview.has_web_terminal_access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_base_urls_are_surfaced_but_blank_ones_read_as_absent() {
|
||||||
|
let mut payload = payload_with(ExportedSecrets::default());
|
||||||
|
payload.settings.global_ollama.base_url = Some("http://attacker.example:11434".to_string());
|
||||||
|
payload.settings.global_llamacpp.base_url = Some(" ".to_string());
|
||||||
|
payload.settings.gateway.api_base = Some("https://gateway.example/v1".to_string());
|
||||||
|
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
assert_eq!(
|
||||||
|
preview.ollama_base_url.as_deref(),
|
||||||
|
Some("http://attacker.example:11434")
|
||||||
|
);
|
||||||
|
assert_eq!(preview.llamacpp_base_url, None);
|
||||||
|
assert_eq!(preview.openai_compatible_base_url, None);
|
||||||
|
assert_eq!(
|
||||||
|
preview.gateway_api_base.as_deref(),
|
||||||
|
Some("https://gateway.example/v1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn counts_reflect_the_real_settings() {
|
||||||
|
let payload = payload_with(ExportedSecrets::default());
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
assert_eq!(preview.custom_env_var_count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_secrets_bundle_reports_itself_as_empty() {
|
||||||
|
assert!(ExportedSecrets::default().is_empty());
|
||||||
|
assert!(!ExportedSecrets {
|
||||||
|
claude_oauth_token: Some("x".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_secrets_bundle_holding_only_whitespace_still_reports_itself_as_empty() {
|
||||||
|
// Matches the "blank counts as absent" rule every other consumer of
|
||||||
|
// these fields applies (`has_claude_oauth_token` and friends above) —
|
||||||
|
// a keychain entry that exists but holds only whitespace carries
|
||||||
|
// nothing usable, so the export-time "nothing to export" log line
|
||||||
|
// must still fire for it.
|
||||||
|
assert!(ExportedSecrets {
|
||||||
|
claude_oauth_token: Some(" ".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_custom_docker_image_is_surfaced() {
|
||||||
|
let mut payload = payload_with(ExportedSecrets::default());
|
||||||
|
payload.settings.image_source = crate::models::ImageSource::Custom;
|
||||||
|
payload.settings.custom_image_name = Some("ghcr.io/attacker/triple-c:latest".to_string());
|
||||||
|
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
assert_eq!(preview.image_source, crate::models::ImageSource::Custom);
|
||||||
|
assert_eq!(
|
||||||
|
preview.custom_image_name.as_deref(),
|
||||||
|
Some("ghcr.io/attacker/triple-c:latest")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_strings_are_stripped_of_control_characters_and_capped_in_length() {
|
||||||
|
let mut payload = payload_with(ExportedSecrets::default());
|
||||||
|
payload.settings.global_ollama.base_url =
|
||||||
|
Some(format!("http://example.test/{}\u{0007}bell", "x".repeat(200)));
|
||||||
|
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
let shown = preview.ollama_base_url.expect("non-blank base url");
|
||||||
|
assert!(!shown.contains('\u{0007}'), "control character leaked into the preview");
|
||||||
|
// +1 for the trailing ellipsis appended when truncated.
|
||||||
|
assert!(
|
||||||
|
shown.chars().count() <= MAX_PREVIEW_STRING_LEN + 1,
|
||||||
|
"preview string was not capped: {} chars",
|
||||||
|
shown.chars().count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
pub mod migration_store;
|
pub mod migration_store;
|
||||||
|
pub mod notes_store;
|
||||||
pub mod pending_cleanup;
|
pub mod pending_cleanup;
|
||||||
pub mod projects_store;
|
pub mod projects_store;
|
||||||
pub mod secure;
|
pub mod secure;
|
||||||
|
pub mod settings_crypto;
|
||||||
pub mod settings_store;
|
pub mod settings_store;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
|
|||||||
@@ -0,0 +1,593 @@
|
|||||||
|
//! Host-side persistence for per-project notes.
|
||||||
|
//!
|
||||||
|
//! One JSON file per project under `<data_dir>/triple-c/notes/`, on the same
|
||||||
|
//! free-function shape as `migration_store` — no struct, nothing in
|
||||||
|
//! `AppState`, no in-memory copy. `ProjectsStore` holds a `Mutex` because it
|
||||||
|
//! caches the project list; a store that reads and writes the file per call
|
||||||
|
//! has nothing to cache and nothing to guard.
|
||||||
|
//!
|
||||||
|
//! Deliberately *not* a field on `Project`. `projects.json` is rewritten on
|
||||||
|
//! every blur by the debounced-nothing save path in `useSaveState`, so notes
|
||||||
|
//! there would mean the whole project list is rewritten per edit, and a note
|
||||||
|
//! save racing a Config save would silently drop one of them.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::models::Note;
|
||||||
|
|
||||||
|
/// The version stamped into every notes file this build writes.
|
||||||
|
const NOTES_FORMAT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// What is actually on disk: a version envelope around the notes.
|
||||||
|
///
|
||||||
|
/// The list is wrapped rather than written bare because the wrapper costs
|
||||||
|
/// nothing today and cannot be added cheaply later — once files exist in the
|
||||||
|
/// field, every reader has to sniff two shapes forever. `version` is written
|
||||||
|
/// and read back but nothing branches on it yet: it is the hook a future
|
||||||
|
/// format change hangs off, and its value is only useful if it has been there
|
||||||
|
/// since the first file.
|
||||||
|
///
|
||||||
|
/// Not in `models/` and not exposed over IPC: the frontend receives
|
||||||
|
/// `Vec<Note>` from `list_notes` and never sees the envelope, so this is a
|
||||||
|
/// storage detail rather than part of the IPC contract.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct ProjectNotes {
|
||||||
|
version: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
notes: Vec<Note>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialises the read-modify-write half of an upsert or delete.
|
||||||
|
///
|
||||||
|
/// Nothing here is cached, so there is no shared state to protect — but an
|
||||||
|
/// upsert reads the whole file, edits one entry and writes it back, and two of
|
||||||
|
/// those interleaving would lose whichever note was written first. The read
|
||||||
|
/// path does not take it.
|
||||||
|
fn write_lock() -> &'static Mutex<()> {
|
||||||
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<data_dir>/triple-c/notes`, created on demand.
|
||||||
|
pub fn notes_dir() -> Result<PathBuf, String> {
|
||||||
|
let dir = dirs::data_dir()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
|
||||||
|
})?
|
||||||
|
.join("triple-c")
|
||||||
|
.join("notes");
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create notes directory: {}", e))?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||||
|
/// the write anywhere but the notes directory.
|
||||||
|
fn sanitize(project_id: &str) -> String {
|
||||||
|
project_id
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notes_path_in(dir: &Path, project_id: &str) -> PathBuf {
|
||||||
|
dir.join(format!("{}.json", sanitize(project_id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API. Each resolves the real directory, then defers to the `_in`
|
||||||
|
// variant, which is what the tests exercise against a temp dir. `ProjectsStore`
|
||||||
|
// hardcodes `dirs::data_dir()` in its constructor and is therefore untestable
|
||||||
|
// as a unit; this store does not inherit that. ─────────────────────────────
|
||||||
|
|
||||||
|
pub fn load(project_id: &str) -> Result<Vec<Note>, String> {
|
||||||
|
load_in(¬es_dir()?, project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert(project_id: &str, note: Note) -> Result<Note, String> {
|
||||||
|
upsert_in(¬es_dir()?, project_id, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(project_id: &str, note_id: &str) -> Result<(), String> {
|
||||||
|
delete_in(¬es_dir()?, project_id, note_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a project's notes file entirely. Missing is success.
|
||||||
|
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||||
|
clear_in(¬es_dir()?, project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Implementation ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Read a project's notes. A missing file is an empty list.
|
||||||
|
///
|
||||||
|
/// **An unparseable file is copied aside and left in place**, then reported as
|
||||||
|
/// empty. Erroring instead would make the Notes tab permanently unusable for
|
||||||
|
/// that project with no way out through the UI; deleting instead would destroy
|
||||||
|
/// the only copy of what the user wrote. The copy is timestamped so a second
|
||||||
|
/// corruption cannot overwrite the first — which is the one taken before
|
||||||
|
/// anything rewrote the file, and therefore the one worth having — and capped,
|
||||||
|
/// because `list_notes` runs on *every* panel mount. See [`keep_corrupt_copy`].
|
||||||
|
fn load_in(dir: &Path, project_id: &str) -> Result<Vec<Note>, String> {
|
||||||
|
let path = notes_path_in(dir, project_id);
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let data = fs::read_to_string(&path).map_err(|e| format!("Failed to read notes: {}", e))?;
|
||||||
|
match parse(&data) {
|
||||||
|
Ok(notes) => Ok(notes),
|
||||||
|
Err(e) => {
|
||||||
|
let kept = keep_corrupt_copy(&path, &chrono::Utc::now());
|
||||||
|
log::error!(
|
||||||
|
"Failed to parse notes for project {}: {} — treating as empty; the file is \
|
||||||
|
left in place{}",
|
||||||
|
project_id,
|
||||||
|
e,
|
||||||
|
kept.describe()
|
||||||
|
);
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a notes file: the versioned envelope, or a bare array.
|
||||||
|
///
|
||||||
|
/// The bare array is what this store wrote before [`ProjectNotes`] existed —
|
||||||
|
/// only ever on a development build, but a developer's own notes are still
|
||||||
|
/// prose nothing else holds a copy of, and the alternative is `load_in`
|
||||||
|
/// declaring a perfectly readable file corrupt. It is read, never written: the
|
||||||
|
/// first save rewrites the file with an envelope.
|
||||||
|
fn parse(data: &str) -> Result<Vec<Note>, serde_json::Error> {
|
||||||
|
match serde_json::from_str::<ProjectNotes>(data) {
|
||||||
|
Ok(file) => Ok(file.notes),
|
||||||
|
// Report the envelope's error, not the array's — the envelope is the
|
||||||
|
// shape this store writes, so its message is the one that describes
|
||||||
|
// what is actually wrong with the file.
|
||||||
|
Err(envelope_err) => serde_json::from_str::<Vec<Note>>(data).map_err(|_| envelope_err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many timestamped copies of one project's corrupt notes file are kept.
|
||||||
|
///
|
||||||
|
/// Timestamping fixes "a second corruption overwrote the first" and introduces
|
||||||
|
/// its opposite: `load_in` runs on every `list_notes`, which is every panel
|
||||||
|
/// mount — every project switch, every dock-follows-tab change, every sub-tab
|
||||||
|
/// toggle. A file that is *persistently* unparseable (the normal case, since
|
||||||
|
/// nothing repairs it) would otherwise mint a fresh full copy of the user's
|
||||||
|
/// prose every time the clock's second changed. Nothing ever reads them back
|
||||||
|
/// and nothing ever removed them.
|
||||||
|
///
|
||||||
|
/// Four is enough for the only use there is: a human looking at what the file
|
||||||
|
/// held. Same constant, same reasoning as `migration_store`.
|
||||||
|
const MAX_CORRUPT_BACKUPS: usize = 4;
|
||||||
|
|
||||||
|
/// What [`keep_corrupt_copy`] did, so the log line can tell the truth about
|
||||||
|
/// whether a file exists.
|
||||||
|
///
|
||||||
|
/// Three outcomes, and they must not be conflated. Folding "already kept
|
||||||
|
/// enough" into success and then saying "a copy was kept" names a file that
|
||||||
|
/// was never created — which is what someone reads before going to look for
|
||||||
|
/// their data.
|
||||||
|
enum Kept {
|
||||||
|
Copied(PathBuf),
|
||||||
|
/// This exact second's copy was already on disk.
|
||||||
|
AlreadyThere(PathBuf),
|
||||||
|
/// The cap is reached; the earlier copies are kept and this one is not.
|
||||||
|
EnoughAlready(usize),
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kept {
|
||||||
|
fn describe(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Kept::Copied(p) | Kept::AlreadyThere(p) => format!(" (a copy is at {})", p.display()),
|
||||||
|
// The earliest copies are the ones worth having, so the cap keeps
|
||||||
|
// those and drops this one. Say so, rather than implying a file
|
||||||
|
// exists.
|
||||||
|
Kept::EnoughAlready(n) => format!(
|
||||||
|
" (no copy kept — {} earlier copies of this file are already saved alongside it)",
|
||||||
|
n
|
||||||
|
),
|
||||||
|
Kept::Failed(e) => format!(" (could not keep a copy: {})", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a copy of an unreadable notes file is kept.
|
||||||
|
fn corrupt_backup_path(path: &Path, now: &chrono::DateTime<chrono::Utc>) -> PathBuf {
|
||||||
|
path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [`MAX_CORRUPT_BACKUPS`] copies of this project's file already exist.
|
||||||
|
///
|
||||||
|
/// Asked *before* the copy rather than pruning after it, so the cap is not
|
||||||
|
/// implemented by writing a file and deleting it again on every pass — and so
|
||||||
|
/// the copies that survive are the oldest, which are the ones taken closest to
|
||||||
|
/// whatever produced the corruption.
|
||||||
|
///
|
||||||
|
/// A directory that cannot be listed answers "not full": failing open costs at
|
||||||
|
/// most one extra file, and failing closed would drop the very first copy of
|
||||||
|
/// prose nothing else has kept.
|
||||||
|
fn corrupt_backups_full(path: &Path) -> bool {
|
||||||
|
let (Some(dir), Some(stem)) = (path.parent(), path.file_stem()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// `{stem}.json.corrupt-` — the same shape `corrupt_backup_path` builds, so
|
||||||
|
// this can never match another project's copies or an unrelated `.bak`.
|
||||||
|
let prefix = format!("{}.json.corrupt-", stem.to_string_lossy());
|
||||||
|
let Ok(entries) = fs::read_dir(dir) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
entries
|
||||||
|
.flatten()
|
||||||
|
.filter(|e| {
|
||||||
|
let name = e.file_name().to_string_lossy().to_string();
|
||||||
|
name.starts_with(&prefix) && name.ends_with(".bak")
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
>= MAX_CORRUPT_BACKUPS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keep_corrupt_copy(path: &Path, now: &chrono::DateTime<chrono::Utc>) -> Kept {
|
||||||
|
let backup = corrupt_backup_path(path, now);
|
||||||
|
if backup.exists() {
|
||||||
|
return Kept::AlreadyThere(backup);
|
||||||
|
}
|
||||||
|
if corrupt_backups_full(path) {
|
||||||
|
return Kept::EnoughAlready(MAX_CORRUPT_BACKUPS);
|
||||||
|
}
|
||||||
|
match fs::copy(path, &backup) {
|
||||||
|
Ok(_) => Kept::Copied(backup),
|
||||||
|
Err(e) => Kept::Failed(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or replace one note, leaving the rest untouched.
|
||||||
|
///
|
||||||
|
/// `created_at` and `id` are the store's, not the caller's: the webview sends
|
||||||
|
/// a whole `Note` back and must not be able to rewrite when a note was made.
|
||||||
|
/// `updated_at` is stamped here for the same reason.
|
||||||
|
fn upsert_in(dir: &Path, project_id: &str, mut note: Note) -> Result<Note, String> {
|
||||||
|
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let mut notes = load_in(dir, project_id)?;
|
||||||
|
note.updated_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
match notes.iter_mut().find(|n| n.id == note.id) {
|
||||||
|
Some(existing) => {
|
||||||
|
note.created_at = existing.created_at.clone();
|
||||||
|
*existing = note.clone();
|
||||||
|
}
|
||||||
|
None => notes.push(note.clone()),
|
||||||
|
}
|
||||||
|
save_all(dir, project_id, ¬es)?;
|
||||||
|
Ok(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove one note. Removing one that is already gone is success — the UI can
|
||||||
|
/// retry a delete whose result it never saw.
|
||||||
|
fn delete_in(dir: &Path, project_id: &str, note_id: &str) -> Result<(), String> {
|
||||||
|
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let mut notes = load_in(dir, project_id)?;
|
||||||
|
let before = notes.len();
|
||||||
|
notes.retain(|n| n.id != note_id);
|
||||||
|
if notes.len() == before {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
save_all(dir, project_id, ¬es)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
|
||||||
|
let _guard = write_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let path = notes_path_in(dir, project_id);
|
||||||
|
match fs::remove_file(&path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(format!("Failed to remove notes: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically **and durably** write the whole list.
|
||||||
|
///
|
||||||
|
/// Write-temp-then-rename alone is only half of it. `fs::write` returns once
|
||||||
|
/// the bytes are in the page cache; the rename is atomic with respect to other
|
||||||
|
/// readers, not to power loss. Losing power in that window leaves the rename
|
||||||
|
/// applied and the data not written — a truncated file, produced by the code
|
||||||
|
/// whose job is to prevent one. So the file is fsynced before the rename and
|
||||||
|
/// the directory after it, since the rename is directory metadata. Notes are
|
||||||
|
/// prose the user typed and nothing else holds a copy.
|
||||||
|
fn save_all(dir: &Path, project_id: &str, notes: &[Note]) -> Result<(), String> {
|
||||||
|
let path = notes_path_in(dir, project_id);
|
||||||
|
let file = ProjectNotes {
|
||||||
|
version: NOTES_FORMAT_VERSION,
|
||||||
|
notes: notes.to_vec(),
|
||||||
|
};
|
||||||
|
let data = serde_json::to_string_pretty(&file)
|
||||||
|
.map_err(|e| format!("Failed to serialize notes: {}", e))?;
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let mut file =
|
||||||
|
fs::File::create(&tmp).map_err(|e| format!("Failed to write notes: {}", e))?;
|
||||||
|
file.write_all(data.as_bytes())
|
||||||
|
.map_err(|e| format!("Failed to write notes: {}", e))?;
|
||||||
|
file.sync_all()
|
||||||
|
.map_err(|e| format!("Failed to flush notes to disk: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit notes: {}", e))?;
|
||||||
|
sync_dir(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// fsync the directory holding `path`, so the rename survives power loss.
|
||||||
|
///
|
||||||
|
/// Best effort only where it is meaningless: Windows has no directory handle
|
||||||
|
/// to sync and returns an error for the attempt, so a failure is logged rather
|
||||||
|
/// than propagated. The file's own `sync_all` carries the data and is not best
|
||||||
|
/// effort.
|
||||||
|
fn sync_dir(path: &Path) {
|
||||||
|
let Some(dir) = path.parent() else { return };
|
||||||
|
if let Err(e) = fs::File::open(dir).and_then(|d| d.sync_all()) {
|
||||||
|
log::debug!(
|
||||||
|
"Could not fsync the notes directory {}: {} — the file itself was flushed",
|
||||||
|
dir.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"triple-c-notes-{}-{}",
|
||||||
|
tag,
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn corrupt_copies(dir: &std::path::Path) -> Vec<String> {
|
||||||
|
std::fs::read_dir(dir)
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||||
|
.filter(|n| n.contains(".corrupt-"))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_ids_cannot_escape_the_notes_directory() {
|
||||||
|
// The id arrives over IPC. It must not be able to steer the write.
|
||||||
|
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||||
|
assert_eq!(sanitize("a/b"), "a_b");
|
||||||
|
assert_eq!(sanitize("a\\b"), "a_b");
|
||||||
|
// A real UUID must survive untouched, or every note file would move
|
||||||
|
// the first time this function changed.
|
||||||
|
assert_eq!(
|
||||||
|
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||||
|
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_file_is_an_empty_list_not_an_error() {
|
||||||
|
let dir = temp_dir("missing");
|
||||||
|
assert_eq!(load_in(&dir, "nobody").unwrap(), Vec::<Note>::new());
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_upserted_note_round_trips() {
|
||||||
|
let dir = temp_dir("roundtrip");
|
||||||
|
let note = Note::new("Deploy steps".into(), "one\ntwo".into());
|
||||||
|
let saved = upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||||
|
assert_eq!(saved.id, note.id);
|
||||||
|
|
||||||
|
let loaded = load_in(&dir, "p1").unwrap();
|
||||||
|
assert_eq!(loaded.len(), 1);
|
||||||
|
assert_eq!(loaded[0].body, "one\ntwo");
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upserting_an_existing_id_replaces_it_and_keeps_created_at() {
|
||||||
|
let dir = temp_dir("replace");
|
||||||
|
let mut note = Note::new("Title".into(), "first".into());
|
||||||
|
upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||||
|
|
||||||
|
note.body = "second".into();
|
||||||
|
note.created_at = "1999-01-01T00:00:00Z".into(); // a client must not rewrite this
|
||||||
|
let saved = upsert_in(&dir, "p1", note.clone()).unwrap();
|
||||||
|
|
||||||
|
let loaded = load_in(&dir, "p1").unwrap();
|
||||||
|
assert_eq!(loaded.len(), 1, "an upsert must not append a duplicate");
|
||||||
|
assert_eq!(loaded[0].body, "second");
|
||||||
|
assert_ne!(
|
||||||
|
saved.created_at, "1999-01-01T00:00:00Z",
|
||||||
|
"created_at is owned by the store, not by whatever the webview sent"
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_note_leaves_the_others_and_a_missing_one_is_success() {
|
||||||
|
let dir = temp_dir("delete");
|
||||||
|
let keep = upsert_in(&dir, "p1", Note::new("keep".into(), "".into())).unwrap();
|
||||||
|
let drop = upsert_in(&dir, "p1", Note::new("drop".into(), "".into())).unwrap();
|
||||||
|
|
||||||
|
delete_in(&dir, "p1", &drop.id).unwrap();
|
||||||
|
let loaded = load_in(&dir, "p1").unwrap();
|
||||||
|
assert_eq!(loaded.len(), 1);
|
||||||
|
assert_eq!(loaded[0].id, keep.id);
|
||||||
|
|
||||||
|
// Idempotent: removing what is already gone is not an error, because
|
||||||
|
// the UI can retry a delete it never saw the result of.
|
||||||
|
delete_in(&dir, "p1", &drop.id).unwrap();
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_file_is_copied_aside_and_reads_as_empty() {
|
||||||
|
// Same reasoning as migration_store: a corrupt file must not make the
|
||||||
|
// tab permanently unusable, and the bytes must not be destroyed.
|
||||||
|
let dir = temp_dir("corrupt");
|
||||||
|
let path = notes_path_in(&dir, "p1");
|
||||||
|
std::fs::write(&path, b"{ not json").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(load_in(&dir, "p1").unwrap(), Vec::<Note>::new());
|
||||||
|
assert!(path.exists(), "the unreadable file is left in place");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
corrupt_copies(&dir).len(),
|
||||||
|
1,
|
||||||
|
"the bytes must be kept exactly once"
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_is_written_is_a_version_envelope_not_a_bare_array() {
|
||||||
|
// The envelope costs nothing now and cannot be added cheaply once
|
||||||
|
// files exist in the field, so the very first file has to carry it.
|
||||||
|
let dir = temp_dir("envelope");
|
||||||
|
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||||
|
|
||||||
|
let raw = std::fs::read_to_string(notes_path_in(&dir, "p1")).unwrap();
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||||
|
assert_eq!(parsed["version"], NOTES_FORMAT_VERSION);
|
||||||
|
assert_eq!(parsed["notes"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(parsed["notes"][0]["body"], "b");
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pre_envelope_bare_array_still_reads_and_is_not_called_corrupt() {
|
||||||
|
// Only a development build ever wrote this shape, but declaring a
|
||||||
|
// perfectly readable file corrupt is the one outcome this store exists
|
||||||
|
// to avoid. It is read, never written back.
|
||||||
|
let dir = temp_dir("legacy");
|
||||||
|
let note = Note::new("Deploy".into(), "one\ntwo".into());
|
||||||
|
std::fs::write(
|
||||||
|
notes_path_in(&dir, "p1"),
|
||||||
|
serde_json::to_string(&vec![note.clone()]).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let loaded = load_in(&dir, "p1").unwrap();
|
||||||
|
assert_eq!(loaded.len(), 1);
|
||||||
|
assert_eq!(loaded[0].body, "one\ntwo");
|
||||||
|
let copies = corrupt_copies(&dir);
|
||||||
|
assert!(copies.is_empty(), "a readable file must not be copied aside");
|
||||||
|
|
||||||
|
// The next write upgrades it in place.
|
||||||
|
upsert_in(&dir, "p1", note).unwrap();
|
||||||
|
let raw = std::fs::read_to_string(notes_path_in(&dir, "p1")).unwrap();
|
||||||
|
assert!(raw.contains("\"version\""));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_copies_are_capped_rather_than_one_per_second() {
|
||||||
|
// `list_notes` runs on every panel mount, so an unrepaired file would
|
||||||
|
// otherwise mint a full copy of the user's prose every time the
|
||||||
|
// clock's second changed.
|
||||||
|
let dir = temp_dir("cap");
|
||||||
|
let path = notes_path_in(&dir, "p1");
|
||||||
|
std::fs::write(&path, b"{ not json").unwrap();
|
||||||
|
|
||||||
|
let base = chrono::Utc::now();
|
||||||
|
for i in 0..MAX_CORRUPT_BACKUPS as i64 + 3 {
|
||||||
|
let at = base + chrono::Duration::seconds(i);
|
||||||
|
let kept = keep_corrupt_copy(&path, &at);
|
||||||
|
if i < MAX_CORRUPT_BACKUPS as i64 {
|
||||||
|
assert!(matches!(kept, Kept::Copied(_)), "copy {} should be kept", i);
|
||||||
|
} else {
|
||||||
|
assert!(
|
||||||
|
matches!(kept, Kept::EnoughAlready(MAX_CORRUPT_BACKUPS)),
|
||||||
|
"copy {} should be refused by the cap",
|
||||||
|
i
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(corrupt_copies(&dir).len(), MAX_CORRUPT_BACKUPS);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_second_read_in_the_same_second_does_not_re_copy() {
|
||||||
|
let dir = temp_dir("samesecond");
|
||||||
|
let path = notes_path_in(&dir, "p1");
|
||||||
|
std::fs::write(&path, b"{ not json").unwrap();
|
||||||
|
|
||||||
|
let at = chrono::Utc::now();
|
||||||
|
assert!(matches!(keep_corrupt_copy(&path, &at), Kept::Copied(_)));
|
||||||
|
assert!(matches!(
|
||||||
|
keep_corrupt_copy(&path, &at),
|
||||||
|
Kept::AlreadyThere(_)
|
||||||
|
));
|
||||||
|
assert_eq!(corrupt_copies(&dir).len(), 1);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_log_line_never_claims_a_backup_that_was_not_written() {
|
||||||
|
// A message that invents a backup is worse than no message: it is what
|
||||||
|
// someone reads before going to look for their data.
|
||||||
|
let dir = temp_dir("honesty");
|
||||||
|
let path = notes_path_in(&dir, "p1");
|
||||||
|
std::fs::write(&path, b"{ not json").unwrap();
|
||||||
|
|
||||||
|
let copied = keep_corrupt_copy(&path, &chrono::Utc::now()).describe();
|
||||||
|
assert!(copied.contains("a copy is at"));
|
||||||
|
|
||||||
|
let refused = Kept::EnoughAlready(MAX_CORRUPT_BACKUPS).describe();
|
||||||
|
assert!(refused.contains("no copy kept"));
|
||||||
|
assert!(!refused.contains("a copy is at"));
|
||||||
|
|
||||||
|
let failed = Kept::Failed("permission denied".into()).describe();
|
||||||
|
assert!(failed.contains("could not keep a copy"));
|
||||||
|
assert!(!failed.contains("a copy is at"));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_write_leaves_no_temp_file_behind() {
|
||||||
|
let dir = temp_dir("tmp");
|
||||||
|
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||||
|
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
|
||||||
|
.collect();
|
||||||
|
assert!(leftovers.is_empty(), "the rename must have consumed the temp file");
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clearing_a_project_removes_its_file_and_missing_is_success() {
|
||||||
|
let dir = temp_dir("clear");
|
||||||
|
upsert_in(&dir, "p1", Note::new("t".into(), "b".into())).unwrap();
|
||||||
|
assert!(notes_path_in(&dir, "p1").exists());
|
||||||
|
|
||||||
|
clear_in(&dir, "p1").unwrap();
|
||||||
|
assert!(!notes_path_in(&dir, "p1").exists());
|
||||||
|
clear_in(&dir, "p1").unwrap(); // idempotent
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clearing_is_what_project_removal_calls_and_it_never_fails_on_absence() {
|
||||||
|
// `remove_project` must not be able to fail because a project simply
|
||||||
|
// never had any notes — an orphaned notes file is harmless, a project
|
||||||
|
// that cannot be removed is not.
|
||||||
|
let dir = temp_dir("removal");
|
||||||
|
assert!(clear_in(&dir, "never-had-notes").is_ok());
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -321,28 +321,52 @@ pub fn delete_gateway_api_key() -> Result<(), String> {
|
|||||||
/// only enforces auth when a master key is configured, so Triple-C always
|
/// only enforces auth when a master key is configured, so Triple-C always
|
||||||
/// configures one.
|
/// configures one.
|
||||||
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
||||||
if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? {
|
if let Some(existing) = get_gateway_master_key()? {
|
||||||
if !existing.trim().is_empty() {
|
|
||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
regenerate_gateway_master_key()
|
regenerate_gateway_master_key()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the gateway master key without minting one if none exists yet.
|
||||||
|
/// Distinct from [`get_or_create_gateway_master_key`], which mints as a side
|
||||||
|
/// effect the read half of that function must not have — settings export
|
||||||
|
/// (triple-c#35) needs "is there one, and if so what is it", not "make sure
|
||||||
|
/// one exists".
|
||||||
|
pub fn get_gateway_master_key() -> Result<Option<String>, String> {
|
||||||
|
Ok(read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")?
|
||||||
|
.filter(|k| !k.trim().is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
||||||
/// previous value must be updated.
|
/// previous value must be updated.
|
||||||
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
||||||
// LiteLLM requires the master key to start with `sk-`.
|
// LiteLLM requires the master key to start with `sk-`.
|
||||||
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
||||||
|
store_gateway_master_key(&key)?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store an exact given gateway master key, replacing any previous one.
|
||||||
|
///
|
||||||
|
/// Distinct from [`regenerate_gateway_master_key`], which always mints a
|
||||||
|
/// fresh random value: this exists for settings import (triple-c#35), where
|
||||||
|
/// restoring the *same* key an export captured is the point — projects on
|
||||||
|
/// the destination machine may not exist yet, but a project migrated or
|
||||||
|
/// re-added later that still has the old key pasted into its config must
|
||||||
|
/// keep working against it. Blank input is rejected rather than silently
|
||||||
|
/// stored, matching every other `store_*` function in this module.
|
||||||
|
pub fn store_gateway_master_key(key: &str) -> Result<(), String> {
|
||||||
|
if key.trim().is_empty() {
|
||||||
|
return Err("Refusing to store an empty gateway master key.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
entry
|
entry
|
||||||
.set_password(&key)
|
.set_password(key.trim())
|
||||||
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
||||||
|
|
||||||
bump_gateway_secret_version()?;
|
bump_gateway_secret_version()
|
||||||
Ok(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//! Password-based encryption for the settings export/import file — see
|
||||||
|
//! triple-c#35.
|
||||||
|
//!
|
||||||
|
//! The exported payload can carry live credentials (the shared Claude OAuth
|
||||||
|
//! token, the gateway provider/master keys — see
|
||||||
|
//! `commands::settings_export_commands`), so this is not encryption for its
|
||||||
|
//! own sake; a wrong or missing key here is a real credential leak, not a
|
||||||
|
//! cosmetic bug. Argon2id derives a 256-bit key from the password (memory-
|
||||||
|
//! hard, meaningfully resistant to GPU/ASIC brute-forcing in a way PBKDF2 at
|
||||||
|
//! any reasonable iteration count is not), and AES-256-GCM is what actually
|
||||||
|
//! encrypts — authenticated, so a wrong password is detected by a failed tag
|
||||||
|
//! check rather than producing silent garbage.
|
||||||
|
//!
|
||||||
|
//! File format: `MAGIC (4 bytes) | salt (16 bytes) | nonce (12 bytes) |
|
||||||
|
//! ciphertext+tag`. The salt and nonce are not secret — they are written in
|
||||||
|
//! the clear right here, on purpose. The salt's only job is to make two
|
||||||
|
//! exports with the same password derive different keys (defeats a
|
||||||
|
//! precomputed-table attack against the password alone); the nonce's job is
|
||||||
|
//! GCM's requirement that a (key, nonce) pair never repeat. Both hold
|
||||||
|
//! because a fresh random value is drawn for each, on every call to
|
||||||
|
//! [`encrypt`].
|
||||||
|
//!
|
||||||
|
//! The whole header (magic + salt + nonce) is passed to AES-GCM as
|
||||||
|
//! associated data, not just placed alongside the ciphertext — free to do,
|
||||||
|
//! and it makes tampering with any header byte fail the same authentication
|
||||||
|
//! check the ciphertext gets, by construction rather than as a side effect
|
||||||
|
//! of the salt/nonce also feeding key derivation and the cipher.
|
||||||
|
|
||||||
|
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||||
|
use aes_gcm::{Aes256Gcm, Nonce};
|
||||||
|
use argon2::{Algorithm, Argon2, Params, Version};
|
||||||
|
use rand::RngCore;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
/// Identifies the file as a Triple-C settings export and pins the format —
|
||||||
|
/// a change to the salt/nonce lengths or the KDF/cipher choice below needs a
|
||||||
|
/// new magic value, not a silent reinterpretation of old bytes.
|
||||||
|
const MAGIC: &[u8; 4] = b"TCX1";
|
||||||
|
const SALT_LEN: usize = 16;
|
||||||
|
const NONCE_LEN: usize = 12;
|
||||||
|
const KEY_LEN: usize = 32;
|
||||||
|
const HEADER_LEN: usize = MAGIC.len() + SALT_LEN + NONCE_LEN;
|
||||||
|
|
||||||
|
/// Argon2id parameters: memory cost in KiB, time cost (iterations),
|
||||||
|
/// parallelism. `(19 MiB, 2, 1)` is OWASP's documented minimum recommendation
|
||||||
|
/// for Argon2id — deliberately heavier than a login-flow KDF would use, since
|
||||||
|
/// this runs once per export/import rather than on every request, so trading
|
||||||
|
/// roughly a second of wall time for real brute-force resistance costs
|
||||||
|
/// nothing a user would notice.
|
||||||
|
fn argon2_params() -> Params {
|
||||||
|
Params::new(19 * 1024, 2, 1, Some(KEY_LEN)).expect("hardcoded Argon2 params are valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The derived key is wrapped in `Zeroizing` so it is overwritten with zeros
|
||||||
|
/// when it drops rather than left in freed memory for whatever reuses that
|
||||||
|
/// stack slot next — cheap insurance (`zeroize` is already in the dependency
|
||||||
|
/// tree via `aes-gcm`) for material that exists only to decrypt live
|
||||||
|
/// credentials.
|
||||||
|
fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>, String> {
|
||||||
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params());
|
||||||
|
let mut key = Zeroizing::new([0u8; KEY_LEN]);
|
||||||
|
argon2
|
||||||
|
.hash_password_into(password.as_bytes(), salt, &mut *key)
|
||||||
|
.map_err(|e| format!("Failed to derive encryption key: {}", e))?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt `plaintext` with a key derived from `password`. Returns the whole
|
||||||
|
/// file's bytes (header + ciphertext) — see the module doc for the layout.
|
||||||
|
pub fn encrypt(plaintext: &[u8], password: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let mut salt = [0u8; SALT_LEN];
|
||||||
|
rand::rng().fill_bytes(&mut salt);
|
||||||
|
let key = derive_key(password, &salt)?;
|
||||||
|
|
||||||
|
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||||
|
rand::rng().fill_bytes(&mut nonce_bytes);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let mut header = Vec::with_capacity(HEADER_LEN);
|
||||||
|
header.extend_from_slice(MAGIC);
|
||||||
|
header.extend_from_slice(&salt);
|
||||||
|
header.extend_from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(&*key)
|
||||||
|
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
|
||||||
|
// The header (magic + salt + nonce) is authenticated as associated data
|
||||||
|
// even though none of it is secret: it costs nothing extra here, and it
|
||||||
|
// means tampering with any header byte is caught by the same tag check
|
||||||
|
// that already covers the ciphertext, by construction rather than as a
|
||||||
|
// side effect of the header also feeding key/nonce derivation.
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(nonce, Payload { msg: plaintext, aad: &header })
|
||||||
|
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||||
|
|
||||||
|
let mut out = header;
|
||||||
|
out.extend_from_slice(&ciphertext);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a file produced by [`encrypt`]. The one error this returns for a
|
||||||
|
/// wrong password is deliberately generic ("wrong password, or the file is
|
||||||
|
/// corrupted") rather than distinguishing the two: GCM's authentication tag
|
||||||
|
/// fails to verify for the wrong key on essentially any ciphertext, so there
|
||||||
|
/// is no reliable way to tell "wrong password" from "corrupted file" apart,
|
||||||
|
/// and guessing would be worse than saying so.
|
||||||
|
///
|
||||||
|
/// Returns `Zeroizing<Vec<u8>>` rather than a plain `Vec<u8>` — the plaintext
|
||||||
|
/// this recovers is the whole settings-plus-secrets payload, so it gets the
|
||||||
|
/// same "wipe it when it drops" treatment as the derived key in
|
||||||
|
/// [`derive_key`].
|
||||||
|
pub fn decrypt(data: &[u8], password: &str) -> Result<Zeroizing<Vec<u8>>, String> {
|
||||||
|
if data.len() < HEADER_LEN {
|
||||||
|
return Err("This does not look like a Triple-C settings export (file too short).".to_string());
|
||||||
|
}
|
||||||
|
if &data[..MAGIC.len()] != MAGIC {
|
||||||
|
return Err("This does not look like a Triple-C settings export (unrecognized file).".to_string());
|
||||||
|
}
|
||||||
|
let header = &data[..HEADER_LEN];
|
||||||
|
let salt = &data[MAGIC.len()..MAGIC.len() + SALT_LEN];
|
||||||
|
let nonce_bytes = &data[MAGIC.len() + SALT_LEN..HEADER_LEN];
|
||||||
|
let ciphertext = &data[HEADER_LEN..];
|
||||||
|
|
||||||
|
let key = derive_key(password, salt)?;
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(&*key)
|
||||||
|
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
|
||||||
|
let nonce = Nonce::from_slice(nonce_bytes);
|
||||||
|
cipher
|
||||||
|
.decrypt(nonce, Payload { msg: ciphertext, aad: header })
|
||||||
|
.map(Zeroizing::new)
|
||||||
|
.map_err(|_| "Wrong password, or the file is corrupted.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_round_trip_with_the_right_password_recovers_the_plaintext() {
|
||||||
|
let plaintext = b"{\"settings\": \"whatever\"}";
|
||||||
|
let encrypted = encrypt(plaintext, "correct horse battery staple").unwrap();
|
||||||
|
let decrypted = decrypt(&encrypted, "correct horse battery staple").unwrap();
|
||||||
|
assert_eq!(&*decrypted, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_wrong_password_fails_rather_than_returning_garbage() {
|
||||||
|
let encrypted = encrypt(b"secret payload", "correct password").unwrap();
|
||||||
|
let result = decrypt(&encrypted, "wrong password");
|
||||||
|
assert!(result.is_err(), "decrypting with the wrong password must fail, not silently succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_exports_of_the_same_plaintext_and_password_produce_different_files() {
|
||||||
|
// If this ever failed it would mean the salt or nonce stopped being
|
||||||
|
// randomized — either one repeating is a real security regression
|
||||||
|
// (a fixed salt lets an attacker precompute against the password
|
||||||
|
// alone; a repeated (key, nonce) pair breaks GCM's guarantees
|
||||||
|
// outright), not just a cosmetic one.
|
||||||
|
let a = encrypt(b"same plaintext", "same password").unwrap();
|
||||||
|
let b = encrypt(b"same plaintext", "same password").unwrap();
|
||||||
|
assert_ne!(a, b, "two independent exports must not be byte-identical");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupting_a_single_byte_of_ciphertext_is_detected() {
|
||||||
|
let mut encrypted = encrypt(b"tamper-evident payload", "a password").unwrap();
|
||||||
|
let last = encrypted.len() - 1;
|
||||||
|
encrypted[last] ^= 0xFF;
|
||||||
|
assert!(decrypt(&encrypted, "a password").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_that_is_too_short_is_rejected_cleanly_not_by_panicking() {
|
||||||
|
assert!(decrypt(b"short", "any password").is_err());
|
||||||
|
assert!(decrypt(b"", "any password").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_with_the_wrong_magic_is_rejected() {
|
||||||
|
let mut encrypted = encrypt(b"payload", "password").unwrap();
|
||||||
|
encrypted[0] = b'X';
|
||||||
|
assert!(decrypt(&encrypted, "password").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
import Sidebar from "./components/layout/Sidebar";
|
import Sidebar from "./components/layout/Sidebar";
|
||||||
import TopBar from "./components/layout/TopBar";
|
import TopBar from "./components/layout/TopBar";
|
||||||
import StatusBar from "./components/layout/StatusBar";
|
import StatusBar from "./components/layout/StatusBar";
|
||||||
|
import NotesDock from "./components/layout/NotesDock";
|
||||||
import TerminalView from "./components/terminal/TerminalView";
|
import TerminalView from "./components/terminal/TerminalView";
|
||||||
import DockerInstallDialog from "./components/DockerInstallDialog";
|
import DockerInstallDialog from "./components/DockerInstallDialog";
|
||||||
import ProjectHome from "./components/projects/home/ProjectHome";
|
import ProjectHome from "./components/projects/home/ProjectHome";
|
||||||
@@ -161,6 +162,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
<NotesDock />
|
||||||
</div>
|
</div>
|
||||||
<StatusBar stt={stt} />
|
<StatusBar stt={stt} />
|
||||||
<ToastHost />
|
<ToastHost />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "../../store/appState";
|
} from "../../store/appState";
|
||||||
import { effectivePermissionMode } from "../projects/PermissionModeControl";
|
import { effectivePermissionMode } from "../projects/PermissionModeControl";
|
||||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||||
|
import { sessionDisplayName } from "../../lib/sessionName";
|
||||||
import type { PermissionMode } from "../../lib/types";
|
import type { PermissionMode } from "../../lib/types";
|
||||||
|
|
||||||
interface ContextMenuState {
|
interface ContextMenuState {
|
||||||
@@ -195,11 +196,10 @@ export default function MainTabs() {
|
|||||||
}
|
}
|
||||||
const session = sessions.find((s) => s.id === tabKeyId(key));
|
const session = sessions.find((s) => s.id === tabKeyId(key));
|
||||||
if (!session) return "";
|
if (!session) return "";
|
||||||
const custom = getCustomName(session.projectId, session.id);
|
return sessionDisplayName(
|
||||||
return custom
|
session,
|
||||||
? `${session.projectName}: ${custom}`
|
projects.find((p) => p.id === session.projectId),
|
||||||
: (session.sessionName ?? session.projectName) +
|
);
|
||||||
(session.sessionType === "bash" ? " (bash)" : "");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const endDrag = () => {
|
const endDrag = () => {
|
||||||
@@ -358,13 +358,7 @@ export default function MainTabs() {
|
|||||||
const session = sessions.find((s) => s.id === sessionId);
|
const session = sessions.find((s) => s.id === sessionId);
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
const project = projects.find((p) => p.id === session.projectId);
|
const project = projects.find((p) => p.id === session.projectId);
|
||||||
const customName = getCustomName(session.projectId, session.id);
|
const displayLabel = sessionDisplayName(session, project);
|
||||||
const baseLabel =
|
|
||||||
(session.sessionName ?? session.projectName) +
|
|
||||||
(session.sessionType === "bash" ? " (bash)" : "");
|
|
||||||
const displayLabel = customName
|
|
||||||
? `${session.projectName}: ${customName}`
|
|
||||||
: baseLabel;
|
|
||||||
const isRenaming = renamingId === session.id;
|
const isRenaming = renamingId === session.id;
|
||||||
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import NotesDock from "./NotesDock";
|
||||||
|
import type { Project, TerminalSession } from "../../lib/types";
|
||||||
|
|
||||||
|
vi.mock("../notes/NotesDockPanel", () => ({
|
||||||
|
default: ({ projectId }: { projectId: string }) => (
|
||||||
|
<div data-testid="panel">{`panel:${projectId}`}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let state: Record<string, unknown> = {};
|
||||||
|
vi.mock("../../store/appState", () => ({
|
||||||
|
useAppState: Object.assign(
|
||||||
|
(selector: (s: unknown) => unknown) => selector(state),
|
||||||
|
{ getState: () => state },
|
||||||
|
),
|
||||||
|
isHomeTab: (k: string) => k.startsWith("home:"),
|
||||||
|
isTerminalTab: (k: string) => k.startsWith("term:"),
|
||||||
|
tabKeyId: (k: string) => k.slice(k.indexOf(":") + 1),
|
||||||
|
// The mocked store module still needs to supply the width constants the
|
||||||
|
// dock imports from it for the separator's aria-value attributes.
|
||||||
|
NOTES_DOCK_MIN_WIDTH: 260,
|
||||||
|
NOTES_DOCK_MAX_WIDTH: 720,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const session: TerminalSession = {
|
||||||
|
id: "s1",
|
||||||
|
projectId: "p9",
|
||||||
|
projectName: "api",
|
||||||
|
sessionType: "claude",
|
||||||
|
sessionName: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = {
|
||||||
|
notesDockOpen: true,
|
||||||
|
setNotesDockOpen: vi.fn(),
|
||||||
|
toggleNotesDock: vi.fn(),
|
||||||
|
notesDockWidth: 352,
|
||||||
|
setNotesDockWidth: vi.fn(),
|
||||||
|
activeTabKey: null,
|
||||||
|
sessions: [session],
|
||||||
|
projects: [{ id: "p9", name: "api" } as unknown as Project],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NotesDock", () => {
|
||||||
|
it("renders nothing when closed", () => {
|
||||||
|
state.notesDockOpen = false;
|
||||||
|
const { container } = render(<NotesDock />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows a project home tab", () => {
|
||||||
|
state.activeTabKey = "home:p1";
|
||||||
|
render(<NotesDock />);
|
||||||
|
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the project of the active terminal tab", () => {
|
||||||
|
// The dock exists to be visible while the agent runs, so a terminal tab
|
||||||
|
// must resolve to its project, not to nothing.
|
||||||
|
state.activeTabKey = "term:s1";
|
||||||
|
render(<NotesDock />);
|
||||||
|
expect(screen.getByTestId("panel")).toHaveTextContent("panel:p9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains itself when no project is active", () => {
|
||||||
|
state.activeTabKey = null;
|
||||||
|
render(<NotesDock />);
|
||||||
|
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/open a project/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing for a terminal whose session has gone", () => {
|
||||||
|
state.activeTabKey = "term:vanished";
|
||||||
|
render(<NotesDock />);
|
||||||
|
expect(screen.queryByTestId("panel")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders at the stored width", () => {
|
||||||
|
state.activeTabKey = "home:p1";
|
||||||
|
state.notesDockWidth = 420;
|
||||||
|
render(<NotesDock />);
|
||||||
|
expect(screen.getByLabelText("Notes")).toHaveStyle({ width: "420px" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has a keyboard-reachable resize handle", () => {
|
||||||
|
// Drag is a mouse gesture; a separator that only responds to pointer
|
||||||
|
// events is unusable without one.
|
||||||
|
state.activeTabKey = "home:p1";
|
||||||
|
render(<NotesDock />);
|
||||||
|
const handle = screen.getByRole("separator", { name: /resize notes/i });
|
||||||
|
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||||
|
expect(state.setNotesDockWidth).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("widens on ArrowLeft and narrows on ArrowRight, by the exact step", () => {
|
||||||
|
// The dock sits on the right edge, so dragging or pressing left grows it
|
||||||
|
// and right shrinks it. Asserting only "was called" would pass even if
|
||||||
|
// the branches were swapped or the sign inverted.
|
||||||
|
state.activeTabKey = "home:p1";
|
||||||
|
render(<NotesDock />);
|
||||||
|
const handle = screen.getByRole("separator", { name: /resize notes/i });
|
||||||
|
|
||||||
|
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||||
|
expect(state.setNotesDockWidth).toHaveBeenLastCalledWith(368);
|
||||||
|
|
||||||
|
fireEvent.keyDown(handle, { key: "ArrowRight" });
|
||||||
|
expect(state.setNotesDockWidth).toHaveBeenLastCalledWith(336);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import {
|
||||||
|
useAppState,
|
||||||
|
isHomeTab,
|
||||||
|
isTerminalTab,
|
||||||
|
tabKeyId,
|
||||||
|
NOTES_DOCK_MIN_WIDTH,
|
||||||
|
NOTES_DOCK_MAX_WIDTH,
|
||||||
|
} from "../../store/appState";
|
||||||
|
import NotesDockPanel from "../notes/NotesDockPanel";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes beside whatever is on screen.
|
||||||
|
*
|
||||||
|
* Project Home and Terminal are sibling top-level tabs, so notes living only
|
||||||
|
* in a sub-tab would be hidden exactly when the agent is running — which is
|
||||||
|
* when a note is worth sending. The dock is the answer to that.
|
||||||
|
*
|
||||||
|
* **It takes space from inside the window and never resizes it.** Growing the
|
||||||
|
* OS window was tried and rejected on evidence: honoured under XWayland,
|
||||||
|
* silently corrupting under native Wayland, where `outer_position()` returns a
|
||||||
|
* confident `Ok(0,0)` for a window that is somewhere else. See the design doc,
|
||||||
|
* §6.1. Narrowing the terminal instead costs nothing — `TerminalView`'s
|
||||||
|
* ResizeObserver already reflows xterm and resizes the container PTY.
|
||||||
|
*/
|
||||||
|
export default function NotesDock() {
|
||||||
|
const {
|
||||||
|
notesDockOpen,
|
||||||
|
setNotesDockOpen,
|
||||||
|
notesDockWidth,
|
||||||
|
setNotesDockWidth,
|
||||||
|
activeTabKey,
|
||||||
|
sessions,
|
||||||
|
} = useAppState(
|
||||||
|
useShallow((s) => ({
|
||||||
|
notesDockOpen: s.notesDockOpen,
|
||||||
|
setNotesDockOpen: s.setNotesDockOpen,
|
||||||
|
notesDockWidth: s.notesDockWidth,
|
||||||
|
setNotesDockWidth: s.setNotesDockWidth,
|
||||||
|
activeTabKey: s.activeTabKey,
|
||||||
|
sessions: s.sessions,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dragging the separator. Pointer capture rather than window listeners, so
|
||||||
|
// the drag survives the pointer crossing the terminal — which swallows
|
||||||
|
// events — and ends correctly if the button is released outside the window.
|
||||||
|
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const handle = e.currentTarget;
|
||||||
|
handle.setPointerCapture(e.pointerId);
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startWidth = notesDockWidth;
|
||||||
|
// The dock is on the right, so dragging left widens it.
|
||||||
|
const onMove = (move: PointerEvent) =>
|
||||||
|
setNotesDockWidth(startWidth + (startX - move.clientX));
|
||||||
|
const onUp = () => {
|
||||||
|
handle.releasePointerCapture(e.pointerId);
|
||||||
|
handle.removeEventListener("pointermove", onMove);
|
||||||
|
handle.removeEventListener("pointerup", onUp);
|
||||||
|
};
|
||||||
|
handle.addEventListener("pointermove", onMove);
|
||||||
|
handle.addEventListener("pointerup", onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHandleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const step = e.shiftKey ? 64 : 16;
|
||||||
|
if (e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
setNotesDockWidth(notesDockWidth + step);
|
||||||
|
} else if (e.key === "ArrowRight") {
|
||||||
|
e.preventDefault();
|
||||||
|
setNotesDockWidth(notesDockWidth - step);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!notesDockOpen) return null;
|
||||||
|
|
||||||
|
// Follow whatever is in front: a home tab is its own project, a terminal tab
|
||||||
|
// is the project it belongs to.
|
||||||
|
let projectId: string | null = null;
|
||||||
|
if (activeTabKey && isHomeTab(activeTabKey)) {
|
||||||
|
projectId = tabKeyId(activeTabKey);
|
||||||
|
} else if (activeTabKey && isTerminalTab(activeTabKey)) {
|
||||||
|
projectId =
|
||||||
|
sessions.find((s) => s.id === tabKeyId(activeTabKey))?.projectId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
aria-label="Notes"
|
||||||
|
style={{ width: `${notesDockWidth}px` }}
|
||||||
|
className="relative flex-shrink-0 flex flex-col min-h-0 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Separator, not decoration: it carries a role and arrow keys, because
|
||||||
|
a resize that only answers to a drag is unavailable to anyone not
|
||||||
|
using a mouse. */}
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
aria-label="Resize notes panel"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-valuenow={notesDockWidth}
|
||||||
|
aria-valuemin={NOTES_DOCK_MIN_WIDTH}
|
||||||
|
aria-valuemax={NOTES_DOCK_MAX_WIDTH}
|
||||||
|
tabIndex={0}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onKeyDown={onHandleKeyDown}
|
||||||
|
className="absolute left-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-[var(--accent-muted)] transition-colors"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-2 px-3 h-9 flex-shrink-0 border-b border-[var(--border-color)]">
|
||||||
|
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">Notes</h2>
|
||||||
|
<Button variant="ghost" onClick={() => setNotesDockOpen(false)} aria-label="Close notes">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{projectId ? (
|
||||||
|
<NotesDockPanel projectId={projectId} />
|
||||||
|
) : (
|
||||||
|
<p className="p-4 text-[13px] text-[var(--text-secondary)]">
|
||||||
|
Open a project or a terminal to see its notes.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ interface Props {
|
|||||||
export default function StatusBar({ stt }: Props) {
|
export default function StatusBar({ stt }: Props) {
|
||||||
const {
|
const {
|
||||||
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
|
||||||
terminalAtBottom, scrollActiveToBottom,
|
terminalAtBottom, scrollActiveToBottom, notesDockOpen, toggleNotesDock,
|
||||||
} = useAppState(
|
} = useAppState(
|
||||||
useShallow(s => ({
|
useShallow(s => ({
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
@@ -20,6 +20,8 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
sttEnabled: s.appSettings?.stt?.enabled,
|
sttEnabled: s.appSettings?.stt?.enabled,
|
||||||
terminalAtBottom: s.terminalAtBottom,
|
terminalAtBottom: s.terminalAtBottom,
|
||||||
scrollActiveToBottom: s.scrollActiveToBottom,
|
scrollActiveToBottom: s.scrollActiveToBottom,
|
||||||
|
notesDockOpen: s.notesDockOpen,
|
||||||
|
toggleNotesDock: s.toggleNotesDock,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const running = projects.filter((p) => p.status === "running").length;
|
const running = projects.filter((p) => p.status === "running").length;
|
||||||
@@ -69,6 +71,14 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
Jump to Current ↓
|
Jump to Current ↓
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={toggleNotesDock}
|
||||||
|
aria-pressed={notesDockOpen}
|
||||||
|
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
|
||||||
|
title="Show or hide the notes panel beside the current tab"
|
||||||
|
>
|
||||||
|
Notes
|
||||||
|
</button>
|
||||||
{sttEnabled && activeSessionId && (
|
{sttEnabled && activeSessionId && (
|
||||||
<SttButton
|
<SttButton
|
||||||
state={stt.state}
|
state={stt.state}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import SendToAgentButton from "./SendToAgentButton";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
onTitleChange: (value: string) => void;
|
||||||
|
onBodyChange: (value: string) => void;
|
||||||
|
onCommit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title and body, saved when a field loses focus.
|
||||||
|
*
|
||||||
|
* Plain text on purpose. There is no markdown rendering and no view/edit split,
|
||||||
|
* so there is no moment where the text on screen is not the text that would be
|
||||||
|
* sent — which is what makes "the agent gets exactly what you see" true rather
|
||||||
|
* than nearly true.
|
||||||
|
*/
|
||||||
|
export default function NoteEditor({
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
onTitleChange,
|
||||||
|
onBodyChange,
|
||||||
|
onCommit,
|
||||||
|
onDelete,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0 gap-2 p-3">
|
||||||
|
{/* Wraps rather than overflows. The two buttons are a group with a fixed
|
||||||
|
appetite (~190px) and the title field can shrink only so far, so in a
|
||||||
|
narrow dock the title takes the first row and the buttons the second.
|
||||||
|
Without the wrap the group is simply clipped by the dock's
|
||||||
|
`overflow-hidden`, which puts Delete off-window with no scrollbar to
|
||||||
|
reach it. */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => onTitleChange(e.target.value)}
|
||||||
|
onBlur={onCommit}
|
||||||
|
placeholder="Note title"
|
||||||
|
aria-label="Note title"
|
||||||
|
className="flex-1 min-w-24 px-2 h-8 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] transition-colors"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{/* The live editor text, not `note.body` — what is on screen is what
|
||||||
|
gets sent. */}
|
||||||
|
<SendToAgentButton projectId={projectId} body={body} />
|
||||||
|
<Button variant="danger" onClick={onDelete} aria-label="Delete note">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => onBodyChange(e.target.value)}
|
||||||
|
onBlur={onCommit}
|
||||||
|
placeholder="Reminders, gotchas, a prompt worth keeping…"
|
||||||
|
aria-label="Note body"
|
||||||
|
className="flex-1 min-h-0 w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] resize-none font-mono transition-colors"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Notes save when a field loses focus. Sending puts the note in the agent’s
|
||||||
|
prompt — you press Enter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import NoteSwitcher from "./NoteSwitcher";
|
||||||
|
import type { Note } from "../../lib/types";
|
||||||
|
|
||||||
|
const onTitleChange = vi.fn();
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
const note = (over: Partial<Note> = {}): Note => ({
|
||||||
|
id: "n1",
|
||||||
|
title: "Deploy steps",
|
||||||
|
body: "",
|
||||||
|
pinned: false,
|
||||||
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
|
updated_at: "2026-09-01T00:00:00Z",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setup = (notes: Note[], selectedId = notes[0]?.id ?? "", title = notes[0]?.title ?? "") =>
|
||||||
|
render(
|
||||||
|
<NoteSwitcher
|
||||||
|
notes={notes}
|
||||||
|
selectedId={selectedId}
|
||||||
|
title={title}
|
||||||
|
onTitleChange={onTitleChange}
|
||||||
|
onCommit={onCommit}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
describe("NoteSwitcher", () => {
|
||||||
|
it("edits the title in place, committing on blur", () => {
|
||||||
|
setup([note()]);
|
||||||
|
const field = screen.getByLabelText("Note title");
|
||||||
|
expect(field).toHaveValue("Deploy steps");
|
||||||
|
|
||||||
|
fireEvent.change(field, { target: { value: "Deploy steps v2" } });
|
||||||
|
expect(onTitleChange).toHaveBeenCalledWith("Deploy steps v2");
|
||||||
|
expect(onCommit).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.blur(field);
|
||||||
|
expect(onCommit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the other notes out of the way until asked for", () => {
|
||||||
|
setup([note(), note({ id: "n2", title: "Gotchas" })]);
|
||||||
|
expect(screen.queryByText("Gotchas")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||||
|
expect(screen.getByRole("option", { name: "Gotchas" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports whether the list is open", () => {
|
||||||
|
setup([note()]);
|
||||||
|
const trigger = screen.getByRole("button", { name: /switch note/i });
|
||||||
|
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||||
|
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the current note as the selected option", () => {
|
||||||
|
setup([note(), note({ id: "n2", title: "Gotchas" })], "n2", "Gotchas");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||||
|
|
||||||
|
expect(screen.getByRole("option", { name: "Gotchas" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("option", { name: "Deploy steps" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a note and closes", () => {
|
||||||
|
setup([note(), note({ id: "n2", title: "Gotchas" })]);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: "Gotchas" }));
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenCalledWith("n2");
|
||||||
|
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names an untitled note rather than showing an empty row", () => {
|
||||||
|
setup([note({ title: " " })]);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||||
|
expect(screen.getByRole("option", { name: "Untitled note" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notes are addressed by id, never by title. Two untitled notes are the
|
||||||
|
// ordinary case, and a title-keyed list would collapse them into one row.
|
||||||
|
it("lists two notes that share a title as two options", () => {
|
||||||
|
setup([note({ id: "n1", title: "" }), note({ id: "n2", title: "" })]);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||||
|
|
||||||
|
const options = screen.getAllByRole("option", { name: "Untitled note" });
|
||||||
|
expect(options).toHaveLength(2);
|
||||||
|
|
||||||
|
fireEvent.click(options[1]);
|
||||||
|
expect(onSelect).toHaveBeenCalledWith("n2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Escape without selecting anything", () => {
|
||||||
|
setup([note(), note({ id: "n2", title: "Gotchas" })]);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||||
|
expect(onSelect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { Note } from "../../lib/types";
|
||||||
|
|
||||||
|
export const UNTITLED = "Untitled note";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
notes: Note[];
|
||||||
|
selectedId: string;
|
||||||
|
title: string;
|
||||||
|
onTitleChange: (value: string) => void;
|
||||||
|
onCommit: () => void;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row that both names the current note and switches to another.
|
||||||
|
*
|
||||||
|
* The dock has no room for a permanent list of titles, so the title field
|
||||||
|
* doubles as the label of what is open and the chevron beside it holds the
|
||||||
|
* rest. Renaming therefore needs no separate affordance.
|
||||||
|
*
|
||||||
|
* Two honest controls rather than one `role="combobox"`: a text field and a
|
||||||
|
* button that opens a listbox. A real combobox owes its listbox keyboard
|
||||||
|
* navigation, active-descendant tracking and an input that filters — none of
|
||||||
|
* which this needs, and half of which is worse than not claiming the role.
|
||||||
|
*
|
||||||
|
* `OverflowMenu` is deliberately not reused here despite the shape being
|
||||||
|
* close. It keys its items by label, and notes are addressed by id: two
|
||||||
|
* untitled notes are the ordinary case and would collapse into one row.
|
||||||
|
*/
|
||||||
|
export default function NoteSwitcher({
|
||||||
|
notes,
|
||||||
|
selectedId,
|
||||||
|
title,
|
||||||
|
onTitleChange,
|
||||||
|
onCommit,
|
||||||
|
onSelect,
|
||||||
|
}: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Same dismissal contract as `OverflowMenu`, so the two feel identical.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDocClick);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDocClick);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="relative flex items-center gap-1 min-w-0">
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => onTitleChange(e.target.value)}
|
||||||
|
onBlur={onCommit}
|
||||||
|
placeholder="Note title"
|
||||||
|
aria-label="Note title"
|
||||||
|
className="flex-1 min-w-0 px-2 h-7 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Switch note"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="inline-flex items-center justify-center h-7 w-6 flex-shrink-0 rounded-[var(--radius-control)] border border-[var(--border-color)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="leading-none text-[10px]">▾</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Notes"
|
||||||
|
className="absolute right-0 top-full mt-1 z-40 w-full max-h-64 overflow-y-auto py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)]"
|
||||||
|
style={{ boxShadow: "var(--shadow-overlay)" }}
|
||||||
|
>
|
||||||
|
{/* Buttons directly inside the listbox: wrapping each in an `<li>`
|
||||||
|
would put an implicit `listitem` between the listbox and its
|
||||||
|
options, which is not a child role a listbox owns. */}
|
||||||
|
{notes.map((n) => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={n.id === selectedId}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(n.id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={`block w-full text-left px-3 py-1.5 text-xs truncate transition-colors hover:bg-[var(--bg-tertiary)] ${
|
||||||
|
n.id === selectedId
|
||||||
|
? "text-[var(--text-primary)] bg-[var(--bg-tertiary)]"
|
||||||
|
: "text-[var(--text-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n.title.trim() || UNTITLED}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import NotesDockPanel from "./NotesDockPanel";
|
||||||
|
import type { Note } from "../../lib/types";
|
||||||
|
|
||||||
|
const saveNote = vi.fn(async () => true);
|
||||||
|
const deleteNote = vi.fn(async () => true);
|
||||||
|
const createNote = vi.fn();
|
||||||
|
let notes: Note[] = [];
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useNotes", () => ({
|
||||||
|
useNotes: () => ({
|
||||||
|
notes,
|
||||||
|
loading,
|
||||||
|
saveState: { status: "idle", error: null },
|
||||||
|
createNote,
|
||||||
|
saveNote,
|
||||||
|
deleteNote,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sendProps: Record<string, unknown>[] = [];
|
||||||
|
vi.mock("./SendToAgentButton", () => ({
|
||||||
|
default: (props: Record<string, unknown>) => {
|
||||||
|
sendProps.push(props);
|
||||||
|
return <button type="button">Send to agent</button>;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const note = (over: Partial<Note> = {}): Note => ({
|
||||||
|
id: "n1",
|
||||||
|
title: "Deploy steps",
|
||||||
|
body: "one\ntwo",
|
||||||
|
pinned: false,
|
||||||
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
|
updated_at: "2026-09-01T00:00:00Z",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
sendProps.length = 0;
|
||||||
|
notes = [];
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NotesDockPanel", () => {
|
||||||
|
it("says it is loading rather than flashing an empty state", () => {
|
||||||
|
loading = true;
|
||||||
|
render(<NotesDockPanel projectId="p1" />);
|
||||||
|
expect(screen.getByText(/loading notes/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a first note when the project has none", async () => {
|
||||||
|
render(<NotesDockPanel projectId="p1" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /new note/i }));
|
||||||
|
await waitFor(() => expect(createNote).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
// The point of the redesign: the dock spends its height on the note being
|
||||||
|
// written, not on a permanent list of the ones that are not.
|
||||||
|
it("shows one note at a time, the rest behind the switcher", () => {
|
||||||
|
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||||
|
render(<NotesDockPanel projectId="p1" />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Note title")).toHaveValue("Deploy steps");
|
||||||
|
expect(screen.queryByText("Gotchas")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||||
|
expect(screen.getByRole("option", { name: "Gotchas" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches to the note picked from the list", () => {
|
||||||
|
notes = [note(), note({ id: "n2", title: "Gotchas", body: "careful" })];
|
||||||
|
render(<NotesDockPanel projectId="p1" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /switch note/i }));
|
||||||
|
fireEvent.click(screen.getByRole("option", { name: "Gotchas" }));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("Note title")).toHaveValue("Gotchas");
|
||||||
|
expect(screen.getByLabelText("Note body")).toHaveValue("careful");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the body when it loses focus, and not before", () => {
|
||||||
|
notes = [note()];
|
||||||
|
render(<NotesDockPanel projectId="p1" />);
|
||||||
|
const body = screen.getByLabelText("Note body");
|
||||||
|
|
||||||
|
fireEvent.change(body, { target: { value: "one\ntwo\nthree" } });
|
||||||
|
expect(saveNote).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.blur(body);
|
||||||
|
expect(saveNote).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: "n1", body: "one\ntwo\nthree" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps New and Delete in the overflow menu, out of the writing area", async () => {
|
||||||
|
notes = [note()];
|
||||||
|
render(<NotesDockPanel projectId="p1" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /note actions/i }));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: /delete note/i }));
|
||||||
|
await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the note it just created", async () => {
|
||||||
|
notes = [note()];
|
||||||
|
createNote.mockResolvedValueOnce(note({ id: "n9", title: "" }));
|
||||||
|
const view = render(<NotesDockPanel projectId="p1" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /note actions/i }));
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: /new note/i }));
|
||||||
|
await waitFor(() => expect(createNote).toHaveBeenCalled());
|
||||||
|
|
||||||
|
notes = [note(), note({ id: "n9", title: "" })];
|
||||||
|
view.rerender(<NotesDockPanel projectId="p1" />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("Note title")).toHaveValue(""),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The send bar sits on the dock's bottom edge, inside an `overflow-hidden`
|
||||||
|
// panel, so both of these are load-bearing rather than cosmetic.
|
||||||
|
it("sends from a full-width bar whose menu opens upward", () => {
|
||||||
|
notes = [note()];
|
||||||
|
render(<NotesDockPanel projectId="p1" />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /send to agent/i })).toBeInTheDocument();
|
||||||
|
expect(sendProps.at(-1)).toMatchObject({ fullWidth: true, dropUp: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends what is on screen, not what was last saved", () => {
|
||||||
|
notes = [note()];
|
||||||
|
render(<NotesDockPanel projectId="p1" />);
|
||||||
|
fireEvent.change(screen.getByLabelText("Note body"), {
|
||||||
|
target: { value: "edited but not blurred" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendProps.at(-1)).toMatchObject({ body: "edited but not blurred" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNotes } from "../../hooks/useNotes";
|
||||||
|
import { useNoteDraft } from "./useNoteDraft";
|
||||||
|
import NoteSwitcher from "./NoteSwitcher";
|
||||||
|
import SendToAgentButton from "./SendToAgentButton";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import OverflowMenu from "../ui/OverflowMenu";
|
||||||
|
import SaveIndicator from "../ui/SaveIndicator";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes at dock width.
|
||||||
|
*
|
||||||
|
* Deliberately not `NotesPanel` in a narrower box. The tab can afford a column
|
||||||
|
* of titles beside the editor; the dock cannot, and shrinking that layout
|
||||||
|
* spends its height on chrome — a title strip, a wrapped button row and a
|
||||||
|
* paragraph of help — for a body that ends up a few words wide.
|
||||||
|
*
|
||||||
|
* So the dock shows exactly one note. The title row names it and switches to
|
||||||
|
* another, the actions that are not writing live in the overflow menu, and
|
||||||
|
* everything left over is the body. Roughly 240px of height comes back.
|
||||||
|
*
|
||||||
|
* What the two surfaces share is the part that must not drift: `useNotes` for
|
||||||
|
* the cache and its write ordering, and `useNoteDraft` for when a keystroke
|
||||||
|
* becomes a save. Only the layout is different.
|
||||||
|
*/
|
||||||
|
export default function NotesDockPanel({ projectId }: Props) {
|
||||||
|
const { notes, loading, saveState, createNote, saveNote, deleteNote } =
|
||||||
|
useNotes(projectId);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
|
||||||
|
[notes, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { title, body, setTitle, setBody, commit } = useNoteDraft(
|
||||||
|
selected,
|
||||||
|
saveNote,
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCreate = async () => {
|
||||||
|
const note = await createNote();
|
||||||
|
if (note) setSelectedId(note.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<p className="p-4 text-xs text-[var(--text-secondary)]">Loading notes…</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 p-4">
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)] text-center">
|
||||||
|
Keep reminders here, and send any of them straight to a running Claude
|
||||||
|
session.
|
||||||
|
</p>
|
||||||
|
<Button variant="primary" onClick={onCreate}>
|
||||||
|
New note
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1.5 flex-shrink-0 border-b border-[var(--border-color)]">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<NoteSwitcher
|
||||||
|
notes={notes}
|
||||||
|
selectedId={selected.id}
|
||||||
|
title={title}
|
||||||
|
onTitleChange={setTitle}
|
||||||
|
onCommit={commit}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Renders nothing while idle, so it costs no width until it matters. */}
|
||||||
|
<SaveIndicator state={saveState} />
|
||||||
|
<OverflowMenu
|
||||||
|
label="Note actions"
|
||||||
|
items={[
|
||||||
|
{ label: "New note", onSelect: () => void onCreate() },
|
||||||
|
{
|
||||||
|
label: "Delete note",
|
||||||
|
danger: true,
|
||||||
|
onSelect: () => void deleteNote(selected.id),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
placeholder="Reminders, gotchas, a prompt worth keeping…"
|
||||||
|
aria-label="Note body"
|
||||||
|
className="flex-1 min-h-0 w-full px-3 py-2 bg-transparent text-[13px] text-[var(--text-primary)] resize-none font-mono"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="px-2 py-2 flex-shrink-0 border-t border-[var(--border-color)]">
|
||||||
|
{/* The live draft, not `selected.body` — what is on screen is what gets
|
||||||
|
sent. `dropUp` because the dock clips its own overflow. */}
|
||||||
|
<SendToAgentButton
|
||||||
|
projectId={projectId}
|
||||||
|
body={body}
|
||||||
|
fullWidth
|
||||||
|
dropUp
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, within, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import NotesPanel from "./NotesPanel";
|
||||||
|
import NotesDockPanel from "./NotesDockPanel";
|
||||||
|
import { useAppState } from "../../store/appState";
|
||||||
|
import type { Note } from "../../lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two panels, one project — the configuration the app actually runs in.
|
||||||
|
*
|
||||||
|
* `NotesTab` mounts a `NotesPanel` and `NotesDock` mounts a `NotesDockPanel`,
|
||||||
|
* and the dock follows the active tab's project, so opening the dock over a
|
||||||
|
* Project Home tab mounts both for the *same* project. Every other notes test
|
||||||
|
* mounts exactly one, which is precisely the configuration in which a
|
||||||
|
* per-panel cache looks correct: it is only with two that an edit made in one
|
||||||
|
* is seen — or lost — by the other. `useNotes` is deliberately **not** mocked
|
||||||
|
* here; the cache is what is under test.
|
||||||
|
*
|
||||||
|
* The two are different components on purpose, which is exactly why this test
|
||||||
|
* pairs them rather than mounting the same one twice: the layouts diverged,
|
||||||
|
* and the cache and draft rules they share are what must not.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const files: Record<string, Note[]> = {};
|
||||||
|
|
||||||
|
vi.mock("../../lib/tauri-commands", () => ({
|
||||||
|
listNotes: async (p: string) => [...(files[p] ?? [])],
|
||||||
|
saveNote: async (p: string, n: Note) => {
|
||||||
|
const list = files[p] ?? (files[p] = []);
|
||||||
|
const at = list.findIndex((x) => x.id === n.id);
|
||||||
|
if (at === -1) list.unshift(n);
|
||||||
|
else list[at] = n;
|
||||||
|
return n;
|
||||||
|
},
|
||||||
|
deleteNote: async (p: string, id: string) => {
|
||||||
|
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./SendToAgentButton", () => ({
|
||||||
|
default: () => <button type="button">Send to agent</button>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const note = (over: Partial<Note> = {}): Note => ({
|
||||||
|
id: "n1",
|
||||||
|
title: "Deploy steps",
|
||||||
|
body: "one",
|
||||||
|
pinned: false,
|
||||||
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
|
updated_at: "2026-09-01T00:00:00Z",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The tab and the dock, mounted together the way `App` mounts them. */
|
||||||
|
function renderBothSurfaces() {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<div data-testid="tab">
|
||||||
|
<NotesPanel projectId="p1" />
|
||||||
|
</div>
|
||||||
|
<div data-testid="dock">
|
||||||
|
<NotesDockPanel projectId="p1" />
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
tab: () => within(screen.getByTestId("tab")),
|
||||||
|
dock: () => within(screen.getByTestId("dock")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const key of Object.keys(files)) delete files[key];
|
||||||
|
files.p1 = [note()];
|
||||||
|
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the tab and the dock both open on one project", () => {
|
||||||
|
it("shows an edit made in one surface in the other", async () => {
|
||||||
|
const { tab, dock } = renderBothSurfaces();
|
||||||
|
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||||
|
|
||||||
|
const dockTitle = dock().getByLabelText("Note title");
|
||||||
|
fireEvent.change(dockTitle, { target: { value: "Deploy steps v2" } });
|
||||||
|
fireEvent.blur(dockTitle);
|
||||||
|
|
||||||
|
// The other surface's list *and* its editor, not just one of them.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(tab().getByRole("button", { name: /deploy steps v2/i })).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(tab().getByLabelText("Note title")).toHaveValue("Deploy steps v2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not write one surface's stale copy over the other's edit", async () => {
|
||||||
|
// The reported repro: edit in the dock, then go back to the tab and edit
|
||||||
|
// there. With a cache per panel, the tab committed `{...staleNote, ...}`
|
||||||
|
// and the dock's edit was gone from disk with no error and no indicator.
|
||||||
|
const { tab, dock } = renderBothSurfaces();
|
||||||
|
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||||
|
|
||||||
|
const dockTitle = dock().getByLabelText("Note title");
|
||||||
|
fireEvent.change(dockTitle, { target: { value: "Deploy steps v2" } });
|
||||||
|
fireEvent.blur(dockTitle);
|
||||||
|
await waitFor(() => expect(files.p1[0].title).toBe("Deploy steps v2"));
|
||||||
|
|
||||||
|
const tabBody = tab().getByLabelText("Note body");
|
||||||
|
fireEvent.change(tabBody, { target: { value: "two" } });
|
||||||
|
fireEvent.blur(tabBody);
|
||||||
|
|
||||||
|
await waitFor(() => expect(files.p1[0].body).toBe("two"));
|
||||||
|
expect(files.p1).toHaveLength(1);
|
||||||
|
expect(files.p1[0].title).toBe("Deploy steps v2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the project once for both surfaces", async () => {
|
||||||
|
// Two panels are two `useNotes`, but the in-flight flag is per project, so
|
||||||
|
// mounting the dock over an open Notes tab does not re-read the file.
|
||||||
|
const listNotes = vi.spyOn(
|
||||||
|
await import("../../lib/tauri-commands"),
|
||||||
|
"listNotes",
|
||||||
|
);
|
||||||
|
renderBothSurfaces();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getAllByLabelText("Note body")[0]).toHaveValue("one"),
|
||||||
|
);
|
||||||
|
expect(listNotes).toHaveBeenCalledTimes(1);
|
||||||
|
listNotes.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps text the user is part-way through typing when the other surface saves", async () => {
|
||||||
|
// Showing a remote edit must never mean discarding an unsaved local one.
|
||||||
|
const { tab, dock } = renderBothSurfaces();
|
||||||
|
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||||
|
|
||||||
|
const tabBody = tab().getByLabelText("Note body");
|
||||||
|
fireEvent.change(tabBody, { target: { value: "half-typed" } });
|
||||||
|
|
||||||
|
const dockBody = dock().getByLabelText("Note body");
|
||||||
|
fireEvent.change(dockBody, { target: { value: "saved in the dock" } });
|
||||||
|
fireEvent.blur(dockBody);
|
||||||
|
await waitFor(() => expect(files.p1[0].body).toBe("saved in the dock"));
|
||||||
|
|
||||||
|
expect(tabBody).toHaveValue("half-typed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to another note when the selected one is deleted", async () => {
|
||||||
|
// The claim a differently-named test in NotesPanel.test.tsx used to make
|
||||||
|
// and could not keep: `useNotes` is mocked there and its list never
|
||||||
|
// changes, so the fallback was invisible. Here the list is real.
|
||||||
|
files.p1 = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
|
||||||
|
const { tab } = renderBothSurfaces();
|
||||||
|
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||||
|
|
||||||
|
fireEvent.click(tab().getByRole("button", { name: /delete note/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("beware"));
|
||||||
|
expect(tab().queryByRole("button", { name: /deploy steps/i })).not.toBeInTheDocument();
|
||||||
|
expect(files.p1).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a note created in one surface in the other", async () => {
|
||||||
|
const { tab, dock } = renderBothSurfaces();
|
||||||
|
await waitFor(() => expect(tab().getByLabelText("Note body")).toHaveValue("one"));
|
||||||
|
|
||||||
|
// The dock keeps New behind its overflow menu — its height belongs to the
|
||||||
|
// note being written, not to a button row.
|
||||||
|
fireEvent.click(dock().getByRole("button", { name: /note actions/i }));
|
||||||
|
fireEvent.click(dock().getByRole("menuitem", { name: /new note/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(tab().getAllByRole("button", { name: /untitled note/i })).toHaveLength(1),
|
||||||
|
);
|
||||||
|
expect(files.p1).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import NotesPanel from "./NotesPanel";
|
||||||
|
import type { Note } from "../../lib/types";
|
||||||
|
|
||||||
|
const saveNote = vi.fn(async () => true);
|
||||||
|
const deleteNote = vi.fn(async () => true);
|
||||||
|
const createNote = vi.fn();
|
||||||
|
let notes: Note[] = [];
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useNotes", () => ({
|
||||||
|
useNotes: () => ({
|
||||||
|
notes,
|
||||||
|
loading,
|
||||||
|
saveState: { status: "idle", error: null },
|
||||||
|
createNote,
|
||||||
|
saveNote,
|
||||||
|
deleteNote,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./SendToAgentButton", () => ({
|
||||||
|
default: ({ body }: { body: string }) => (
|
||||||
|
<button type="button" data-testid="send">{`send:${body}`}</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const note = (over: Partial<Note> = {}): Note => ({
|
||||||
|
id: "n1",
|
||||||
|
title: "Deploy steps",
|
||||||
|
body: "one\ntwo",
|
||||||
|
pinned: false,
|
||||||
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
|
updated_at: "2026-09-01T00:00:00Z",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
notes = [];
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NotesPanel", () => {
|
||||||
|
it("invites the user to start when there are no notes", () => {
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
expect(screen.getByText(/no notes yet/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists notes by title and selects the first", () => {
|
||||||
|
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
expect(screen.getByRole("button", { name: /deploy steps/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Note body")).toHaveValue("one\ntwo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an untitled note under a placeholder rather than a blank row", () => {
|
||||||
|
notes = [note({ title: "" })];
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
expect(screen.getByRole("button", { name: /untitled note/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches the editor when another note is selected", () => {
|
||||||
|
notes = [note(), note({ id: "n2", title: "Gotchas", body: "beware" })];
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /gotchas/i }));
|
||||||
|
expect(screen.getByLabelText("Note body")).toHaveValue("beware");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves on blur, not on every keystroke", async () => {
|
||||||
|
notes = [note()];
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
const body = screen.getByLabelText("Note body");
|
||||||
|
|
||||||
|
fireEvent.change(body, { target: { value: "edited" } });
|
||||||
|
expect(saveNote).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.blur(body);
|
||||||
|
await waitFor(() => expect(saveNote).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: "n1", body: "edited" }),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not save on blur when nothing changed", async () => {
|
||||||
|
// Clicking through notes to read them must not write the file.
|
||||||
|
notes = [note()];
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
fireEvent.blur(screen.getByLabelText("Note body"));
|
||||||
|
await waitFor(() => expect(saveNote).not.toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands the live editor text to the send button, not the last saved copy", () => {
|
||||||
|
// Sending what is on screen is the whole contract: no transform on the way
|
||||||
|
// out except the newline substitution.
|
||||||
|
notes = [note()];
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
fireEvent.change(screen.getByLabelText("Note body"), { target: { value: "fresh" } });
|
||||||
|
expect(screen.getByTestId("send")).toHaveTextContent("send:fresh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks the hook to delete the selected note", async () => {
|
||||||
|
// Only the call: `useNotes` is mocked here and the mocked list never
|
||||||
|
// changes, so nothing in this file can exercise what the panel selects
|
||||||
|
// afterwards. The fallback is covered against the real hook in
|
||||||
|
// NotesPanel.shared.test.tsx.
|
||||||
|
notes = [note(), note({ id: "n2", title: "Gotchas" })];
|
||||||
|
render(<NotesPanel projectId="p1" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /delete note/i }));
|
||||||
|
await waitFor(() => expect(deleteNote).toHaveBeenCalledWith("n1"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNotes } from "../../hooks/useNotes";
|
||||||
|
import { useNoteDraft } from "./useNoteDraft";
|
||||||
|
import NoteEditor from "./NoteEditor";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import SaveIndicator from "../ui/SaveIndicator";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNTITLED = "Untitled note";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notes surface itself, shared by the Project Home tab and the dock so the
|
||||||
|
* two cannot drift into different behaviour.
|
||||||
|
*
|
||||||
|
* Master/detail: titles beside the editor when there is room, stacked above it
|
||||||
|
* when there is not. That is a **container** query, not a viewport one, because
|
||||||
|
* the two surfaces differ in width while sharing a viewport — the dock opens at
|
||||||
|
* 352px and the tab is the width of the main area. A `md:` breakpoint would
|
||||||
|
* read the window and give both the same answer, which is the wrong answer for
|
||||||
|
* one of them.
|
||||||
|
*
|
||||||
|
* The threshold is arithmetic, not taste: side by side needs the 192px list,
|
||||||
|
* plus an editor wide enough for its own action row (~280px), plus the divider.
|
||||||
|
* Below ~473px the editor is narrower than its buttons, so `@lg` (512px) is the
|
||||||
|
* first stop that clears it.
|
||||||
|
*
|
||||||
|
* The editor holds draft text locally and commits on blur, which is how every
|
||||||
|
* other editable field in the app behaves (`ClaudeInstructionsEditor`, the
|
||||||
|
* Config tab).
|
||||||
|
*/
|
||||||
|
export default function NotesPanel({ projectId }: Props) {
|
||||||
|
const { notes, loading, saveState, createNote, saveNote, deleteNote } =
|
||||||
|
useNotes(projectId);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => notes.find((n) => n.id === selectedId) ?? notes[0] ?? null,
|
||||||
|
[notes, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { title, body, setTitle, setBody, commit } = useNoteDraft(
|
||||||
|
selected,
|
||||||
|
saveNote,
|
||||||
|
);
|
||||||
|
|
||||||
|
const onCreate = async () => {
|
||||||
|
const note = await createNote();
|
||||||
|
if (note) setSelectedId(note.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<p className="p-4 text-xs text-[var(--text-secondary)]">Loading notes…</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="@container flex flex-col h-full min-h-0">
|
||||||
|
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-[var(--border-color)]">
|
||||||
|
<Button variant="primary" onClick={onCreate}>
|
||||||
|
New note
|
||||||
|
</Button>
|
||||||
|
<SaveIndicator state={saveState} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notes.length === 0 ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-4">
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)] text-center">
|
||||||
|
No notes yet. Keep reminders here, and send any of them straight to a
|
||||||
|
running Claude session.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col @lg:flex-row">
|
||||||
|
{/* Stacked: a capped strip of titles above the editor, so the note
|
||||||
|
being written keeps most of the height. Side by side: a full-height
|
||||||
|
column of the fixed width the editor's arithmetic assumes. */}
|
||||||
|
<ul className="flex-shrink-0 overflow-y-auto py-1 max-h-32 border-b @lg:max-h-none @lg:w-48 @lg:border-b-0 @lg:border-r border-[var(--border-color)]">
|
||||||
|
{notes.map((n) => (
|
||||||
|
<li key={n.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(n.id)}
|
||||||
|
className={`w-full text-left px-3 py-1.5 text-xs truncate transition-colors ${
|
||||||
|
selected?.id === n.id
|
||||||
|
? "bg-[var(--bg-tertiary)] text-[var(--text-primary)]"
|
||||||
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n.title.trim() || UNTITLED}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{selected && (
|
||||||
|
<NoteEditor
|
||||||
|
projectId={projectId}
|
||||||
|
title={title}
|
||||||
|
body={body}
|
||||||
|
onTitleChange={setTitle}
|
||||||
|
onBodyChange={setBody}
|
||||||
|
onCommit={commit}
|
||||||
|
onDelete={() => void deleteNote(selected.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import SendToAgentButton from "./SendToAgentButton";
|
||||||
|
import type { Project, TerminalSession } from "../../lib/types";
|
||||||
|
|
||||||
|
const sendInput = vi.fn(async () => {});
|
||||||
|
let sessions: TerminalSession[] = [];
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useTerminal", () => ({
|
||||||
|
useTerminal: () => ({ sessions, sendInput }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const setActiveTabKey = vi.fn();
|
||||||
|
const requestTerminalFocus = vi.fn();
|
||||||
|
const pushToast = vi.fn();
|
||||||
|
let projects: Project[] = [];
|
||||||
|
|
||||||
|
vi.mock("../../store/appState", () => ({
|
||||||
|
useAppState: Object.assign(
|
||||||
|
(selector: (s: unknown) => unknown) =>
|
||||||
|
selector({ projects, setActiveTabKey, requestTerminalFocus, pushToast }),
|
||||||
|
{
|
||||||
|
getState: () => ({
|
||||||
|
projects,
|
||||||
|
setActiveTabKey,
|
||||||
|
requestTerminalFocus,
|
||||||
|
pushToast,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
terminalTabKey: (id: string) => `term:${id}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const session = (over: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||||
|
id: "s1",
|
||||||
|
projectId: "p1",
|
||||||
|
projectName: "api",
|
||||||
|
sessionType: "claude",
|
||||||
|
sessionName: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
sessions = [];
|
||||||
|
projects = [{ id: "p1", name: "api", renamed_session_names: {} } as unknown as Project];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SendToAgentButton", () => {
|
||||||
|
// Unavailable, not `disabled`: the reason a note cannot be sent is the whole
|
||||||
|
// content of these states, and native `disabled` announces it to nobody.
|
||||||
|
it("says why it cannot send when the project has no running session", () => {
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
const button = screen.getByRole("button", { name: /send to agent/i });
|
||||||
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||||
|
expect(button).toHaveAccessibleDescription(
|
||||||
|
"No running Claude session for this project",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says why it cannot send an empty note", () => {
|
||||||
|
sessions = [session()];
|
||||||
|
render(<SendToAgentButton projectId="p1" body=" " />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /send to agent/i }),
|
||||||
|
).toHaveAccessibleDescription("Nothing to send — this note is empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is unavailable when the only session belongs to another project", () => {
|
||||||
|
sessions = [session({ projectId: "other" })];
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /send to agent/i }),
|
||||||
|
).toHaveAttribute("aria-disabled", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is unavailable when the only session is a bash tab", () => {
|
||||||
|
// `bash -l`'s readline has no binding for ESC+CR and just bells, so a
|
||||||
|
// shell is never a target.
|
||||||
|
sessions = [session({ sessionType: "bash" })];
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /send to agent/i }),
|
||||||
|
).toHaveAttribute("aria-disabled", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
// `aria-disabled` is advisory — it blocks nothing on its own. Without the
|
||||||
|
// guard this swap would turn a greyed-out button into a live one.
|
||||||
|
it("sends nothing when activated while unavailable", () => {
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
const button = screen.getByRole("button", { name: /send to agent/i });
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.keyDown(button, { key: "Enter" });
|
||||||
|
fireEvent.keyDown(button, { key: " " });
|
||||||
|
|
||||||
|
expect(sendInput).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends straight to the one session, with newlines converted and no terminator", async () => {
|
||||||
|
sessions = [session()];
|
||||||
|
render(<SendToAgentButton projectId="p1" body={"one\ntwo"} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "one\x1b\rtwo"));
|
||||||
|
expect(sendInput.mock.calls[0][1].endsWith("\r")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the terminal it sent to, so the user watches it land", async () => {
|
||||||
|
sessions = [session()];
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hi" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||||
|
await waitFor(() => expect(setActiveTabKey).toHaveBeenCalledWith("term:s1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a menu of display names when several sessions are open", async () => {
|
||||||
|
sessions = [session(), session({ id: "s2", sessionName: "review" })];
|
||||||
|
projects = [
|
||||||
|
{ id: "p1", name: "api", renamed_session_names: { s1: "release" } } as unknown as Project,
|
||||||
|
];
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hi" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||||
|
expect(sendInput).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "api: release" }));
|
||||||
|
await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "hi"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a failed send rather than looking like it worked", async () => {
|
||||||
|
sessions = [session()];
|
||||||
|
sendInput.mockRejectedValueOnce(new Error("session closed"));
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hi" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||||
|
await waitFor(() => expect(pushToast).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing for an empty note", () => {
|
||||||
|
sessions = [session()];
|
||||||
|
render(<SendToAgentButton projectId="p1" body=" " />);
|
||||||
|
const button = screen.getByRole("button", { name: /send to agent/i });
|
||||||
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||||
|
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.keyDown(button, { key: "Enter" });
|
||||||
|
expect(sendInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the session menu upward when it sits at the foot of the dock", async () => {
|
||||||
|
sessions = [session({ id: "s1" }), session({ id: "s2" })];
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" dropUp />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||||
|
|
||||||
|
// Anchored to the button's top edge, not below it: the dock clips its own
|
||||||
|
// overflow, so a downward menu at the bottom edge is invisible.
|
||||||
|
await waitFor(() => expect(screen.getByRole("menu")).toHaveClass("bottom-full"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switching to the tab is not enough. When the dock is open beside the
|
||||||
|
// terminal it sends to, that terminal is already the active tab, so
|
||||||
|
// `setActiveTabKey` changes nothing and no effect re-runs — leaving focus on
|
||||||
|
// this button, one click short of the Enter the user came to press.
|
||||||
|
it("hands focus to the terminal so the next keystroke is Enter", async () => {
|
||||||
|
sessions = [session()];
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves focus alone when the send failed", async () => {
|
||||||
|
sessions = [session()];
|
||||||
|
sendInput.mockRejectedValueOnce(new Error("pty gone"));
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(pushToast).toHaveBeenCalled());
|
||||||
|
expect(requestTerminalFocus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the session picked from the menu, not the first one", async () => {
|
||||||
|
sessions = [session(), session({ id: "s2", sessionName: "review" })];
|
||||||
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
||||||
|
fireEvent.click(await screen.findByRole("menuitem", { name: "review" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestTerminalFocus).toHaveBeenCalledWith("s2"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
|
import { useAppState, terminalTabKey } from "../../store/appState";
|
||||||
|
import { toClaudePayload } from "../../lib/claudeInput";
|
||||||
|
import { sessionDisplayName } from "../../lib/sessionName";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
body: string;
|
||||||
|
/**
|
||||||
|
* Open the session menu above the button instead of below. The dock puts
|
||||||
|
* this at its foot, and the dock clips its own overflow, so a downward menu
|
||||||
|
* there is drawn outside the panel and never seen.
|
||||||
|
*/
|
||||||
|
dropUp?: boolean;
|
||||||
|
/** Fill the row. The dock's send bar is the width of the dock. */
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts a note into a running Claude session's prompt.
|
||||||
|
*
|
||||||
|
* Three behaviours by target count: none disables the button, one sends
|
||||||
|
* straight there, several ask which. It never guesses — the note goes to a
|
||||||
|
* session the user named, or to the only one there is.
|
||||||
|
*
|
||||||
|
* Only `claude` sessions are offered. A bash tab would receive ESC+CR as an
|
||||||
|
* unbound readline key and answer with a bell (see `lib/claudeInput.ts`).
|
||||||
|
*/
|
||||||
|
export default function SendToAgentButton({
|
||||||
|
projectId,
|
||||||
|
body,
|
||||||
|
dropUp = false,
|
||||||
|
fullWidth = false,
|
||||||
|
}: Props) {
|
||||||
|
const { sessions, sendInput } = useTerminal();
|
||||||
|
const { projects, setActiveTabKey, requestTerminalFocus, pushToast } =
|
||||||
|
useAppState(
|
||||||
|
useShallow((s) => ({
|
||||||
|
projects: s.projects,
|
||||||
|
setActiveTabKey: s.setActiveTabKey,
|
||||||
|
requestTerminalFocus: s.requestTerminalFocus,
|
||||||
|
pushToast: s.pushToast,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const targets = useMemo(
|
||||||
|
() =>
|
||||||
|
sessions.filter(
|
||||||
|
(s) => s.projectId === projectId && s.sessionType === "claude",
|
||||||
|
),
|
||||||
|
[sessions, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const project = projects.find((p) => p.id === projectId);
|
||||||
|
const hasBody = body.trim().length > 0;
|
||||||
|
const unavailable = targets.length === 0 || !hasBody;
|
||||||
|
|
||||||
|
// Same dismissal contract as `ui/OverflowMenu` and the tab context menu.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return;
|
||||||
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
if (!rootRef.current?.contains(e.target as Node)) setMenuOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDocClick);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDocClick);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [menuOpen]);
|
||||||
|
|
||||||
|
const send = useCallback(
|
||||||
|
async (sessionId: string) => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
try {
|
||||||
|
// No trailing CR: the note lands in the prompt and the user presses
|
||||||
|
// Enter. Newlines become ESC+CR so it arrives as one message rather
|
||||||
|
// than one prompt per line.
|
||||||
|
await sendInput(sessionId, toClaudePayload(body));
|
||||||
|
// A courtesy, not part of the send: if the tab cannot be focused the
|
||||||
|
// text still went.
|
||||||
|
setActiveTabKey(terminalTabKey(sessionId));
|
||||||
|
// Switching tabs is not the same as taking focus, and when the dock is
|
||||||
|
// open beside the terminal it just sent to, that tab is already the
|
||||||
|
// active one — so nothing above moves the caret off this button. The
|
||||||
|
// note is sitting in the prompt waiting for Enter; put the user there.
|
||||||
|
requestTerminalFocus(sessionId);
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not send the note to the agent",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[body, sendInput, setActiveTabKey, requestTerminalFocus, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
// The target is resolved at click time and pinned for the whole send, the
|
||||||
|
// hazard `useSTT` guards against by capturing its session at record start:
|
||||||
|
// the list can change while the request is in flight.
|
||||||
|
if (targets.length === 1) {
|
||||||
|
void send(targets[0].id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMenuOpen((open) => !open);
|
||||||
|
}, [targets, send]);
|
||||||
|
|
||||||
|
const title = !hasBody
|
||||||
|
? "Nothing to send — this note is empty"
|
||||||
|
: targets.length === 0
|
||||||
|
? "No running Claude session for this project"
|
||||||
|
: "Put this note into the agent's prompt (you press Enter)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={`relative ${fullWidth ? "block w-full" : "inline-block"}`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size={fullWidth ? "md" : "sm"}
|
||||||
|
className={fullWidth ? "w-full" : ""}
|
||||||
|
// Not `disabled`: every one of these reasons is information, and
|
||||||
|
// `disabled` takes the button — reason and all — out of the
|
||||||
|
// accessibility tree. `Button` guards the click for us.
|
||||||
|
unavailable={unavailable}
|
||||||
|
unavailableReason={title}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-haspopup={targets.length > 1 ? "menu" : undefined}
|
||||||
|
aria-expanded={targets.length > 1 ? menuOpen : undefined}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
Send to agent
|
||||||
|
</Button>
|
||||||
|
{menuOpen && targets.length > 1 && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className={`absolute right-0 z-40 min-w-[12rem] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs ${
|
||||||
|
dropUp ? "bottom-full mb-1" : "mt-1"
|
||||||
|
}`}
|
||||||
|
style={{ boxShadow: "var(--shadow-overlay)" }}
|
||||||
|
>
|
||||||
|
{targets.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => void send(s.id)}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
{sessionDisplayName(s, project)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { Note } from "../../lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draft text for the note being edited, committed when a field loses focus.
|
||||||
|
*
|
||||||
|
* This is the half the dock and the tab must never disagree on, so it lives
|
||||||
|
* here rather than in either layout. The two surfaces differ in how they show
|
||||||
|
* notes; they must not differ in when a keystroke becomes a save.
|
||||||
|
*
|
||||||
|
* The draft is "untouched" exactly while it still matches what was last copied
|
||||||
|
* out of the store, which is what lets an edit made on the *other* surface
|
||||||
|
* reach this one's editor without ever discarding half-typed text.
|
||||||
|
*/
|
||||||
|
export function useNoteDraft(
|
||||||
|
selected: Note | null,
|
||||||
|
saveNote: (note: Note) => Promise<unknown>,
|
||||||
|
) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const seeded = useRef<{ id: string | null; title: string; body: string }>({
|
||||||
|
id: null,
|
||||||
|
title: "",
|
||||||
|
body: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-seed on a change of note, and on a change to the *stored* text of the
|
||||||
|
// note already open — the second case is the dock and the tab showing one
|
||||||
|
// project at once.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected) {
|
||||||
|
seeded.current = { id: null, title: "", body: "" };
|
||||||
|
setTitle("");
|
||||||
|
setBody("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const untouched =
|
||||||
|
title === seeded.current.title && body === seeded.current.body;
|
||||||
|
if (seeded.current.id !== selected.id || untouched) {
|
||||||
|
seeded.current = {
|
||||||
|
id: selected.id,
|
||||||
|
title: selected.title,
|
||||||
|
body: selected.body,
|
||||||
|
};
|
||||||
|
setTitle(selected.title);
|
||||||
|
setBody(selected.body);
|
||||||
|
}
|
||||||
|
}, [selected?.id, selected?.title, selected?.body]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
if (!selected) return;
|
||||||
|
// Reading is not editing: clicking through notes must not rewrite the file.
|
||||||
|
if (title === selected.title && body === selected.body) return;
|
||||||
|
// Mark the draft as matching what was just committed, so the store update
|
||||||
|
// this save produces reads as "no change" rather than as a stale re-seed.
|
||||||
|
seeded.current = { id: selected.id, title, body };
|
||||||
|
void saveNote({ ...selected, title, body });
|
||||||
|
};
|
||||||
|
|
||||||
|
return { title, body, setTitle, setBody, commit };
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||||
|
import AddProjectDialog from "./AddProjectDialog";
|
||||||
|
|
||||||
|
const add = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useProjects", () => ({
|
||||||
|
useProjects: () => ({ add }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||||
|
open: vi.fn(async () => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** A promise whose resolution this test controls, so `loading` can be held open. */
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: (v: unknown) => void;
|
||||||
|
const promise = new Promise((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillValidForm() {
|
||||||
|
fireEvent.change(screen.getByLabelText("Project name"), {
|
||||||
|
target: { value: "my-project" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText("Folder 1 host path"), {
|
||||||
|
target: { value: "/home/user/my-project" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitButton() {
|
||||||
|
return screen.getByRole("button", { name: /Add Project|Adding/ });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AddProjectDialog", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the project with the name and folder entered", async () => {
|
||||||
|
add.mockResolvedValue({ id: "p1" });
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<AddProjectDialog onClose={onClose} />);
|
||||||
|
fillValidForm();
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(add).toHaveBeenCalledWith("my-project", [
|
||||||
|
{ host_path: "/home/user/my-project", mount_name: "my-project" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the submit button announced, and explains why, while adding", async () => {
|
||||||
|
const { promise, resolve } = deferred();
|
||||||
|
add.mockReturnValue(promise);
|
||||||
|
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||||
|
fillValidForm();
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
|
||||||
|
// Native `disabled` would remove the button from the accessibility tree
|
||||||
|
// exactly when it has something to say.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||||
|
);
|
||||||
|
expect(submitButton()).not.toBeDisabled();
|
||||||
|
expect(submitButton()).toHaveAccessibleDescription(/being added/i);
|
||||||
|
|
||||||
|
await act(async () => resolve({ id: "p1" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores clicks and Enter/Space on the submit button while adding", async () => {
|
||||||
|
const { promise, resolve } = deferred();
|
||||||
|
add.mockReturnValue(promise);
|
||||||
|
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||||
|
fillValidForm();
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
fireEvent.keyDown(submitButton(), { key: "Enter" });
|
||||||
|
fireEvent.keyDown(submitButton(), { key: " " });
|
||||||
|
expect(add).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => resolve({ id: "p1" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a form submit raised from elsewhere while adding", async () => {
|
||||||
|
const { promise, resolve } = deferred();
|
||||||
|
add.mockReturnValue(promise);
|
||||||
|
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||||
|
fillValidForm();
|
||||||
|
fireEvent.click(submitButton());
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enter in a text field submits a form regardless of the submit button's
|
||||||
|
// state, so the handler has to guard itself too.
|
||||||
|
// Modal portals to document.body, so the form is not under `container`.
|
||||||
|
const form = document.querySelector("form");
|
||||||
|
expect(form).not.toBeNull();
|
||||||
|
fireEvent.submit(form!);
|
||||||
|
expect(add).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => resolve({ id: "p1" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the submit button plainly available when idle", () => {
|
||||||
|
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||||
|
expect(submitButton()).not.toHaveAttribute("aria-disabled");
|
||||||
|
expect(submitButton()).toHaveAccessibleDescription("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,10 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
|
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
|
// The submit button is `aria-disabled` rather than `disabled` while an add
|
||||||
|
// is in flight, and Enter inside a text field submits the form without
|
||||||
|
// touching the button at all. Both routes end here, so the guard does too.
|
||||||
|
if (loading) return;
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError("Project name is required");
|
setError("Project name is required");
|
||||||
return;
|
return;
|
||||||
@@ -97,7 +101,19 @@ export default function AddProjectDialog({ onClose }: Props) {
|
|||||||
<Button size="md" variant="ghost" onClick={onClose}>
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}>
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
form={formId}
|
||||||
|
unavailable={loading}
|
||||||
|
unavailableReason="The project is being added. Wait for it to finish."
|
||||||
|
title={
|
||||||
|
loading
|
||||||
|
? "The project is being added. Wait for it to finish."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{loading ? "Adding…" : "Add Project"}
|
{loading ? "Adding…" : "Add Project"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -122,14 +122,6 @@ describe("ProjectRow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("only allows opening a terminal while the container runs", () => {
|
it("only allows opening a terminal while the container runs", () => {
|
||||||
const { unmount } = render(<ProjectRow project={baseProject} />);
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", {
|
|
||||||
name: "Open a Claude terminal for Test Project",
|
|
||||||
}),
|
|
||||||
).toBeDisabled();
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", {
|
screen.getByRole("button", {
|
||||||
@@ -139,6 +131,38 @@ describe("ProjectRow", () => {
|
|||||||
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the terminal button announced, and explains why, while stopped", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
const button = screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
});
|
||||||
|
// Native `disabled` would drop the button out of the accessibility tree
|
||||||
|
// and out of the tab order, taking the reason with it.
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||||
|
expect(button).toHaveAccessibleDescription(/is not running/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores clicks and Enter/Space on the terminal button while stopped", () => {
|
||||||
|
render(<ProjectRow project={baseProject} />);
|
||||||
|
const button = screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
});
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.keyDown(button, { key: "Enter" });
|
||||||
|
fireEvent.keyDown(button, { key: " " });
|
||||||
|
expect(mockOpenClaudeTerminal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops aria-disabled once the container is running", () => {
|
||||||
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||||
|
const button = screen.getByRole("button", {
|
||||||
|
name: "Open a Claude terminal for Test Project",
|
||||||
|
});
|
||||||
|
expect(button).not.toHaveAttribute("aria-disabled");
|
||||||
|
expect(button).not.toHaveAccessibleDescription(/is not running/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows container progress inline rather than in a blocking modal", () => {
|
it("shows container progress inline rather than in a blocking modal", () => {
|
||||||
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
||||||
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Project } from "../../lib/types";
|
|||||||
import { useAppState, homeTabKey } from "../../store/appState";
|
import { useAppState, homeTabKey } from "../../store/appState";
|
||||||
import { useProjectActions } from "../../hooks/useProjectActions";
|
import { useProjectActions } from "../../hooks/useProjectActions";
|
||||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||||
|
import { useUnavailable } from "../ui/unavailable";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -31,6 +32,15 @@ export default function ProjectRow({ project }: Props) {
|
|||||||
const isTransitioning =
|
const isTransitioning =
|
||||||
project.status === "starting" || project.status === "stopping";
|
project.status === "starting" || project.status === "stopping";
|
||||||
|
|
||||||
|
// A terminal needs a running container. Saying so out loud beats a `disabled`
|
||||||
|
// attribute that hides the button — and the reason — from anyone not using a
|
||||||
|
// mouse and eyes.
|
||||||
|
const terminal = useUnavailable({
|
||||||
|
unavailable: !isRunning,
|
||||||
|
reason: `${project.name} is not running. Start it to open a terminal.`,
|
||||||
|
onClick: () => openClaudeTerminal(),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
||||||
@@ -113,11 +123,14 @@ export default function ProjectRow({ project }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!isRunning}
|
{...terminal.controlProps}
|
||||||
onClick={() => openClaudeTerminal()}
|
title={
|
||||||
title={`Open a Claude terminal for ${project.name}`}
|
isRunning
|
||||||
|
? `Open a Claude terminal for ${project.name}`
|
||||||
|
: `${project.name} is not running. Start it to open a terminal.`
|
||||||
|
}
|
||||||
aria-label={`Open a Claude terminal for ${project.name}`}
|
aria-label={`Open a Claude terminal for ${project.name}`}
|
||||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
|
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent aria-disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-3.5 h-3.5"
|
className="w-3.5 h-3.5"
|
||||||
@@ -134,6 +147,7 @@ export default function ProjectRow({ project }: Props) {
|
|||||||
<line x1="13" y1="15" x2="17" y2="15" />
|
<line x1="13" y1="15" x2="17" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
{terminal.reasonNode}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Project } from "../../../lib/types";
|
||||||
|
import NotesPanel from "../../notes/NotesPanel";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes as a Project Home sub-tab.
|
||||||
|
*
|
||||||
|
* The same panel the dock shows. This is the roomy view for writing; the dock
|
||||||
|
* is the one that stays visible while the agent works.
|
||||||
|
*/
|
||||||
|
export default function NotesTab({ project }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="h-full min-h-0">
|
||||||
|
<NotesPanel projectId={project.id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import AutomationTab from "./AutomationTab";
|
|||||||
import ConfigTab from "./ConfigTab";
|
import ConfigTab from "./ConfigTab";
|
||||||
import FilesTab from "./FilesTab";
|
import FilesTab from "./FilesTab";
|
||||||
import BrowserTab from "./BrowserTab";
|
import BrowserTab from "./BrowserTab";
|
||||||
|
import NotesTab from "./NotesTab";
|
||||||
import { formatUptime } from "./format";
|
import { formatUptime } from "./format";
|
||||||
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
|
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ const TABS = [
|
|||||||
{ id: "config", label: "Config" },
|
{ id: "config", label: "Config" },
|
||||||
{ id: "files", label: "Files" },
|
{ id: "files", label: "Files" },
|
||||||
{ id: "browser", label: "Browser" },
|
{ id: "browser", label: "Browser" },
|
||||||
|
{ id: "notes", label: "Notes" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
|
||||||
@@ -255,6 +257,7 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
{tab === "browser" && (
|
{tab === "browser" && (
|
||||||
<BrowserTab project={project} active={active && tab === "browser"} />
|
<BrowserTab project={project} active={active && tab === "browser"} />
|
||||||
)}
|
)}
|
||||||
|
{tab === "notes" && <NotesTab project={project} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showMigration && (
|
{showMigration && (
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import ExportSettingsModal from "./ExportSettingsModal";
|
||||||
|
|
||||||
|
const exportSettings = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../lib/tauri-commands", () => ({
|
||||||
|
exportSettings: (password: string) => exportSettings(password),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function fillPasswords(password: string, confirm: string) {
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: password } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Confirm password"), { target: { value: confirm } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ExportSettingsModal", () => {
|
||||||
|
it("keeps the submit button disabled until the passwords are long enough and match", () => {
|
||||||
|
render(<ExportSettingsModal onClose={vi.fn()} />);
|
||||||
|
const submit = screen.getByRole("button", { name: /choose where to save/i });
|
||||||
|
expect(submit).toBeDisabled();
|
||||||
|
|
||||||
|
fillPasswords("short", "short");
|
||||||
|
expect(submit).toBeDisabled();
|
||||||
|
expect(screen.getByText(/use at least 8 characters/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
fillPasswords("longenoughpassword", "different");
|
||||||
|
expect(submit).toBeDisabled();
|
||||||
|
expect(screen.getByText(/don't match/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
fillPasswords("longenoughpassword", "longenoughpassword");
|
||||||
|
expect(submit).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports with the entered password and shows success", async () => {
|
||||||
|
exportSettings.mockResolvedValue(true);
|
||||||
|
render(<ExportSettingsModal onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
fillPasswords("longenoughpassword", "longenoughpassword");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(exportSettings).toHaveBeenCalledWith("longenoughpassword"));
|
||||||
|
await waitFor(() => expect(screen.getByText(/settings exported/i)).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes quietly when the save dialog is dismissed", async () => {
|
||||||
|
exportSettings.mockResolvedValue(false);
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<ExportSettingsModal onClose={onClose} />);
|
||||||
|
|
||||||
|
fillPasswords("longenoughpassword", "longenoughpassword");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByText(/settings exported/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error rather than closing when the export fails", async () => {
|
||||||
|
exportSettings.mockRejectedValue("Disk is full");
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<ExportSettingsModal onClose={onClose} />);
|
||||||
|
|
||||||
|
fillPasswords("longenoughpassword", "longenoughpassword");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Disk is full")).toBeInTheDocument());
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import Field, { inputClass } from "../ui/Field";
|
||||||
|
import { exportSettings } from "../../lib/tauri-commands";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LENGTH = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Password entry for exporting global settings. The save dialog itself opens
|
||||||
|
* from Rust once a password is confirmed here — see the doc comment on
|
||||||
|
* `commands::settings_export_commands` for why the host path never
|
||||||
|
* round-trips through this component.
|
||||||
|
*/
|
||||||
|
export default function ExportSettingsModal({ onClose }: Props) {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
const mismatch = confirmPassword.length > 0 && password !== confirmPassword;
|
||||||
|
const tooShort = password.length > 0 && password.length < MIN_PASSWORD_LENGTH;
|
||||||
|
const canSubmit = password.length >= MIN_PASSWORD_LENGTH && password === confirmPassword;
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const saved = await exportSettings(password);
|
||||||
|
if (saved) setDone(true);
|
||||||
|
// `false` means the save dialog was dismissed — close quietly, same as
|
||||||
|
// if the user had cancelled the modal itself.
|
||||||
|
else onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Export settings"
|
||||||
|
description="Saves your global settings and any stored credentials (a shared Claude login, gateway keys) to one encrypted file. Project-specific settings and container data are not included."
|
||||||
|
widthClassName="w-[28rem]"
|
||||||
|
dismissible={!busy}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
done ? (
|
||||||
|
<Button size="md" variant="primary" onClick={onClose}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void handleExport()}
|
||||||
|
disabled={!canSubmit || busy}
|
||||||
|
>
|
||||||
|
{busy ? "Exporting…" : "Choose where to save…"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{done ? (
|
||||||
|
<p className="text-[13px] text-[var(--success)]">
|
||||||
|
Settings exported. Keep the password somewhere safe — there is no way to recover
|
||||||
|
the file without it.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Password" hint={`At least ${MIN_PASSWORD_LENGTH} characters. You'll need this exact password to import the file later.`}>
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Confirm password">
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
{tooShort && (
|
||||||
|
<p className="text-xs text-[var(--error)]">
|
||||||
|
Use at least {MIN_PASSWORD_LENGTH} characters.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{mismatch && <p className="text-xs text-[var(--error)]">Passwords don't match.</p>}
|
||||||
|
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import ImportSettingsModal from "./ImportSettingsModal";
|
||||||
|
import type { AppSettings, SettingsImportOutcome, SettingsImportPreview } from "../../lib/types";
|
||||||
|
|
||||||
|
const previewSettingsImport = vi.fn();
|
||||||
|
const applySettingsImport = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../lib/tauri-commands", () => ({
|
||||||
|
previewSettingsImport: (password: string) => previewSettingsImport(password),
|
||||||
|
applySettingsImport: (password: string) => applySettingsImport(password),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const samplePreview: SettingsImportPreview = {
|
||||||
|
exported_at: "2026-08-27T00:00:00Z",
|
||||||
|
app_version: "0.4.14",
|
||||||
|
custom_env_var_count: 2,
|
||||||
|
gateway_model_count: 0,
|
||||||
|
has_claude_code_settings: false,
|
||||||
|
has_claude_oauth_token: true,
|
||||||
|
has_gateway_api_key: false,
|
||||||
|
has_gateway_master_key: false,
|
||||||
|
has_web_terminal_access_token: false,
|
||||||
|
enables_web_terminal: false,
|
||||||
|
ollama_base_url: null,
|
||||||
|
llamacpp_base_url: null,
|
||||||
|
openai_compatible_base_url: null,
|
||||||
|
gateway_api_base: null,
|
||||||
|
image_source: "registry",
|
||||||
|
custom_image_name: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome {
|
||||||
|
return { settings, secret_restore_warnings: secretRestoreWarnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ImportSettingsModal", () => {
|
||||||
|
it("keeps 'Choose file' disabled until a password is entered", () => {
|
||||||
|
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||||
|
expect(screen.getByRole("button", { name: /choose file/i })).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||||
|
expect(screen.getByRole("button", { name: /choose file/i })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the preview and confirms with the same password used to open it", async () => {
|
||||||
|
previewSettingsImport.mockResolvedValue(samplePreview);
|
||||||
|
applySettingsImport.mockResolvedValue(outcome({} as AppSettings));
|
||||||
|
const onImported = vi.fn();
|
||||||
|
render(<ImportSettingsModal onClose={vi.fn()} onImported={onImported} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(previewSettingsImport).toHaveBeenCalledWith("hunter2"));
|
||||||
|
expect(await screen.findByText(/2 global custom env vars/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/your shared claude login/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
|
||||||
|
await waitFor(() => expect(applySettingsImport).toHaveBeenCalledWith("hunter2"));
|
||||||
|
await waitFor(() => expect(onImported).toHaveBeenCalledWith({}));
|
||||||
|
expect(await screen.findByText(/settings imported/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a distinct warning when the import would enable the web terminal", async () => {
|
||||||
|
previewSettingsImport.mockResolvedValue({ ...samplePreview, enables_web_terminal: true });
|
||||||
|
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns about a custom Docker image every time, not just on change", async () => {
|
||||||
|
previewSettingsImport.mockResolvedValue({
|
||||||
|
...samplePreview,
|
||||||
|
image_source: "custom",
|
||||||
|
custom_image_name: "ghcr.io/attacker/triple-c:latest",
|
||||||
|
});
|
||||||
|
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/custom docker image: ghcr\.io\/attacker\/triple-c:latest/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a secret-restore warning alongside success rather than hiding it", async () => {
|
||||||
|
previewSettingsImport.mockResolvedValue(samplePreview);
|
||||||
|
applySettingsImport.mockResolvedValue(
|
||||||
|
outcome({} as AppSettings, ["Could not restore the gateway master key: keychain locked"]),
|
||||||
|
);
|
||||||
|
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||||
|
await screen.findByText(/2 global custom env vars/i);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
|
||||||
|
expect(await screen.findByText(/settings imported/i)).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText(/could not restore the gateway master key/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes quietly when the file picker is dismissed", async () => {
|
||||||
|
previewSettingsImport.mockResolvedValue(null);
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<ImportSettingsModal onClose={onClose} onImported={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error when the password is wrong rather than a blank preview", async () => {
|
||||||
|
previewSettingsImport.mockRejectedValue("Wrong password, or the file is corrupted.");
|
||||||
|
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "wrong" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/wrong password, or the file is corrupted/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error if applying the import fails, without claiming success", async () => {
|
||||||
|
previewSettingsImport.mockResolvedValue(samplePreview);
|
||||||
|
applySettingsImport.mockRejectedValue("Keychain write failed");
|
||||||
|
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||||
|
await screen.findByText(/2 global custom env vars/i);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
|
||||||
|
expect(await screen.findByText("Keychain write failed")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/settings imported/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import Field, { inputClass } from "../ui/Field";
|
||||||
|
import { applySettingsImport, previewSettingsImport } from "../../lib/tauri-commands";
|
||||||
|
import { describeImport, describeImportWarnings } from "../../lib/settingsImportPreview";
|
||||||
|
import type { AppSettings, SettingsImportPreview } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
/** Fired once the import is actually applied, so the caller can refresh
|
||||||
|
* whatever reads settings from the store. */
|
||||||
|
onImported: (settings: AppSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two phases: enter the password and pick the file (backend resolves the
|
||||||
|
* file dialog itself — see `commands::settings_export_commands`), then
|
||||||
|
* confirm a preview before anything is actually applied. The same password
|
||||||
|
* is reused for the second call rather than asking again; nothing about
|
||||||
|
* that call needs a fresh secret; the backend just doesn't cache the
|
||||||
|
* *decrypted payload* between the two.
|
||||||
|
*/
|
||||||
|
export default function ImportSettingsModal({ onClose, onImported }: Props) {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [preview, setPreview] = useState<SettingsImportPreview | null>(null);
|
||||||
|
const [applied, setApplied] = useState(false);
|
||||||
|
const [secretWarnings, setSecretWarnings] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const handleChooseFile = async () => {
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await previewSettingsImport(password);
|
||||||
|
if (result) setPreview(result);
|
||||||
|
else onClose(); // File picker dismissed.
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const outcome = await applySettingsImport(password);
|
||||||
|
setApplied(true);
|
||||||
|
setSecretWarnings(outcome.secret_restore_warnings);
|
||||||
|
onImported(outcome.settings);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Import settings"
|
||||||
|
description={
|
||||||
|
preview
|
||||||
|
? "Review what this file will change before applying it."
|
||||||
|
: "Choose a Triple-C settings export and enter the password it was created with."
|
||||||
|
}
|
||||||
|
widthClassName="w-[28rem]"
|
||||||
|
dismissible={!busy}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
applied ? (
|
||||||
|
<Button size="md" variant="primary" onClick={onClose}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
) : preview ? (
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button size="md" variant="primary" onClick={() => void handleConfirm()} disabled={busy}>
|
||||||
|
{busy ? "Importing…" : "Import"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void handleChooseFile()}
|
||||||
|
disabled={!password || busy}
|
||||||
|
>
|
||||||
|
{busy ? "Opening…" : "Choose file…"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{applied ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[13px] text-[var(--success)]">Settings imported.</p>
|
||||||
|
{secretWarnings.map((warning) => (
|
||||||
|
<p
|
||||||
|
key={warning}
|
||||||
|
className="px-2.5 py-2 text-xs text-[var(--error)] bg-[var(--error-muted)] border border-[var(--error)]/40 rounded-[var(--radius-control)] leading-snug"
|
||||||
|
>
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : preview ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Exported {new Date(preview.exported_at).toLocaleString()} from Triple-C{" "}
|
||||||
|
{preview.app_version}.
|
||||||
|
</p>
|
||||||
|
{/* Warnings render before the replace list, deliberately: the list
|
||||||
|
* below can run long, and the one thing here that most needs to
|
||||||
|
* stay above the fold while scrolling is "this turns on a
|
||||||
|
* network-listening service" or "this runs a different image" —
|
||||||
|
* not a bullet buried among ordinary settings. */}
|
||||||
|
{describeImportWarnings(preview).map((warning) => (
|
||||||
|
<p
|
||||||
|
key={warning}
|
||||||
|
className="px-2.5 py-2 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/40 rounded-[var(--radius-control)] leading-snug break-all"
|
||||||
|
>
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<p className="text-[13px] font-medium text-[var(--text-primary)]">This will replace:</p>
|
||||||
|
<ul className="mt-1 list-disc pl-4 text-[13px] text-[var(--text-secondary)] space-y-0.5">
|
||||||
|
{describeImport(preview).map((item) => (
|
||||||
|
<li key={item} className="break-all">
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Password">
|
||||||
|
{(id) => (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,13 +15,17 @@ import type { EnvVar } from "../../lib/types";
|
|||||||
import Tooltip from "../ui/Tooltip";
|
import Tooltip from "../ui/Tooltip";
|
||||||
import AccordionSection from "../ui/AccordionSection";
|
import AccordionSection from "../ui/AccordionSection";
|
||||||
import Toggle from "../ui/Toggle";
|
import Toggle from "../ui/Toggle";
|
||||||
|
import SegmentedControl from "../ui/SegmentedControl";
|
||||||
|
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
||||||
import WebTerminalSettings from "./WebTerminalSettings";
|
import WebTerminalSettings from "./WebTerminalSettings";
|
||||||
import SttSettings from "./SttSettings";
|
import SttSettings from "./SttSettings";
|
||||||
import SharedAuthSettings from "./SharedAuthSettings";
|
import SharedAuthSettings from "./SharedAuthSettings";
|
||||||
import CertificateSettings from "./CertificateSettings";
|
import CertificateSettings from "./CertificateSettings";
|
||||||
|
import ExportSettingsModal from "./ExportSettingsModal";
|
||||||
|
import ImportSettingsModal from "./ImportSettingsModal";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
const { appSettings, saveSettings } = useSettings();
|
const { appSettings, saveSettings, setAppSettings } = useSettings();
|
||||||
const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates();
|
const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates();
|
||||||
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
||||||
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
||||||
@@ -33,6 +37,8 @@ export default function SettingsPanel() {
|
|||||||
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
|
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
|
||||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
|
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
|
||||||
// Sync local state when appSettings change
|
// Sync local state when appSettings change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,6 +69,14 @@ export default function SettingsPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGpuRenderingChange = async (value: "auto" | "on" | "off") => {
|
||||||
|
if (!appSettings) return;
|
||||||
|
await saveSettings({
|
||||||
|
...appSettings,
|
||||||
|
terminal_gpu_rendering: value === "auto" ? null : value === "on",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleAutoCheckToggle = async () => {
|
const handleAutoCheckToggle = async () => {
|
||||||
if (!appSettings) return;
|
if (!appSettings) return;
|
||||||
await saveSettings({ ...appSettings, auto_check_updates: !appSettings.auto_check_updates });
|
await saveSettings({ ...appSettings, auto_check_updates: !appSettings.auto_check_updates });
|
||||||
@@ -238,6 +252,45 @@ export default function SettingsPanel() {
|
|||||||
<SttSettings />
|
<SttSettings />
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
|
|
||||||
|
<AccordionSection id="terminal" title="Terminal" defaultOpen={false}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs text-[var(--text-secondary)]">GPU rendering</label>
|
||||||
|
<SegmentedControl
|
||||||
|
label="Terminal GPU rendering"
|
||||||
|
value={
|
||||||
|
appSettings?.terminal_gpu_rendering == null
|
||||||
|
? "auto"
|
||||||
|
: appSettings.terminal_gpu_rendering
|
||||||
|
? "on"
|
||||||
|
: "off"
|
||||||
|
}
|
||||||
|
onChange={handleGpuRenderingChange}
|
||||||
|
segments={[
|
||||||
|
{
|
||||||
|
value: "auto",
|
||||||
|
label: "Auto",
|
||||||
|
hint: resolveTerminalGpuRendering(null, navigator.userAgent)
|
||||||
|
? "On for this platform."
|
||||||
|
: "Off on Linux — the DMA-BUF workaround leaves WebGL on software rendering, which is slower than the canvas renderer.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "on",
|
||||||
|
label: "On",
|
||||||
|
hint: "Always load the WebGL renderer.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "off",
|
||||||
|
label: "Off",
|
||||||
|
hint: "Always use xterm's canvas renderer. Try this if typing feels laggy.",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Takes effect when a terminal tab is next switched to.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AccordionSection>
|
||||||
|
|
||||||
<AccordionSection id="updates" title="Updates" defaultOpen={false}>
|
<AccordionSection id="updates" title="Updates" defaultOpen={false}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{appVersion && (
|
{appVersion && (
|
||||||
@@ -269,6 +322,39 @@ export default function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
|
|
||||||
|
<AccordionSection id="backup" title="Backup" defaultOpen={false}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
Export your global settings and stored credentials (a shared Claude login,
|
||||||
|
gateway keys) to one password-encrypted file, or restore them on a new machine.
|
||||||
|
Project-specific settings and container data are never included.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
Export settings…
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportModal(true)}
|
||||||
|
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
Import settings…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionSection>
|
||||||
|
|
||||||
|
{showExportModal && <ExportSettingsModal onClose={() => setShowExportModal(false)} />}
|
||||||
|
|
||||||
|
{showImportModal && (
|
||||||
|
<ImportSettingsModal
|
||||||
|
onClose={() => setShowImportModal(false)}
|
||||||
|
onImported={(settings) => setAppSettings(settings)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showInstructionsModal && (
|
{showInstructionsModal && (
|
||||||
<ClaudeInstructionsModal
|
<ClaudeInstructionsModal
|
||||||
instructions={globalInstructions}
|
instructions={globalInstructions}
|
||||||
|
|||||||
@@ -538,3 +538,59 @@ describe("TerminalView — reaching the URL prompt without a mouse", () => {
|
|||||||
expect(document.activeElement).toBe(before);
|
expect(document.activeElement).toBe(before);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("TerminalView — focus on request", () => {
|
||||||
|
/** Mount, then deliberately give focus away, so what the assertions below
|
||||||
|
* observe is the *request* taking effect and never the focus `active`
|
||||||
|
* already grants on mount. That distinction is the whole point: the notes
|
||||||
|
* dock sends to a terminal whose tab is already active, where nothing
|
||||||
|
* changes and no `active` effect re-runs. */
|
||||||
|
async function mountAndBlur() {
|
||||||
|
const view = mountSession("claude");
|
||||||
|
await act(async () => {});
|
||||||
|
const elsewhere = document.createElement("button");
|
||||||
|
document.body.appendChild(elsewhere);
|
||||||
|
elsewhere.focus();
|
||||||
|
expect(document.activeElement).toBe(elsewhere);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("focuses the terminal named by the request", async () => {
|
||||||
|
const view = await mountAndBlur();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(helperTextarea(view.container));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a request meant for another session", async () => {
|
||||||
|
const view = await mountAndBlur();
|
||||||
|
const before = document.activeElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s2");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(before);
|
||||||
|
expect(document.activeElement).not.toBe(helperTextarea(view.container));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the request, so a second send focuses again", async () => {
|
||||||
|
const view = await mountAndBlur();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
});
|
||||||
|
expect(useAppState.getState().pendingTerminalFocus).toBeNull();
|
||||||
|
|
||||||
|
const elsewhere = document.querySelector("button");
|
||||||
|
(elsewhere as HTMLButtonElement).focus();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
});
|
||||||
|
expect(document.activeElement).toBe(helperTextarea(view.container));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
|
import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput";
|
||||||
import {
|
import {
|
||||||
awsSsoRefresh,
|
awsSsoRefresh,
|
||||||
openPageInContainerBrowser,
|
openPageInContainerBrowser,
|
||||||
@@ -28,6 +29,7 @@ import UrlToast, {
|
|||||||
URL_TOAST_SHORTCUT,
|
URL_TOAST_SHORTCUT,
|
||||||
} from "./UrlToast";
|
} from "./UrlToast";
|
||||||
import { trimSelection } from "./trimSelection";
|
import { trimSelection } from "./trimSelection";
|
||||||
|
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
||||||
import TerminalContextMenu from "./TerminalContextMenu";
|
import TerminalContextMenu from "./TerminalContextMenu";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -95,6 +97,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const webglRef = useRef<WebglAddon | null>(null);
|
const webglRef = useRef<WebglAddon | null>(null);
|
||||||
const detectorRef = useRef<UrlDetector | null>(null);
|
const detectorRef = useRef<UrlDetector | null>(null);
|
||||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||||
|
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
||||||
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
||||||
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
|
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
|
||||||
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
|
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
|
||||||
@@ -413,7 +416,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
!event.isComposing &&
|
!event.isComposing &&
|
||||||
sessionTypeRef.current === "claude"
|
sessionTypeRef.current === "claude"
|
||||||
) {
|
) {
|
||||||
sendInput(sessionId, "\x1b\r");
|
sendInput(sessionId, CLAUDE_SOFT_NEWLINE);
|
||||||
// **`preventDefault()` is what stops the submit, not the `return false`.**
|
// **`preventDefault()` is what stops the submit, not the `return false`.**
|
||||||
//
|
//
|
||||||
// xterm's `_keyDown` returns the instant a custom handler says `false`
|
// xterm's `_keyDown` returns the instant a custom handler says `false`
|
||||||
@@ -491,7 +494,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
// Handle user input -> backend
|
// Handle user input -> backend
|
||||||
const inputDisposable = term.onData((data) => {
|
const inputDisposable = term.onData((data) => {
|
||||||
sendInput(sessionId, data);
|
// Ordered and coalesced by the queue in `useTerminal`; a rejection here
|
||||||
|
// means the session is gone, which the exit listener already reports.
|
||||||
|
sendInput(sessionId, data).catch((e) =>
|
||||||
|
console.error("Failed to send terminal input:", e)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
|
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
|
||||||
@@ -684,7 +691,16 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const term = termRef.current;
|
const term = termRef.current;
|
||||||
if (!term) return;
|
if (!term) return;
|
||||||
|
|
||||||
if (active) {
|
// Auto on macOS/Windows, off on Linux, overridable either way — see
|
||||||
|
// `resolveTerminalGpuRendering`. Loading the addon under a software-GL
|
||||||
|
// WebKitGTK is slower than xterm's canvas renderer, not faster.
|
||||||
|
const useGpu = resolveTerminalGpuRendering(gpuRenderingSetting, navigator.userAgent);
|
||||||
|
|
||||||
|
// The renderer and the activation work are independent: a terminal with
|
||||||
|
// GPU rendering switched off still has to fit and take focus when its tab
|
||||||
|
// becomes active. Keeping these in one branch made "GPU off" silently mean
|
||||||
|
// "never re-fit, never focus".
|
||||||
|
if (active && useGpu) {
|
||||||
// Attach WebGL renderer
|
// Attach WebGL renderer
|
||||||
if (!webglRef.current) {
|
if (!webglRef.current) {
|
||||||
try {
|
try {
|
||||||
@@ -699,19 +715,36 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
// WebGL not available, canvas renderer is fine
|
// WebGL not available, canvas renderer is fine
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (webglRef.current) {
|
||||||
|
// Release the context — for inactive terminals, and when the setting
|
||||||
|
// turns GPU rendering off while this terminal is on screen.
|
||||||
|
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
||||||
|
webglRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active) {
|
||||||
fitRef.current?.fit();
|
fitRef.current?.fit();
|
||||||
if (autoFollowRef.current) {
|
if (autoFollowRef.current) {
|
||||||
term.scrollToBottom();
|
term.scrollToBottom();
|
||||||
}
|
}
|
||||||
term.focus();
|
term.focus();
|
||||||
} else {
|
|
||||||
// Release WebGL context for inactive terminals
|
|
||||||
if (webglRef.current) {
|
|
||||||
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
|
||||||
webglRef.current = null;
|
|
||||||
}
|
}
|
||||||
}
|
}, [active, gpuRenderingSetting]);
|
||||||
}, [active]);
|
|
||||||
|
// Focus on demand, for the caller that cannot rely on the effect above.
|
||||||
|
// That one keys off `active`, so it covers switching *to* a terminal and
|
||||||
|
// nothing else — and the notes dock sends to the terminal already on screen,
|
||||||
|
// where `active` never changes. Consumed once and cleared, so asking twice
|
||||||
|
// for the same terminal works.
|
||||||
|
const pendingTerminalFocus = useAppState((s) => s.pendingTerminalFocus);
|
||||||
|
const clearPendingTerminalFocus = useAppState(
|
||||||
|
(s) => s.clearPendingTerminalFocus,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingTerminalFocus !== sessionId) return;
|
||||||
|
termRef.current?.focus();
|
||||||
|
clearPendingTerminalFocus();
|
||||||
|
}, [pendingTerminalFocus, sessionId, clearPendingTerminalFocus]);
|
||||||
|
|
||||||
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
|
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
|
||||||
// A keyboard user who has just jumped into the toast is mid-decision, and
|
// A keyboard user who has just jumped into the toast is mid-decision, and
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import Button from "./Button";
|
||||||
|
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const onKeyDown = vi.fn();
|
||||||
|
|
||||||
|
describe("Button", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still supports the native disabled attribute", () => {
|
||||||
|
render(
|
||||||
|
<Button disabled onClick={onClick}>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays in the accessibility tree when unavailable, and says why", () => {
|
||||||
|
render(
|
||||||
|
<Button unavailable unavailableReason="Stop the container first.">
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: "Save" });
|
||||||
|
expect(button).not.toBeDisabled();
|
||||||
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||||
|
expect(button).toHaveAccessibleDescription("Stop the container first.");
|
||||||
|
// The reason is a description, not part of the name.
|
||||||
|
expect(button).toHaveAccessibleName("Save");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards clicks and Enter/Space while unavailable", () => {
|
||||||
|
render(
|
||||||
|
<Button unavailable unavailableReason="Stop the container first." onClick={onClick}>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: "Save" });
|
||||||
|
fireEvent.click(button);
|
||||||
|
fireEvent.keyDown(button, { key: "Enter" });
|
||||||
|
fireEvent.keyDown(button, { key: " " });
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still forwards keys that are not activation keys", () => {
|
||||||
|
render(
|
||||||
|
<Button
|
||||||
|
unavailable
|
||||||
|
unavailableReason="Stop the container first."
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(screen.getByRole("button", { name: "Save" }), {
|
||||||
|
key: "Escape",
|
||||||
|
});
|
||||||
|
expect(onKeyDown).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behaves like an ordinary button when available", () => {
|
||||||
|
render(
|
||||||
|
<Button unavailable={false} unavailableReason="Stop the container first." onClick={onClick}>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: "Save" });
|
||||||
|
expect(button).not.toHaveAttribute("aria-disabled");
|
||||||
|
expect(button).toHaveAccessibleDescription("");
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
|
import { useUnavailable } from "./unavailable";
|
||||||
|
|
||||||
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||||
export type ButtonSize = "sm" | "md";
|
export type ButtonSize = "sm" | "md";
|
||||||
@@ -7,22 +8,37 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Unavailable, but still announced. Renders `aria-disabled` and wires
|
||||||
|
* `unavailableReason` to `aria-describedby` instead of using the native
|
||||||
|
* `disabled` attribute, which would take the button out of the tab order and
|
||||||
|
* out of the accessibility tree — reason and all. Clicks and Enter/Space are
|
||||||
|
* guarded for you. Prefer this over `disabled` whenever there is a reason
|
||||||
|
* worth telling the user.
|
||||||
|
*/
|
||||||
|
unavailable?: boolean;
|
||||||
|
/** Why the button cannot be used. Required for `unavailable` to say anything. */
|
||||||
|
unavailableReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real buttons with visible bounds and a ≥24px hit target.
|
* Real buttons with visible bounds and a ≥24px hit target.
|
||||||
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
|
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
|
||||||
* `--accent` stays reserved for foreground/link use.
|
* `--accent` stays reserved for foreground/link use.
|
||||||
|
*
|
||||||
|
* The `aria-disabled:` class mirrors below exist because Tailwind's
|
||||||
|
* `disabled:` variant only matches the native attribute, which `unavailable`
|
||||||
|
* deliberately does not set. Keep the two lists in step.
|
||||||
*/
|
*/
|
||||||
const VARIANTS: Record<ButtonVariant, string> = {
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
primary:
|
primary:
|
||||||
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)]",
|
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] aria-disabled:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)]",
|
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
|
||||||
danger:
|
danger:
|
||||||
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent",
|
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-transparent",
|
||||||
ghost:
|
ghost:
|
||||||
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent",
|
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIZES: Record<ButtonSize, string> = {
|
const SIZES: Record<ButtonSize, string> = {
|
||||||
@@ -35,16 +51,30 @@ export default function Button({
|
|||||||
size = "sm",
|
size = "sm",
|
||||||
className = "",
|
className = "",
|
||||||
type = "button",
|
type = "button",
|
||||||
|
unavailable = false,
|
||||||
|
unavailableReason = "",
|
||||||
children,
|
children,
|
||||||
...rest
|
...rest
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { controlProps, reasonNode } = useUnavailable({
|
||||||
|
unavailable,
|
||||||
|
reason: unavailableReason,
|
||||||
|
onClick: rest.onClick,
|
||||||
|
onKeyDown: rest.onKeyDown,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type={type}
|
type={type}
|
||||||
{...rest}
|
{...rest}
|
||||||
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
{...controlProps}
|
||||||
|
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed aria-disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
{/* Outside the button: inside, the reason would join its accessible name. */}
|
||||||
|
{reasonNode}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
useId,
|
||||||
|
type KeyboardEventHandler,
|
||||||
|
type MouseEventHandler,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
/** Keys a native `<button>` turns into a click. */
|
||||||
|
const ACTIVATION_KEYS = new Set([" ", "Spacebar", "Enter"]);
|
||||||
|
|
||||||
|
export interface UnavailableControlProps {
|
||||||
|
"aria-disabled"?: true;
|
||||||
|
"aria-describedby"?: string;
|
||||||
|
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnavailableControl {
|
||||||
|
/** Spread onto the control. Carries the guarded handlers. */
|
||||||
|
controlProps: UnavailableControlProps;
|
||||||
|
/**
|
||||||
|
* Render as a *sibling* of the control — inside it the reason would be
|
||||||
|
* appended to the accessible name instead of the description.
|
||||||
|
*/
|
||||||
|
reasonNode: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a control unavailable without hiding it from assistive technology.
|
||||||
|
*
|
||||||
|
* `disabled` takes an element out of the tab order *and* out of the
|
||||||
|
* accessibility tree, so the `title` explaining why it cannot be used is
|
||||||
|
* announced to nobody and shown only to a sighted user with a mouse. That is
|
||||||
|
* backwards: the people who most need the reason are the ones who never get
|
||||||
|
* it. `aria-disabled` keeps the control focusable and announced, and
|
||||||
|
* `aria-describedby` hands over the reason.
|
||||||
|
*
|
||||||
|
* The catch is that `aria-disabled` is advisory — it does not block clicks or
|
||||||
|
* Enter/Space the way `disabled` does. This hook therefore returns the guards
|
||||||
|
* along with the attributes, so a call site cannot take the announcement
|
||||||
|
* without the guard. Handlers that a form can reach without going through the
|
||||||
|
* control (Enter inside a text field submits the form) still have to guard
|
||||||
|
* themselves.
|
||||||
|
*/
|
||||||
|
export function useUnavailable({
|
||||||
|
unavailable,
|
||||||
|
reason,
|
||||||
|
onClick,
|
||||||
|
onKeyDown,
|
||||||
|
}: {
|
||||||
|
unavailable: boolean;
|
||||||
|
reason: string;
|
||||||
|
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
||||||
|
}): UnavailableControl {
|
||||||
|
const reasonId = `${useId()}unavailable`;
|
||||||
|
|
||||||
|
if (!unavailable) {
|
||||||
|
return { controlProps: { onClick, onKeyDown }, reasonNode: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
controlProps: {
|
||||||
|
"aria-disabled": true,
|
||||||
|
"aria-describedby": reasonId,
|
||||||
|
onClick: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
onKeyDown: (e) => {
|
||||||
|
if (!ACTIVATION_KEYS.has(e.key)) {
|
||||||
|
onKeyDown?.(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Suppress the default action before it can become a click, submit a
|
||||||
|
// form, or scroll the page.
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reasonNode: (
|
||||||
|
<span id={reasonId} className="sr-only">
|
||||||
|
{reason}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||||
|
import { useNotes } from "./useNotes";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
|
import type { Note } from "../lib/types";
|
||||||
|
|
||||||
|
const listNotes = vi.fn();
|
||||||
|
const saveNote = vi.fn();
|
||||||
|
const deleteNote = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../lib/tauri-commands", () => ({
|
||||||
|
listNotes: (p: string) => listNotes(p),
|
||||||
|
saveNote: (p: string, n: Note) => saveNote(p, n),
|
||||||
|
deleteNote: (p: string, id: string) => deleteNote(p, id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const note = (over: Partial<Note> = {}): Note => ({
|
||||||
|
id: "n1",
|
||||||
|
title: "Deploy",
|
||||||
|
body: "one\ntwo",
|
||||||
|
pinned: false,
|
||||||
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
|
updated_at: "2026-09-01T00:00:00Z",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The toasts the hook pushed. The store is real, so this is what a user sees. */
|
||||||
|
const toasts = () => useAppState.getState().toasts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stand-in for the Rust store: one list per project, upsert and delete
|
||||||
|
* applied to it, `list_notes` reading it back. Several of these tests are about
|
||||||
|
* what the *list* looks like after a sequence of writes, which a per-call
|
||||||
|
* `mockResolvedValueOnce` cannot express.
|
||||||
|
*/
|
||||||
|
function fakeBackend(initial: Record<string, Note[]> = {}) {
|
||||||
|
const files: Record<string, Note[]> = { ...initial };
|
||||||
|
listNotes.mockImplementation(async (p: string) => [...(files[p] ?? [])]);
|
||||||
|
saveNote.mockImplementation(async (p: string, n: Note) => {
|
||||||
|
const list = files[p] ?? (files[p] = []);
|
||||||
|
const at = list.findIndex((x) => x.id === n.id);
|
||||||
|
if (at === -1) list.unshift(n);
|
||||||
|
else list[at] = n;
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
deleteNote.mockImplementation(async (p: string, id: string) => {
|
||||||
|
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
|
||||||
|
});
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// The cache is shared app state now, so it has to be reset like any other.
|
||||||
|
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
|
||||||
|
listNotes.mockResolvedValue([note()]);
|
||||||
|
saveNote.mockImplementation(async (_p: string, n: Note) => n);
|
||||||
|
deleteNote.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useNotes", () => {
|
||||||
|
it("loads a project's notes on mount", async () => {
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(listNotes).toHaveBeenCalledWith("p1");
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a failed save instead of swallowing it", async () => {
|
||||||
|
// Silent save failure is data loss: the user sees their text on screen and
|
||||||
|
// believes it is stored. Same reason `useSaveState` exists.
|
||||||
|
saveNote.mockRejectedValueOnce(new Error("disk full"));
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
let ok: boolean | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
ok = await result.current.saveNote(note({ body: "edited" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(result.current.saveState.status).toBe("failed");
|
||||||
|
expect(toasts()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the saved note in place rather than appending", async () => {
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
// Mock the re-read to return the edited note
|
||||||
|
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveNote(note({ body: "edited" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
expect(result.current.notes[0].body).toBe("edited");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a deleted note from the list", async () => {
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.deleteNote("n1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteNote).toHaveBeenCalledWith("p1", "n1");
|
||||||
|
expect(result.current.notes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not load anything for an empty project id", async () => {
|
||||||
|
// The dock renders with no project selected; it must not fire a command
|
||||||
|
// for the empty string.
|
||||||
|
renderHook(() => useNotes(""));
|
||||||
|
await waitFor(() => expect(listNotes).not.toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the first project's notes when the projectId changes to another non-empty value", async () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||||
|
{ initialProps: { projectId: "p1" } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
|
||||||
|
// Change to a different project before the new fetch resolves
|
||||||
|
listNotes.mockImplementationOnce(() => new Promise(() => {})); // never resolves
|
||||||
|
rerender({ projectId: "p2" });
|
||||||
|
|
||||||
|
// The old notes should be cleared immediately
|
||||||
|
expect(result.current.notes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves no stale notes on screen when a load fails", async () => {
|
||||||
|
listNotes.mockResolvedValueOnce([note()]);
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||||
|
{ initialProps: { projectId: "p1" } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
|
||||||
|
// Switch to a project whose load fails
|
||||||
|
listNotes.mockRejectedValueOnce(new Error("load failed"));
|
||||||
|
rerender({ projectId: "p2" });
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.notes).toHaveLength(0);
|
||||||
|
expect(toasts()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends with the list the backend returned when saving a new note", async () => {
|
||||||
|
// Initially one note
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
|
||||||
|
// Saving a new note (not in the current list) re-reads and ends with the backend's list
|
||||||
|
const newNote = note({ id: "n2", title: "New" });
|
||||||
|
listNotes.mockResolvedValueOnce([newNote, note()]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveNote(newNote);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.notes).toHaveLength(2);
|
||||||
|
expect(result.current.notes[0].id).toBe("n2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-reads the list after a successful save rather than patching in place", async () => {
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
const callCountBefore = listNotes.mock.calls.length;
|
||||||
|
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveNote(note({ body: "edited" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// listNotes should be called again after the save
|
||||||
|
expect(listNotes).toHaveBeenCalledTimes(callCountBefore + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite the new project's notes when a stale save resolves", async () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||||
|
{ initialProps: { projectId: "p1" } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.notes[0].id).toBe("n1");
|
||||||
|
|
||||||
|
// Start a save for p1 that hangs
|
||||||
|
let resolveSave: ((note: Note) => void) | undefined;
|
||||||
|
saveNote.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveSave = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let savePromise: Promise<boolean> | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
savePromise = result.current.saveNote(note({ id: "n1" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to p2 while the save is in flight
|
||||||
|
listNotes.mockResolvedValueOnce([note({ id: "n2", title: "Project 2 Note" })]);
|
||||||
|
rerender({ projectId: "p2" });
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
// Now p2's note should be displayed
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
expect(result.current.notes[0].id).toBe("n2");
|
||||||
|
|
||||||
|
// Resolve the stale p1 save
|
||||||
|
listNotes.mockResolvedValueOnce([note({ id: "n1", body: "edited" })]);
|
||||||
|
await act(async () => {
|
||||||
|
resolveSave?.(note({ id: "n1", body: "edited" }));
|
||||||
|
await savePromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
// p2's note should still be displayed, not p1's
|
||||||
|
expect(result.current.notes).toHaveLength(1);
|
||||||
|
expect(result.current.notes[0].id).toBe("n2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the notes already on screen when a refresh fails", async () => {
|
||||||
|
// The second surface mounting for a project is a refresh behind a list the
|
||||||
|
// user is already reading. One shared cache means a failed refresh would
|
||||||
|
// otherwise blank both panels.
|
||||||
|
fakeBackend({ p1: [note()] });
|
||||||
|
const tab = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
listNotes.mockRejectedValueOnce(new Error("read failed"));
|
||||||
|
const dock = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(toasts()).toHaveLength(1));
|
||||||
|
|
||||||
|
expect(tab.result.current.notes).toHaveLength(1);
|
||||||
|
expect(dock.result.current.notes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not report the old project's save on the new project's indicator", async () => {
|
||||||
|
// The indicator is per-panel and reads "Saved ✓". Firing it after a switch
|
||||||
|
// tells the user their *current* project was written when it was not.
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ projectId }: { projectId: string }) => useNotes(projectId),
|
||||||
|
{ initialProps: { projectId: "p1" } },
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
let resolveSave: ((n: Note) => void) | undefined;
|
||||||
|
saveNote.mockImplementationOnce(
|
||||||
|
() => new Promise((resolve) => (resolveSave = resolve)),
|
||||||
|
);
|
||||||
|
let savePromise: Promise<boolean> | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
savePromise = result.current.saveNote(note({ body: "edited" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ projectId: "p2" });
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveSave?.(note({ body: "edited" }));
|
||||||
|
await savePromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.saveState.status).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports a save on the indicator of the project it was made for", async () => {
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveNote(note({ body: "edited" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.saveState.status).toBe("saved");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serialises a project's writes so an edit cannot be re-inserted after its delete", async () => {
|
||||||
|
// Clicking Delete while the textarea has focus fires blur first, so a save
|
||||||
|
// and a delete go out back to back. The Rust write lock stops them
|
||||||
|
// interleaving but does not order them: a delete that wins the lock is
|
||||||
|
// undone by the upsert behind it, and the note comes back on next load.
|
||||||
|
const files = fakeBackend({ p1: [note()] });
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
const order: string[] = [];
|
||||||
|
saveNote.mockImplementationOnce(async (p: string, n: Note) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
order.push("save");
|
||||||
|
files[p] = [n];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
deleteNote.mockImplementationOnce(async (p: string, id: string) => {
|
||||||
|
order.push("delete");
|
||||||
|
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const save = result.current.saveNote(note({ body: "typo fixed" }));
|
||||||
|
const del = result.current.deleteNote("n1");
|
||||||
|
await Promise.all([save, del]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(order).toEqual(["save", "delete"]);
|
||||||
|
expect(files.p1).toHaveLength(0);
|
||||||
|
expect(result.current.notes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a new note when another note is saved right after it", async () => {
|
||||||
|
// A purely local draft used to be wiped by the next re-read: two clicks of
|
||||||
|
// "New note", type in the second, blur, and the first row was gone.
|
||||||
|
const files = fakeBackend({ p1: [note()] });
|
||||||
|
const { result } = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
let first: Note | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
first = await result.current.createNote();
|
||||||
|
await result.current.createNote();
|
||||||
|
});
|
||||||
|
expect(result.current.notes).toHaveLength(3);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.saveNote(note({ body: "edited" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.notes).toHaveLength(3);
|
||||||
|
expect(result.current.notes.some((n) => n.id === first!.id)).toBe(true);
|
||||||
|
expect(files.p1).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares one cache between every hook watching the same project", async () => {
|
||||||
|
// The Project Home sub-tab and the dock both mount a panel for the same
|
||||||
|
// project. Two caches meant an edit in one was invisible to the other, and
|
||||||
|
// the other's next blur wrote its stale copy back over it.
|
||||||
|
fakeBackend({ p1: [note()] });
|
||||||
|
const tab = renderHook(() => useNotes("p1"));
|
||||||
|
const dock = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
||||||
|
await waitFor(() => expect(dock.result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
// One read for both — the in-flight flag is per project, not per hook.
|
||||||
|
expect(listNotes).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await dock.result.current.saveNote(note({ body: "written in the dock" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tab.result.current.notes[0].body).toBe("written in the dock");
|
||||||
|
expect(tab.result.current.notes).toBe(dock.result.current.notes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not blank an already-loaded list when a second panel mounts", async () => {
|
||||||
|
fakeBackend({ p1: [note()] });
|
||||||
|
const tab = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
const dock = renderHook(() => useNotes("p1"));
|
||||||
|
// No "Loading notes…" flash on the second surface.
|
||||||
|
expect(dock.result.current.loading).toBe(false);
|
||||||
|
expect(dock.result.current.notes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a slow mount read overwrite a fresher post-save refresh", async () => {
|
||||||
|
// One gesture, two requests. The tab is already loaded; the user clicks the
|
||||||
|
// dock toggle with the textarea focused, so `blur` → `saveNote` and the
|
||||||
|
// dock's mount → `list_notes` are issued in the same tick. The save's
|
||||||
|
// re-read writes the post-save list; the mount's read — issued earlier,
|
||||||
|
// still in flight — must not then land its pre-save snapshot on top of it.
|
||||||
|
const files = fakeBackend({ p1: [note({ body: "before" })] });
|
||||||
|
const tab = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
||||||
|
expect(tab.result.current.notes[0].body).toBe("before");
|
||||||
|
|
||||||
|
// The dock's mount read: it snapshots the list as it is *now* (pre-save)
|
||||||
|
// and hangs, standing in for a plain read that is slower than
|
||||||
|
// `save_note`'s double-fsync write plus the re-read behind it.
|
||||||
|
let releaseMountRead: (() => void) | undefined;
|
||||||
|
listNotes.mockImplementationOnce(async (p: string) => {
|
||||||
|
const preSave = [...(files[p] ?? [])];
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
releaseMountRead = resolve;
|
||||||
|
});
|
||||||
|
return preSave;
|
||||||
|
});
|
||||||
|
const dock = renderHook(() => useNotes("p1"));
|
||||||
|
expect(releaseMountRead).toBeDefined();
|
||||||
|
|
||||||
|
// The save and its re-read complete while that read is still out.
|
||||||
|
await act(async () => {
|
||||||
|
await tab.result.current.saveNote(note({ body: "after" }));
|
||||||
|
});
|
||||||
|
expect(tab.result.current.notes[0].body).toBe("after");
|
||||||
|
|
||||||
|
// Now the stale read lands.
|
||||||
|
await act(async () => {
|
||||||
|
releaseMountRead!();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tab.result.current.notes[0].body).toBe("after");
|
||||||
|
expect(dock.result.current.notes[0].body).toBe("after");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a slow mount read resurrect a note deleted while it was in flight", async () => {
|
||||||
|
// The other half of the same ordering rule: a confirmed delete is newer
|
||||||
|
// than any read issued before it finished, so the read's pre-delete list
|
||||||
|
// must not be written back over the shortened one.
|
||||||
|
const files = fakeBackend({ p1: [note()] });
|
||||||
|
const tab = renderHook(() => useNotes("p1"));
|
||||||
|
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
let releaseMountRead: (() => void) | undefined;
|
||||||
|
listNotes.mockImplementationOnce(async (p: string) => {
|
||||||
|
const preDelete = [...(files[p] ?? [])];
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
releaseMountRead = resolve;
|
||||||
|
});
|
||||||
|
return preDelete;
|
||||||
|
});
|
||||||
|
const dock = renderHook(() => useNotes("p1"));
|
||||||
|
expect(releaseMountRead).toBeDefined();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await tab.result.current.deleteNote("n1");
|
||||||
|
});
|
||||||
|
expect(tab.result.current.notes).toHaveLength(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
releaseMountRead!();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(tab.result.current.notes).toHaveLength(0);
|
||||||
|
expect(dock.result.current.notes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
import type { Note } from "../lib/types";
|
||||||
|
import type { SaveState } from "./useSaveState";
|
||||||
|
import { useAppState } from "../store/appState";
|
||||||
|
|
||||||
|
/** A blank note, ordered to the top so the user can start typing immediately. */
|
||||||
|
function draft(): Note {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
// The backend keeps whatever id it is handed for a note it has not seen,
|
||||||
|
// so this one is the note's real id from the first save onward.
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: "",
|
||||||
|
body: "",
|
||||||
|
pinned: false,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable empty list, so a project with nothing cached does not re-render on identity. */
|
||||||
|
const NO_NOTES: Note[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-project mutation chain.
|
||||||
|
*
|
||||||
|
* A project's writes are serialised so that two of them cannot be in flight at
|
||||||
|
* once. The Rust `write_lock` stops an upsert and a delete *interleaving*; it
|
||||||
|
* does not order them, and the order is the part that matters here. Clicking
|
||||||
|
* Delete while the textarea has focus fires `blur` first, so `save_note` and
|
||||||
|
* `delete_note` are issued back to back — and if the delete wins the lock, the
|
||||||
|
* upsert behind it re-inserts the note and it comes back on the next load.
|
||||||
|
* "Fix a typo, decide the note is useless, delete it" is an ordinary sequence.
|
||||||
|
*
|
||||||
|
* Module scope, not hook scope, for the reason `useTerminal`'s input queue is:
|
||||||
|
* several components call `useNotes` for the same project (the Project Home
|
||||||
|
* tab and the dock), and a per-hook chain would give each its own ordering and
|
||||||
|
* leave them racing each other — which is the bug, not the fix.
|
||||||
|
*/
|
||||||
|
const mutationChains = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
function enqueueMutation<T>(projectId: string, run: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = mutationChains.get(projectId) ?? Promise.resolve();
|
||||||
|
// `run` on both arms: a failed mutation must not stall every later one.
|
||||||
|
const result = previous.then(run, run);
|
||||||
|
const tail = result.then(
|
||||||
|
() => {},
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
mutationChains.set(projectId, tail);
|
||||||
|
void tail.then(() => {
|
||||||
|
// Drop the entry once idle, so closed projects do not accumulate.
|
||||||
|
if (mutationChains.get(projectId) === tail) mutationChains.delete(projectId);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-project write ordering for the shared notes cache.
|
||||||
|
*
|
||||||
|
* `mutationChains` orders a project's *writes* against each other. It says
|
||||||
|
* nothing about reads, and the mount load is a read that runs outside it — so
|
||||||
|
* one gesture can put two requests in flight at once and let the slower one
|
||||||
|
* win. Clicking the dock toggle with the textarea focused fires `blur` →
|
||||||
|
* `saveNote` and the dock's mount → `list_notes` in the same tick: the save
|
||||||
|
* finishes, its re-read writes the post-save list, and then the mount's read —
|
||||||
|
* issued earlier, still out — lands its pre-save snapshot on top. Both panels
|
||||||
|
* show stale text until something else refreshes. It needs the plain read to
|
||||||
|
* be slower than `save_note`'s double-fsync write plus a second read, so it is
|
||||||
|
* narrow, but it was reproduced.
|
||||||
|
*
|
||||||
|
* The fix is a sequence number rather than a chain, because the two requests
|
||||||
|
* are not competing for a resource — the loser's result is simply *older*, and
|
||||||
|
* the cheapest correct thing to do with it is throw it away. Every write
|
||||||
|
* claims a sequence when the request behind it is issued, and `commitNotes`
|
||||||
|
* drops one whose sequence predates what is already cached. That also closes a
|
||||||
|
* hole identity comparison cannot: on a `p1 → p2 → p1` switch a read from the
|
||||||
|
* *first* p1 era is indistinguishable from a current one by project id, and
|
||||||
|
* would land its stale list on the second era's.
|
||||||
|
*
|
||||||
|
* Note what this deliberately does **not** replace. `isCurrent()` asks whether
|
||||||
|
* this *panel* is still showing the project a save was made for, which governs
|
||||||
|
* a per-panel `SaveIndicator` and not the shared cache at all; a per-project
|
||||||
|
* counter cannot answer it. Ordering and panel identity are two questions, and
|
||||||
|
* they keep two guards.
|
||||||
|
*
|
||||||
|
* Entries are two integers per project and are never pruned: they must outlive
|
||||||
|
* every request that could still land, and the map is monotone, so a stale
|
||||||
|
* sequence can never be reissued.
|
||||||
|
*/
|
||||||
|
const notesSequences = new Map<string, { issued: number; committed: number }>();
|
||||||
|
|
||||||
|
function sequenceFor(projectId: string): { issued: number; committed: number } {
|
||||||
|
let seq = notesSequences.get(projectId);
|
||||||
|
if (!seq) notesSequences.set(projectId, (seq = { issued: 0, committed: 0 }));
|
||||||
|
return seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim the sequence for a write about to be issued.
|
||||||
|
*
|
||||||
|
* Called immediately before the request whose result it will commit, so that
|
||||||
|
* ordering is by *issue* time. Resolution order is exactly what cannot be
|
||||||
|
* trusted here.
|
||||||
|
*/
|
||||||
|
function issueNotesWrite(projectId: string): number {
|
||||||
|
const seq = sequenceFor(projectId);
|
||||||
|
seq.issued += 1;
|
||||||
|
return seq.issued;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a list into the cache under the sequence it was issued at, unless
|
||||||
|
* something newer has already been committed.
|
||||||
|
*
|
||||||
|
* A local patch — the filter behind a confirmed delete, say — is authoritative
|
||||||
|
* at the moment it applies rather than derived from an earlier read, so it
|
||||||
|
* claims its sequence here: `issued` is never below `committed`, so a freshly
|
||||||
|
* claimed one always wins, and anything still in flight behind it is correctly
|
||||||
|
* treated as stale.
|
||||||
|
*/
|
||||||
|
function commitNotes(projectId: string, seq: number, notes: Note[]): boolean {
|
||||||
|
const sequence = sequenceFor(projectId);
|
||||||
|
if (seq <= sequence.committed) return false;
|
||||||
|
sequence.committed = seq;
|
||||||
|
useAppState.getState().setProjectNotes(projectId, notes);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-read the canonical list into the shared cache.
|
||||||
|
*
|
||||||
|
* A successful save stamps a new `updated_at` and the backend sorts on it, so
|
||||||
|
* the record's position has changed and positional patching would disagree
|
||||||
|
* with what a reload would show. The backend owns the order; the webview never
|
||||||
|
* sorts. A failed re-read leaves the cache alone rather than clearing it.
|
||||||
|
*
|
||||||
|
* `true` means "the cache is current", which is why a superseded commit still
|
||||||
|
* returns it: whatever beat this read was issued later and therefore read the
|
||||||
|
* same write or a later one.
|
||||||
|
*/
|
||||||
|
async function refresh(projectId: string): Promise<boolean> {
|
||||||
|
const seq = issueNotesWrite(projectId);
|
||||||
|
try {
|
||||||
|
const reloaded = await commands.listNotes(projectId);
|
||||||
|
commitNotes(projectId, seq, reloaded);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A project's notes, cached from the backend.
|
||||||
|
*
|
||||||
|
* The backend is the source of truth and the zustand slice is the cache —
|
||||||
|
* every mutation goes through a command and the returned list replaces the
|
||||||
|
* cached one, so the list can never drift from the file. The cache lives in
|
||||||
|
* the store rather than in this hook because two surfaces show the same
|
||||||
|
* project's notes at once; see `notesByProject`.
|
||||||
|
*
|
||||||
|
* `saveState` is deliberately *not* shared: it is this panel's report of this
|
||||||
|
* panel's write, and `ui/SaveIndicator` is per-panel. A save that fails
|
||||||
|
* silently is a user staring at text they believe is stored.
|
||||||
|
*/
|
||||||
|
export function useNotes(projectId: string) {
|
||||||
|
const cached = useAppState((s) => s.notesByProject[projectId]);
|
||||||
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>({ status: "idle", error: null });
|
||||||
|
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const currentProjectId = useRef(projectId);
|
||||||
|
currentProjectId.current = projectId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) return;
|
||||||
|
// Read through `getState` rather than through subscribed values: the
|
||||||
|
// effect must fire once per project, not again every time the flag it sets
|
||||||
|
// changes. Two panels mounting for the same project therefore make one
|
||||||
|
// read, and the second renders from the cache with no loading flash.
|
||||||
|
const store = useAppState.getState();
|
||||||
|
if (store.notesLoading[projectId]) return;
|
||||||
|
store.setNotesLoading(projectId, true);
|
||||||
|
const seq = issueNotesWrite(projectId);
|
||||||
|
commands
|
||||||
|
.listNotes(projectId)
|
||||||
|
.then((loaded) => {
|
||||||
|
commitNotes(projectId, seq, loaded);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
// A project that has never been read caches the empty list, so a panel
|
||||||
|
// does not sit on "Loading notes…" forever. One that *has* been read
|
||||||
|
// keeps what it has: this load is a refresh behind a list already on
|
||||||
|
// screen — the second surface mounting, say — and a failed refresh
|
||||||
|
// must not blank both of them. Same rule as `refresh()`. Neither
|
||||||
|
// branch has a stale-project hazard, because the write is keyed by the
|
||||||
|
// project it belongs to.
|
||||||
|
//
|
||||||
|
// The commit goes under this read's own sequence, not a fresh one: a
|
||||||
|
// *later* read still in flight has the newer answer and must not be
|
||||||
|
// dropped in favour of this failure's empty list.
|
||||||
|
if (useAppState.getState().notesByProject[projectId] === undefined) {
|
||||||
|
commitNotes(projectId, seq, []);
|
||||||
|
}
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not load notes for this project",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
useAppState.getState().setNotesLoading(projectId, false);
|
||||||
|
});
|
||||||
|
}, [projectId, pushToast]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The indicator belongs to whatever project this panel is showing *now*.
|
||||||
|
// Without this, switching project mid-save leaves the new project's
|
||||||
|
// SaveIndicator stuck on the old project's "Saving…" — the same wrong-project
|
||||||
|
// report as flashing its "Saved ✓", just in the other direction.
|
||||||
|
useEffect(() => {
|
||||||
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
|
setSaveState({ status: "idle", error: null });
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this hook is still looking at the project a queued mutation was
|
||||||
|
* issued for. Only the *reporting* is gated on it — the cache write is not,
|
||||||
|
* because it is keyed by project and belongs to that project either way.
|
||||||
|
* Without this, the new project's SaveIndicator flashes "Saved ✓" for the
|
||||||
|
* old project's write.
|
||||||
|
*/
|
||||||
|
const isCurrent = useCallback(
|
||||||
|
() => currentProjectId.current === projectId,
|
||||||
|
[projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const succeeded = useCallback(() => {
|
||||||
|
setSaveState({ status: "saved", error: null });
|
||||||
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
|
resetTimer.current = setTimeout(
|
||||||
|
() => setSaveState({ status: "idle", error: null }),
|
||||||
|
2500,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveNote = useCallback(
|
||||||
|
(note: Note) =>
|
||||||
|
enqueueMutation(projectId, async () => {
|
||||||
|
if (isCurrent()) {
|
||||||
|
if (resetTimer.current) clearTimeout(resetTimer.current);
|
||||||
|
setSaveState({ status: "saving", error: null });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await commands.saveNote(projectId, note);
|
||||||
|
await refresh(projectId);
|
||||||
|
if (isCurrent()) succeeded();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
const message = String(e);
|
||||||
|
if (isCurrent()) setSaveState({ status: "failed", error: message });
|
||||||
|
// The toast is not project-scoped — it names the failure and stays
|
||||||
|
// readable after a switch — so it fires either way.
|
||||||
|
pushToast({ kind: "error", message: "Could not save note", detail: message });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[projectId, pushToast, succeeded, isCurrent],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a note by persisting it, rather than holding it locally until the
|
||||||
|
* first blur.
|
||||||
|
*
|
||||||
|
* The draft used to live only in the list, which meant any *other* note
|
||||||
|
* being saved replaced the list with the backend's and the unsaved draft
|
||||||
|
* silently vanished — click "New note" twice, type in the second, blur, and
|
||||||
|
* the first row is gone. Sharing one cache between two surfaces makes that
|
||||||
|
* worse rather than better: a local-only row would exist in whichever panel
|
||||||
|
* created it and nowhere else. Letting the backend own the row from the
|
||||||
|
* start removes the whole class: there is no such thing as a note in the
|
||||||
|
* list that the file does not have.
|
||||||
|
*/
|
||||||
|
const createNote = useCallback(
|
||||||
|
() =>
|
||||||
|
enqueueMutation(projectId, async () => {
|
||||||
|
const note = draft();
|
||||||
|
try {
|
||||||
|
const saved = await commands.saveNote(projectId, note);
|
||||||
|
if (!(await refresh(projectId))) {
|
||||||
|
// The note exists; only the re-read failed. Show it rather than
|
||||||
|
// leaving the user with a button that did nothing visible.
|
||||||
|
const store = useAppState.getState();
|
||||||
|
commitNotes(projectId, issueNotesWrite(projectId), [
|
||||||
|
saved,
|
||||||
|
...(store.notesByProject[projectId] ?? []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not create note",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[projectId, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteNote = useCallback(
|
||||||
|
(noteId: string) =>
|
||||||
|
enqueueMutation(projectId, async () => {
|
||||||
|
try {
|
||||||
|
await commands.deleteNote(projectId, noteId);
|
||||||
|
const store = useAppState.getState();
|
||||||
|
commitNotes(
|
||||||
|
projectId,
|
||||||
|
issueNotesWrite(projectId),
|
||||||
|
(store.notesByProject[projectId] ?? []).filter((n) => n.id !== noteId),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({ kind: "error", message: "Could not delete note", detail: String(e) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[projectId, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notes: cached ?? NO_NOTES,
|
||||||
|
// Only "loading" before the project has ever been read — never on a
|
||||||
|
// refresh behind a list that is already on screen, and never on the second
|
||||||
|
// panel to mount for a project the first one already fetched. A failed
|
||||||
|
// load caches the empty list, so this cannot latch on.
|
||||||
|
loading: Boolean(projectId) && cached === undefined,
|
||||||
|
saveState,
|
||||||
|
createNote,
|
||||||
|
saveNote,
|
||||||
|
deleteNote,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -36,5 +36,9 @@ export function useSettings() {
|
|||||||
appSettings,
|
appSettings,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
|
/** For a command that already returns the new `AppSettings` itself
|
||||||
|
* (settings import) — updates the store without a redundant
|
||||||
|
* `updateSettings` round trip through the backend. */
|
||||||
|
setAppSettings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// The queue lives at module scope in useTerminal, so the command layer is
|
||||||
|
// mocked and the hook's `sendInput` is exercised through `renderHook`.
|
||||||
|
const terminalInput = vi.fn<(sessionId: string, data: number[]) => Promise<void>>();
|
||||||
|
|
||||||
|
vi.mock("../lib/tauri-commands", () => ({
|
||||||
|
terminalInput: (sessionId: string, data: number[]) => terminalInput(sessionId, data),
|
||||||
|
openTerminalSession: vi.fn(),
|
||||||
|
closeTerminalSession: vi.fn(),
|
||||||
|
terminalResize: vi.fn(),
|
||||||
|
pasteImageToTerminal: vi.fn(),
|
||||||
|
updateProject: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() }));
|
||||||
|
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { useTerminal } from "./useTerminal";
|
||||||
|
|
||||||
|
const decode = (bytes: number[]) => new TextDecoder().decode(new Uint8Array(bytes));
|
||||||
|
|
||||||
|
describe("useTerminal input ordering", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
terminalInput.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves order even when the underlying invokes resolve out of order", async () => {
|
||||||
|
// Make the *first* call the slowest, which is exactly the race that put a
|
||||||
|
// backspace behind the characters typed after it.
|
||||||
|
const resolvers: Array<() => void> = [];
|
||||||
|
terminalInput.mockImplementation(
|
||||||
|
() => new Promise<void>((resolve) => resolvers.push(resolve)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTerminal());
|
||||||
|
|
||||||
|
const first = result.current.sendInput("s1", "\x7f"); // backspace
|
||||||
|
const rest = ["a", "b", "c"].map((ch) => result.current.sendInput("s1", ch));
|
||||||
|
|
||||||
|
// Only one write may be in flight at a time.
|
||||||
|
expect(terminalInput).toHaveBeenCalledTimes(1);
|
||||||
|
expect(decode(terminalInput.mock.calls[0][1])).toBe("\x7f");
|
||||||
|
|
||||||
|
resolvers.shift()!();
|
||||||
|
await first;
|
||||||
|
|
||||||
|
// The three queued keystrokes coalesce into one ordered write.
|
||||||
|
expect(terminalInput).toHaveBeenCalledTimes(2);
|
||||||
|
expect(decode(terminalInput.mock.calls[1][1])).toBe("abc");
|
||||||
|
|
||||||
|
resolvers.shift()!();
|
||||||
|
await Promise.all(rest);
|
||||||
|
|
||||||
|
const sent = terminalInput.mock.calls.map((c) => decode(c[1])).join("");
|
||||||
|
expect(sent).toBe("\x7fabc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settles each caller's promise and does not drop later writes on failure", async () => {
|
||||||
|
terminalInput.mockRejectedValueOnce(new Error("boom")).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTerminal());
|
||||||
|
|
||||||
|
await expect(result.current.sendInput("s2", "x")).rejects.toThrow("boom");
|
||||||
|
await expect(result.current.sendInput("s2", "y")).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(decode(terminalInput.mock.calls[1][1])).toBe("y");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps separate sessions independent", async () => {
|
||||||
|
terminalInput.mockResolvedValue(undefined);
|
||||||
|
const { result } = renderHook(() => useTerminal());
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
result.current.sendInput("a", "1"),
|
||||||
|
result.current.sendInput("b", "2"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bySession = terminalInput.mock.calls.map((c) => [c[0], decode(c[1])]);
|
||||||
|
expect(bySession).toContainEqual(["a", "1"]);
|
||||||
|
expect(bySession).toContainEqual(["b", "2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,86 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-session ordered write queue.
|
||||||
|
*
|
||||||
|
* Every keystroke used to be its own `invoke("terminal_input")`, and because
|
||||||
|
* that command is `async` on the Rust side Tauri spawns each one as an
|
||||||
|
* independent task. Those tasks then race for the session mutex in
|
||||||
|
* `ExecSessionManager::send_input`, so nothing preserved the order the bytes
|
||||||
|
* were typed in — the visible symptom was a backspace landing *after* the
|
||||||
|
* characters typed behind it. The serial writer task downstream cannot help,
|
||||||
|
* because the order is already lost by the time anything reaches the channel.
|
||||||
|
*
|
||||||
|
* The queue restores ordering the same way the web terminal gets it for free:
|
||||||
|
* one write in flight at a time, the next only after the previous resolves.
|
||||||
|
* Anything typed while a write is in flight coalesces into the next chunk,
|
||||||
|
* which also collapses a burst of typing into a couple of IPC round trips
|
||||||
|
* rather than one per key. Concatenating the byte arrays is safe — a PTY
|
||||||
|
* cannot tell one write of "ab" from writes of "a" then "b" — and each
|
||||||
|
* caller's promise still settles only when its own bytes have gone, so
|
||||||
|
* `await sendInput(...)` keeps the meaning it had.
|
||||||
|
*
|
||||||
|
* Module scope, not hook scope, because `useTerminal()` is called from several
|
||||||
|
* components (App for speech-to-text, TerminalView for typing and image paste,
|
||||||
|
* useProjectActions for tile commands). A per-hook queue would give each caller
|
||||||
|
* its own ordering and leave them racing against each other.
|
||||||
|
*/
|
||||||
|
type PendingWrite = {
|
||||||
|
bytes: number[];
|
||||||
|
resolve: () => void;
|
||||||
|
reject: (reason: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputQueues = new Map<string, { pending: PendingWrite[]; draining: boolean }>();
|
||||||
|
|
||||||
|
async function drainInputQueue(sessionId: string): Promise<void> {
|
||||||
|
const q = inputQueues.get(sessionId);
|
||||||
|
if (!q || q.draining) return;
|
||||||
|
|
||||||
|
q.draining = true;
|
||||||
|
try {
|
||||||
|
while (q.pending.length > 0) {
|
||||||
|
// Take everything queued so far as one batch, preserving order.
|
||||||
|
const batch = q.pending.splice(0, q.pending.length);
|
||||||
|
const bytes = batch.flatMap((w) => w.bytes);
|
||||||
|
try {
|
||||||
|
await commands.terminalInput(sessionId, bytes);
|
||||||
|
batch.forEach((w) => w.resolve());
|
||||||
|
} catch (err) {
|
||||||
|
// Reject only the writes in this batch. Anything queued while it was
|
||||||
|
// in flight is still pending and gets its own attempt on the next lap.
|
||||||
|
batch.forEach((w) => w.reject(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
q.draining = false;
|
||||||
|
// Drop the entry once idle so closed sessions do not accumulate.
|
||||||
|
if (q.pending.length === 0) inputQueues.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueInput(sessionId: string, bytes: number[]): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
let q = inputQueues.get(sessionId);
|
||||||
|
if (!q) {
|
||||||
|
q = { pending: [], draining: false };
|
||||||
|
inputQueues.set(sessionId, q);
|
||||||
|
}
|
||||||
|
q.pending.push({ bytes, resolve, reject });
|
||||||
|
void drainInputQueue(sessionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop any queued input for a session that is going away. */
|
||||||
|
function discardInputQueue(sessionId: string): void {
|
||||||
|
const q = inputQueues.get(sessionId);
|
||||||
|
if (!q) return;
|
||||||
|
const dropped = q.pending.splice(0, q.pending.length);
|
||||||
|
dropped.forEach((w) => w.reject(new Error(`Session ${sessionId} closed`)));
|
||||||
|
if (!q.draining) inputQueues.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
export function useTerminal() {
|
export function useTerminal() {
|
||||||
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
|
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
|
||||||
useAppState(
|
useAppState(
|
||||||
@@ -33,6 +113,7 @@ export function useTerminal() {
|
|||||||
const session = currentSessions.find((s) => s.id === sessionId);
|
const session = currentSessions.find((s) => s.id === sessionId);
|
||||||
const project = session ? projects.find((p) => p.id === session.projectId) : undefined;
|
const project = session ? projects.find((p) => p.id === session.projectId) : undefined;
|
||||||
|
|
||||||
|
discardInputQueue(sessionId);
|
||||||
await commands.closeTerminalSession(sessionId);
|
await commands.closeTerminalSession(sessionId);
|
||||||
removeSession(sessionId);
|
removeSession(sessionId);
|
||||||
|
|
||||||
@@ -54,7 +135,7 @@ export function useTerminal() {
|
|||||||
const sendInput = useCallback(
|
const sendInput = useCallback(
|
||||||
async (sessionId: string, data: string) => {
|
async (sessionId: string, data: string) => {
|
||||||
const bytes = Array.from(new TextEncoder().encode(data));
|
const bytes = Array.from(new TextEncoder().encode(data));
|
||||||
await commands.terminalInput(sessionId, bytes);
|
await enqueueInput(sessionId, bytes);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { CLAUDE_SOFT_NEWLINE, toClaudePayload } from "./claudeInput";
|
||||||
|
|
||||||
|
describe("toClaudePayload", () => {
|
||||||
|
it("is ESC+CR, the sequence Claude Code's own /terminal-setup installs", () => {
|
||||||
|
expect(CLAUDE_SOFT_NEWLINE).toBe("\x1b\r");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces every newline so the note arrives as one prompt", () => {
|
||||||
|
// Typed raw, each \n submits — the note would arrive as three truncated
|
||||||
|
// messages instead of one.
|
||||||
|
expect(toClaudePayload("one\ntwo\nthree")).toBe("one\x1b\rtwo\x1b\rthree");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises CRLF, which is what a paste from Windows carries", () => {
|
||||||
|
expect(toClaudePayload("one\r\ntwo")).toBe("one\x1b\rtwo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises a lone CR, which would otherwise submit", () => {
|
||||||
|
// A bare \r is a carriage return: it submits in a Claude prompt and runs
|
||||||
|
// the line in a shell — the terminator this function promises not to
|
||||||
|
// append. A textarea cannot make one, but a notes file that was
|
||||||
|
// hand-edited or written by something else can, and `load_in` hands it
|
||||||
|
// straight back.
|
||||||
|
expect(toClaudePayload("one\rtwo")).toBe("one\x1b\rtwo");
|
||||||
|
expect(toClaudePayload("one\rtwo\r\nthree\nfour")).toBe(
|
||||||
|
"one\x1b\rtwo\x1b\rthree\x1b\rfour",
|
||||||
|
);
|
||||||
|
expect(toClaudePayload("text\r").endsWith("\r")).toBe(true);
|
||||||
|
// …but only as the tail of the soft-newline sequence, never bare.
|
||||||
|
expect(toClaudePayload("text\r")).toBe("text\x1b\r");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves single-line text untouched", () => {
|
||||||
|
expect(toClaudePayload("just one line")).toBe("just one line");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never appends a terminator", () => {
|
||||||
|
// The note lands in the prompt unsubmitted; the user presses Enter. An
|
||||||
|
// unsent prompt is recoverable, a sent one is not.
|
||||||
|
expect(toClaudePayload("text").endsWith("\r")).toBe(false);
|
||||||
|
expect(toClaudePayload("text\n")).toBe("text\x1b\r");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* The bytes that insert a newline in Claude Code's prompt without submitting
|
||||||
|
* it: ESC then CR.
|
||||||
|
*
|
||||||
|
* These are the in-band bytes, not a guess — they are exactly what Claude
|
||||||
|
* Code's own `/terminal-setup` writes into the VS Code, Cursor, Alacritty and
|
||||||
|
* Zed keymaps, and `TerminalView`'s Shift+Enter handler has sent them since
|
||||||
|
* that feature landed. **This must not be "simplified" to `\n`:** Claude Code
|
||||||
|
* accepts `\n` too, but a shell would *run* the line, so the two session types
|
||||||
|
* would quietly diverge.
|
||||||
|
*
|
||||||
|
* That last sentence is also why anything sending this must first check the
|
||||||
|
* session is a Claude one. `bash -l`'s readline has no binding for `\e\r` and
|
||||||
|
* answers with a bell.
|
||||||
|
*/
|
||||||
|
export const CLAUDE_SOFT_NEWLINE = "\x1b\r";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn multi-line text into something that arrives in a Claude prompt as one
|
||||||
|
* message.
|
||||||
|
*
|
||||||
|
* Sent as raw keystrokes, every `\n` submits, so an N-line note would arrive
|
||||||
|
* as N truncated prompts. Deliberately appends no terminator: the text lands
|
||||||
|
* in the prompt and the user presses Enter, which is what speech-to-text does
|
||||||
|
* for the same reason — an unsent prompt is recoverable and a sent one is not.
|
||||||
|
*
|
||||||
|
* A **lone** `\r` is matched too, not only the one in a CRLF. It is a carriage
|
||||||
|
* return: it submits in a Claude prompt and runs the line in a shell, which is
|
||||||
|
* exactly the terminator this function promises never to append. A `<textarea>`
|
||||||
|
* cannot produce one, but a note body is read back from a JSON file that can be
|
||||||
|
* hand-edited or written by something else, so the guarantee has to hold for
|
||||||
|
* whatever `load_in` returns rather than for whatever the editor can type.
|
||||||
|
*/
|
||||||
|
export function toClaudePayload(text: string): string {
|
||||||
|
return text.replace(/\r\n|\r|\n/g, CLAUDE_SOFT_NEWLINE);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { sessionDisplayName } from "./sessionName";
|
||||||
|
import type { Project, TerminalSession } from "./types";
|
||||||
|
|
||||||
|
const session = (over: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||||
|
id: "s1",
|
||||||
|
projectId: "p1",
|
||||||
|
projectName: "api",
|
||||||
|
sessionType: "claude",
|
||||||
|
sessionName: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = (renamed: Record<string, string> = {}) =>
|
||||||
|
({ id: "p1", name: "api", renamed_session_names: renamed }) as unknown as Project;
|
||||||
|
|
||||||
|
describe("sessionDisplayName", () => {
|
||||||
|
it("prefers a user-set custom name, prefixed with the project", () => {
|
||||||
|
expect(sessionDisplayName(session(), project({ s1: "release work" }))).toBe(
|
||||||
|
"api: release work",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the session name when there is no custom one", () => {
|
||||||
|
expect(sessionDisplayName(session({ sessionName: "review" }), project())).toBe("review");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the project name when there is no session name", () => {
|
||||||
|
expect(sessionDisplayName(session(), project())).toBe("api");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks bash sessions", () => {
|
||||||
|
expect(sessionDisplayName(session({ sessionType: "bash" }), project())).toBe("api (bash)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works with no project, which is how a closing tab renders", () => {
|
||||||
|
expect(sessionDisplayName(session())).toBe("api");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark bash when a custom name is set, matching the existing rule", () => {
|
||||||
|
expect(
|
||||||
|
sessionDisplayName(session({ sessionType: "bash" }), project({ s1: "logs" })),
|
||||||
|
).toBe("api: logs");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Project, TerminalSession } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a terminal session is called on screen.
|
||||||
|
*
|
||||||
|
* The rule used to be written twice inside `MainTabs.tsx` — once in `tabLabel`
|
||||||
|
* for the drag ghost, once inline in `renderTab` — both local and neither
|
||||||
|
* exported, so the two could disagree the moment either was edited. It is here
|
||||||
|
* because a third caller (the note send-target picker) would have made that
|
||||||
|
* three.
|
||||||
|
*
|
||||||
|
* A user-set name wins and is prefixed with the project, because a custom name
|
||||||
|
* is usually about the work rather than the project and needs the context. The
|
||||||
|
* `(bash)` marker only appears on the fallback: a session someone bothered to
|
||||||
|
* name does not need to be told apart from its neighbours.
|
||||||
|
*/
|
||||||
|
export function sessionDisplayName(
|
||||||
|
session: TerminalSession,
|
||||||
|
project?: Project,
|
||||||
|
): string {
|
||||||
|
const custom = project?.renamed_session_names?.[session.id];
|
||||||
|
if (custom) return `${session.projectName}: ${custom}`;
|
||||||
|
return (
|
||||||
|
(session.sessionName ?? session.projectName) +
|
||||||
|
(session.sessionType === "bash" ? " (bash)" : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { describeImport, describeImportWarnings } from "./settingsImportPreview";
|
||||||
|
import type { SettingsImportPreview } from "./types";
|
||||||
|
|
||||||
|
function preview(overrides: Partial<SettingsImportPreview> = {}): SettingsImportPreview {
|
||||||
|
return {
|
||||||
|
exported_at: "2026-08-27T00:00:00Z",
|
||||||
|
app_version: "0.4.14",
|
||||||
|
custom_env_var_count: 0,
|
||||||
|
gateway_model_count: 0,
|
||||||
|
has_claude_code_settings: false,
|
||||||
|
has_claude_oauth_token: false,
|
||||||
|
has_gateway_api_key: false,
|
||||||
|
has_gateway_master_key: false,
|
||||||
|
has_web_terminal_access_token: false,
|
||||||
|
enables_web_terminal: false,
|
||||||
|
ollama_base_url: null,
|
||||||
|
llamacpp_base_url: null,
|
||||||
|
openai_compatible_base_url: null,
|
||||||
|
gateway_api_base: null,
|
||||||
|
image_source: "registry",
|
||||||
|
custom_image_name: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("describeImport", () => {
|
||||||
|
it("always names the settings replacement, even with nothing else set", () => {
|
||||||
|
expect(describeImport(preview())).toEqual([
|
||||||
|
"Your global settings (all of them — this replaces what's here now)",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("singularizes a count of exactly one", () => {
|
||||||
|
const items = describeImport(preview({ custom_env_var_count: 1, gateway_model_count: 1 }));
|
||||||
|
expect(items).toContain("1 global custom env var");
|
||||||
|
expect(items).toContain("1 gateway model");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pluralizes counts greater than one", () => {
|
||||||
|
const items = describeImport(preview({ custom_env_var_count: 3, gateway_model_count: 2 }));
|
||||||
|
expect(items).toContain("3 global custom env vars");
|
||||||
|
expect(items).toContain("2 gateway models");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names every present secret and setting without naming absent ones", () => {
|
||||||
|
const items = describeImport(
|
||||||
|
preview({
|
||||||
|
has_claude_code_settings: true,
|
||||||
|
has_claude_oauth_token: true,
|
||||||
|
has_gateway_api_key: true,
|
||||||
|
has_gateway_master_key: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(items).toContain("Global Claude Code settings");
|
||||||
|
expect(items).toContain("Your shared Claude login");
|
||||||
|
expect(items).toContain("The gateway provider API key");
|
||||||
|
expect(items).toContain("The gateway master key");
|
||||||
|
// None of the count-based items, since both counts are 0.
|
||||||
|
expect(items.some((i) => i.includes("env var"))).toBe(false);
|
||||||
|
expect(items.some((i) => i.includes("gateway model"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the web terminal access token like any other present secret", () => {
|
||||||
|
const items = describeImport(preview({ has_web_terminal_access_token: true }));
|
||||||
|
expect(items).toContain("The web terminal access token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names custom base URLs verbatim, since they're endpoints rather than secrets", () => {
|
||||||
|
const items = describeImport(
|
||||||
|
preview({
|
||||||
|
ollama_base_url: "http://10.0.0.5:11434",
|
||||||
|
gateway_api_base: "https://gateway.example/v1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(items).toContain("Ollama server: http://10.0.0.5:11434");
|
||||||
|
expect(items).toContain("Gateway upstream: https://gateway.example/v1");
|
||||||
|
expect(items.some((i) => i.includes("llama.cpp"))).toBe(false);
|
||||||
|
expect(items.some((i) => i.includes("OpenAI-compatible"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a custom Docker image when set, falling back to a placeholder if unnamed", () => {
|
||||||
|
expect(
|
||||||
|
describeImport(preview({ image_source: "custom", custom_image_name: "ghcr.io/me/triple-c" })),
|
||||||
|
).toContain("Docker image: ghcr.io/me/triple-c");
|
||||||
|
expect(describeImport(preview({ image_source: "custom", custom_image_name: null }))).toContain(
|
||||||
|
"Docker image: (no image name set)",
|
||||||
|
);
|
||||||
|
expect(describeImport(preview({ image_source: "registry" })).some((i) => i.includes("Docker image"))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("describeImportWarnings", () => {
|
||||||
|
it("is empty when nothing about the import needs extra attention", () => {
|
||||||
|
expect(describeImportWarnings(preview())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns when the import enables the web terminal, regardless of the token", () => {
|
||||||
|
// `enabled` and the token are independent — the warning is about the
|
||||||
|
// service turning on, whether or not a token came with it.
|
||||||
|
expect(describeImportWarnings(preview({ enables_web_terminal: true }))).toEqual([
|
||||||
|
"Enables the remote web terminal, which listens on your network.",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
describeImportWarnings(
|
||||||
|
preview({ enables_web_terminal: true, has_web_terminal_access_token: true }),
|
||||||
|
),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns about a dormant web terminal token even while the terminal stays off", () => {
|
||||||
|
expect(describeImportWarnings(preview({ has_web_terminal_access_token: true }))).toEqual([
|
||||||
|
"Includes a web terminal access token that will activate the next time the web terminal is turned on.",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns about a custom Docker image every time, not only when it changes", () => {
|
||||||
|
expect(
|
||||||
|
describeImportWarnings(preview({ image_source: "custom", custom_image_name: "evil:latest" })),
|
||||||
|
).toEqual(["Runs every project container from a custom Docker image: evil:latest."]);
|
||||||
|
expect(describeImportWarnings(preview({ image_source: "registry" }))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { SettingsImportPreview } from "./types";
|
||||||
|
|
||||||
|
/** Named things a `SettingsImportPreview` says an import will change, for
|
||||||
|
* `ImportSettingsModal`'s confirmation list. Does not include anything
|
||||||
|
* `describeImportWarnings` covers — those get their own, more visible
|
||||||
|
* treatment rather than blending into this list. */
|
||||||
|
export function describeImport(preview: SettingsImportPreview): string[] {
|
||||||
|
const items: string[] = ["Your global settings (all of them — this replaces what's here now)"];
|
||||||
|
if (preview.custom_env_var_count > 0) {
|
||||||
|
items.push(
|
||||||
|
`${preview.custom_env_var_count} global custom env var${preview.custom_env_var_count === 1 ? "" : "s"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (preview.has_claude_code_settings) items.push("Global Claude Code settings");
|
||||||
|
if (preview.gateway_model_count > 0) {
|
||||||
|
items.push(`${preview.gateway_model_count} gateway model${preview.gateway_model_count === 1 ? "" : "s"}`);
|
||||||
|
}
|
||||||
|
if (preview.has_claude_oauth_token) items.push("Your shared Claude login");
|
||||||
|
if (preview.has_gateway_api_key) items.push("The gateway provider API key");
|
||||||
|
if (preview.has_gateway_master_key) items.push("The gateway master key");
|
||||||
|
if (preview.has_web_terminal_access_token) items.push("The web terminal access token");
|
||||||
|
if (preview.ollama_base_url) items.push(`Ollama server: ${preview.ollama_base_url}`);
|
||||||
|
if (preview.llamacpp_base_url) items.push(`llama.cpp server: ${preview.llamacpp_base_url}`);
|
||||||
|
if (preview.openai_compatible_base_url) {
|
||||||
|
items.push(`OpenAI-compatible server: ${preview.openai_compatible_base_url}`);
|
||||||
|
}
|
||||||
|
if (preview.gateway_api_base) items.push(`Gateway upstream: ${preview.gateway_api_base}`);
|
||||||
|
if (preview.image_source === "custom") {
|
||||||
|
items.push(`Docker image: ${preview.custom_image_name ?? "(no image name set)"}`);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Things about an import that deserve more attention than a bullet in a
|
||||||
|
* long list — deliberately its own function rather than a flag inside
|
||||||
|
* `describeImport`: a setting that turns on a network-listening service is
|
||||||
|
* exactly the kind of change a "your settings were replaced" summary is bad
|
||||||
|
* at surfacing, on purpose or (if the file came from someone else) not.
|
||||||
|
*
|
||||||
|
* A token that arrives with the terminal left *off* gets its own warning
|
||||||
|
* too, distinct from the "enables it now" one: `start_web_terminal` only
|
||||||
|
* mints a fresh token when none is already set, so a planted token here
|
||||||
|
* would silently become live the next time someone flips the terminal on
|
||||||
|
* through the UI, with no import-time signal that it wasn't freshly
|
||||||
|
* generated.
|
||||||
|
*
|
||||||
|
* A custom Docker image gets a warning every time, not just on change: it's
|
||||||
|
* the image every project container is created from, so it's worth calling
|
||||||
|
* out regardless of what was configured before the import.
|
||||||
|
*/
|
||||||
|
export function describeImportWarnings(preview: SettingsImportPreview): string[] {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
if (preview.enables_web_terminal) {
|
||||||
|
warnings.push("Enables the remote web terminal, which listens on your network.");
|
||||||
|
} else if (preview.has_web_terminal_access_token) {
|
||||||
|
warnings.push(
|
||||||
|
"Includes a web terminal access token that will activate the next time the web terminal is turned on.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (preview.image_source === "custom") {
|
||||||
|
warnings.push(
|
||||||
|
`Runs every project container from a custom Docker image: ${preview.custom_image_name ?? "(no image name set)"}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
|
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome, Note } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -25,6 +25,15 @@ export const rebuildProjectContainer = (projectId: string) =>
|
|||||||
export const reconcileProjectStatuses = () =>
|
export const reconcileProjectStatuses = () =>
|
||||||
invoke<Project[]>("reconcile_project_statuses");
|
invoke<Project[]>("reconcile_project_statuses");
|
||||||
|
|
||||||
|
// Notes — per-project, host-side, readable with the container stopped.
|
||||||
|
export const listNotes = (projectId: string) =>
|
||||||
|
invoke<Note[]>("list_notes", { projectId });
|
||||||
|
/** Insert or replace one note. `created_at` and `id` are owned by the backend. */
|
||||||
|
export const saveNote = (projectId: string, note: Note) =>
|
||||||
|
invoke<Note>("save_note", { projectId, note });
|
||||||
|
export const deleteNote = (projectId: string, noteId: string) =>
|
||||||
|
invoke<void>("delete_note", { projectId, noteId });
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
export const getSettings = () => invoke<AppSettings>("get_settings");
|
export const getSettings = () => invoke<AppSettings>("get_settings");
|
||||||
export const updateSettings = (settings: AppSettings) =>
|
export const updateSettings = (settings: AppSettings) =>
|
||||||
@@ -42,6 +51,15 @@ export const inspectCaCertPath = (path: string) =>
|
|||||||
export const detectHostTimezone = () =>
|
export const detectHostTimezone = () =>
|
||||||
invoke<string>("detect_host_timezone");
|
invoke<string>("detect_host_timezone");
|
||||||
|
|
||||||
|
// Settings export/import — `false`/`null` mean the save/open dialog was
|
||||||
|
// dismissed, not an error.
|
||||||
|
export const exportSettings = (password: string) =>
|
||||||
|
invoke<boolean>("export_settings", { password });
|
||||||
|
export const previewSettingsImport = (password: string) =>
|
||||||
|
invoke<SettingsImportPreview | null>("preview_settings_import", { password });
|
||||||
|
export const applySettingsImport = (password: string) =>
|
||||||
|
invoke<SettingsImportOutcome>("apply_settings_import", { password });
|
||||||
|
|
||||||
// AWS
|
// AWS
|
||||||
export const awsSsoRefresh = (projectId: string) =>
|
export const awsSsoRefresh = (projectId: string) =>
|
||||||
invoke<void>("aws_sso_refresh", { projectId });
|
invoke<void>("aws_sso_refresh", { projectId });
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { isLinuxWebview, resolveTerminalGpuRendering } from "./terminalRenderer";
|
||||||
|
|
||||||
|
const LINUX = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 Safari/605.1.15";
|
||||||
|
const MAC = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15";
|
||||||
|
const WINDOWS = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36";
|
||||||
|
const ANDROID = "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36";
|
||||||
|
|
||||||
|
describe("isLinuxWebview", () => {
|
||||||
|
it("recognises desktop Linux", () => {
|
||||||
|
expect(isLinuxWebview(LINUX)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not count Android as desktop Linux", () => {
|
||||||
|
expect(isLinuxWebview(ANDROID)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects the other desktop platforms", () => {
|
||||||
|
expect(isLinuxWebview(MAC)).toBe(false);
|
||||||
|
expect(isLinuxWebview(WINDOWS)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveTerminalGpuRendering", () => {
|
||||||
|
it("auto is off on Linux, where WebGL falls back to software rendering", () => {
|
||||||
|
expect(resolveTerminalGpuRendering(null, LINUX)).toBe(false);
|
||||||
|
expect(resolveTerminalGpuRendering(undefined, LINUX)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto is on elsewhere", () => {
|
||||||
|
expect(resolveTerminalGpuRendering(null, MAC)).toBe(true);
|
||||||
|
expect(resolveTerminalGpuRendering(null, WINDOWS)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an explicit setting wins on every platform", () => {
|
||||||
|
expect(resolveTerminalGpuRendering(true, LINUX)).toBe(true);
|
||||||
|
expect(resolveTerminalGpuRendering(false, MAC)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Decides whether the terminal loads `@xterm/addon-webgl`.
|
||||||
|
*
|
||||||
|
* Split out of `TerminalView` so it can be unit-tested without standing up a
|
||||||
|
* terminal, and so the platform rule lives in exactly one place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** True when the webview is running on Linux (WebKitGTK), excluding Android. */
|
||||||
|
export function isLinuxWebview(userAgent: string): boolean {
|
||||||
|
return /\bLinux\b/.test(userAgent) && !/\bAndroid\b/.test(userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective WebGL setting.
|
||||||
|
*
|
||||||
|
* `setting` is `AppSettings.terminal_gpu_rendering`: `true`/`false` force the
|
||||||
|
* answer, `null`/`undefined` mean auto. Auto is on everywhere except Linux —
|
||||||
|
* there the app disables WebKitGTK's DMA-BUF renderer at startup (triple-c#34),
|
||||||
|
* which leaves WebGL present but software-rasterised, so loading the addon is
|
||||||
|
* slower than the canvas renderer it would otherwise have fallen back to.
|
||||||
|
*/
|
||||||
|
export function resolveTerminalGpuRendering(
|
||||||
|
setting: boolean | null | undefined,
|
||||||
|
userAgent: string,
|
||||||
|
): boolean {
|
||||||
|
if (typeof setting === "boolean") return setting;
|
||||||
|
return !isLinuxWebview(userAgent);
|
||||||
|
}
|
||||||
@@ -290,6 +290,52 @@ export interface AppSettings {
|
|||||||
stt: SttSettings;
|
stt: SttSettings;
|
||||||
gateway: GatewaySettings;
|
gateway: GatewaySettings;
|
||||||
global_claude_code_settings: ClaudeCodeSettings | null;
|
global_claude_code_settings: ClaudeCodeSettings | null;
|
||||||
|
/** Whether the terminal loads the WebGL renderer. `null` is auto: on
|
||||||
|
* everywhere except Linux, where the DMA-BUF workaround leaves WebGL
|
||||||
|
* backed by software rasterisation and the addon ends up slower than the
|
||||||
|
* canvas renderer it would otherwise fall back to. See
|
||||||
|
* `resolveTerminalGpuRendering` in `lib/terminalRenderer.ts`. */
|
||||||
|
terminal_gpu_rendering: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What `preview_settings_import` returns before anything is applied —
|
||||||
|
* counts and presence flags only, never a secret value itself. Built from
|
||||||
|
* this, not from the raw import file, which the frontend never sees. */
|
||||||
|
export interface SettingsImportPreview {
|
||||||
|
exported_at: string;
|
||||||
|
app_version: string;
|
||||||
|
custom_env_var_count: number;
|
||||||
|
gateway_model_count: number;
|
||||||
|
has_claude_code_settings: boolean;
|
||||||
|
has_claude_oauth_token: boolean;
|
||||||
|
has_gateway_api_key: boolean;
|
||||||
|
has_gateway_master_key: boolean;
|
||||||
|
has_web_terminal_access_token: boolean;
|
||||||
|
/** Whether the import turns the web terminal on — surfaced separately
|
||||||
|
* from the token above since either can be true without the other, and
|
||||||
|
* "this enables a service that listens on your network" must not hide
|
||||||
|
* inside a generic "settings replaced" summary. */
|
||||||
|
enables_web_terminal: boolean;
|
||||||
|
/** Non-blank custom base URLs the import would set — endpoints, not
|
||||||
|
* secrets, so shown verbatim to disclose a redirect of model traffic. */
|
||||||
|
ollama_base_url: string | null;
|
||||||
|
llamacpp_base_url: string | null;
|
||||||
|
openai_compatible_base_url: string | null;
|
||||||
|
gateway_api_base: string | null;
|
||||||
|
/** Whether the import sets a custom Docker image, and its name if so —
|
||||||
|
* this is the image every project container is created from, so worth
|
||||||
|
* more attention than an ordinary setting. */
|
||||||
|
image_source: ImageSource;
|
||||||
|
custom_image_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What `apply_settings_import` returns: the settings that were actually
|
||||||
|
* saved, plus a note for each keychain secret the import carried but could
|
||||||
|
* not be restored (a partial keychain failure must not read as unqualified
|
||||||
|
* success just because the settings half went through). */
|
||||||
|
export interface SettingsImportOutcome {
|
||||||
|
settings: AppSettings;
|
||||||
|
secret_restore_warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What `inspect_ca_cert_path` reports about a corporate CA path. Errors ride
|
/** What `inspect_ca_cert_path` reports about a corporate CA path. Errors ride
|
||||||
@@ -519,6 +565,16 @@ export interface SchedulerNotification {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One project note. Mirrors `models::Note` — field names are the Rust ones. */
|
||||||
|
export interface Note {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
pinned: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Auth bridge ──────────────────────────────────────────────────────────────
|
// ── Auth bridge ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Which loopback family the container-side listener was found on.
|
/** Which loopback family the container-side listener was found on.
|
||||||
|
|||||||
@@ -112,3 +112,27 @@ describe("toasts", () => {
|
|||||||
expect(toasts()).toHaveLength(2);
|
expect(toasts()).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("terminal focus requests", () => {
|
||||||
|
beforeEach(() => useAppState.setState({ pendingTerminalFocus: null }));
|
||||||
|
|
||||||
|
const pending = () => useAppState.getState().pendingTerminalFocus;
|
||||||
|
|
||||||
|
it("names the session that should take focus", () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
expect(pending()).toBe("s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consumed once, exactly like `pendingHomeTab`. Without the clear, the
|
||||||
|
// second send to a terminal already holding the request would set the same
|
||||||
|
// value, no state would change, and no effect would re-run — which is the
|
||||||
|
// failure this whole mechanism exists to fix.
|
||||||
|
it("is cleared once consumed, so the same terminal can be asked again", () => {
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
useAppState.getState().clearPendingTerminalFocus();
|
||||||
|
expect(pending()).toBeNull();
|
||||||
|
|
||||||
|
useAppState.getState().requestTerminalFocus("s1");
|
||||||
|
expect(pending()).toBe("s1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+137
-1
@@ -1,5 +1,12 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo } from "../lib/types";
|
import type {
|
||||||
|
Project,
|
||||||
|
TerminalSession,
|
||||||
|
AppSettings,
|
||||||
|
UpdateInfo,
|
||||||
|
ImageUpdateInfo,
|
||||||
|
Note,
|
||||||
|
} from "../lib/types";
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
|
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
|
||||||
|
|
||||||
@@ -19,6 +26,54 @@ function persistSidebarCollapsed(value: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NOTES_DOCK_KEY = "triple-c.notes.dock";
|
||||||
|
const NOTES_DOCK_WIDTH_KEY = "triple-c.notes.dock.width";
|
||||||
|
|
||||||
|
/** Wide enough for a note, narrow enough to leave a usable terminal. */
|
||||||
|
export const NOTES_DOCK_MIN_WIDTH = 260;
|
||||||
|
export const NOTES_DOCK_MAX_WIDTH = 720;
|
||||||
|
export const NOTES_DOCK_DEFAULT_WIDTH = 352;
|
||||||
|
|
||||||
|
function loadNotesDockOpen(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(NOTES_DOCK_KEY) === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistNotesDockOpen(value: boolean) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(NOTES_DOCK_KEY, value ? "1" : "0");
|
||||||
|
} catch {
|
||||||
|
// ignore — storage may be unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamped on the way in as well as out: a stored value can be anything a
|
||||||
|
* previous version, a hand edit, or a different screen left behind. */
|
||||||
|
export function clampDockWidth(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return NOTES_DOCK_DEFAULT_WIDTH;
|
||||||
|
return Math.min(NOTES_DOCK_MAX_WIDTH, Math.max(NOTES_DOCK_MIN_WIDTH, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNotesDockWidth(): number {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(NOTES_DOCK_WIDTH_KEY);
|
||||||
|
return raw === null ? NOTES_DOCK_DEFAULT_WIDTH : clampDockWidth(Number(raw));
|
||||||
|
} catch {
|
||||||
|
return NOTES_DOCK_DEFAULT_WIDTH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistNotesDockWidth(value: number) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(NOTES_DOCK_WIDTH_KEY, String(value));
|
||||||
|
} catch {
|
||||||
|
// ignore — storage may be unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The main area hosts two tab kinds — terminals and Project Home views — in a
|
* The main area hosts two tab kinds — terminals and Project Home views — in a
|
||||||
* single ordered strip. Tabs are addressed by a string key so one array can
|
* single ordered strip. Tabs are addressed by a string key so one array can
|
||||||
@@ -89,6 +144,21 @@ interface AppState {
|
|||||||
/** Consumed once by `ProjectHome`, then cleared. */
|
/** Consumed once by `ProjectHome`, then cleared. */
|
||||||
pendingHomeTab: { projectId: string; tab: string } | null;
|
pendingHomeTab: { projectId: string; tab: string } | null;
|
||||||
clearPendingHomeTab: () => void;
|
clearPendingHomeTab: () => void;
|
||||||
|
/**
|
||||||
|
* Ask a terminal to take keyboard focus.
|
||||||
|
*
|
||||||
|
* `TerminalView` already focuses when its tab *becomes* active, which covers
|
||||||
|
* switching to a terminal. It cannot cover being asked to focus the terminal
|
||||||
|
* that is already on screen — nothing changes, so no effect re-runs — and
|
||||||
|
* that is the ordinary case for the notes dock, which sits beside the
|
||||||
|
* terminal it sends to.
|
||||||
|
*
|
||||||
|
* Consumed once and cleared, like `pendingHomeTab`: holding the id would
|
||||||
|
* make a second request for the same terminal a no-op state write.
|
||||||
|
*/
|
||||||
|
pendingTerminalFocus: string | null;
|
||||||
|
requestTerminalFocus: (sessionId: string) => void;
|
||||||
|
clearPendingTerminalFocus: () => void;
|
||||||
closeHomeTab: (projectId: string) => void;
|
closeHomeTab: (projectId: string) => void;
|
||||||
setActiveTabKey: (key: string) => void;
|
setActiveTabKey: (key: string) => void;
|
||||||
cycleTab: (delta: number) => void;
|
cycleTab: (delta: number) => void;
|
||||||
@@ -98,6 +168,29 @@ interface AppState {
|
|||||||
/** Nudge the active tab left/right — the keyboard route to the same thing. */
|
/** Nudge the active tab left/right — the keyboard route to the same thing. */
|
||||||
moveActiveTab: (delta: number) => void;
|
moveActiveTab: (delta: number) => void;
|
||||||
|
|
||||||
|
// Per-project notes, cached from the backend.
|
||||||
|
//
|
||||||
|
// Rust is the source of truth and this is a cache — but it has to be *one*
|
||||||
|
// cache. Notes are shown by two surfaces at once (the Project Home sub-tab
|
||||||
|
// and the dock, which resolves to the same project), and a hook-local
|
||||||
|
// `useState` in each gave them independent copies: an edit made in the dock
|
||||||
|
// was invisible to the tab, and the tab's next blur wrote its stale record
|
||||||
|
// back over it with no error and no indicator. Keyed by project id so a
|
||||||
|
// response that lands after the user has moved on updates the project it
|
||||||
|
// belongs to instead of whichever one is on screen.
|
||||||
|
//
|
||||||
|
// This is also the boundary a detached notes window would need: swap the
|
||||||
|
// transport for a `notes-changed` event and both windows feed the same slice.
|
||||||
|
notesByProject: Record<string, Note[]>;
|
||||||
|
/**
|
||||||
|
* Projects with a `list_notes` in flight, so two panels mounting for the
|
||||||
|
* same project make one read rather than two, and so a panel whose project
|
||||||
|
* has never been read can tell "loading" from "no notes".
|
||||||
|
*/
|
||||||
|
notesLoading: Record<string, boolean>;
|
||||||
|
setProjectNotes: (projectId: string, notes: Note[]) => void;
|
||||||
|
setNotesLoading: (projectId: string, loading: boolean) => void;
|
||||||
|
|
||||||
// Inline container progress, replacing the blocking progress modal.
|
// Inline container progress, replacing the blocking progress modal.
|
||||||
containerProgress: Record<string, string>;
|
containerProgress: Record<string, string>;
|
||||||
setContainerProgress: (projectId: string, message: string | null) => void;
|
setContainerProgress: (projectId: string, message: string | null) => void;
|
||||||
@@ -127,6 +220,13 @@ interface AppState {
|
|||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||||
toggleSidebarCollapsed: () => void;
|
toggleSidebarCollapsed: () => void;
|
||||||
|
/** The notes dock, visible over any tab including a terminal. */
|
||||||
|
notesDockOpen: boolean;
|
||||||
|
setNotesDockOpen: (open: boolean) => void;
|
||||||
|
toggleNotesDock: () => void;
|
||||||
|
/** Dock width in CSS px, clamped and persisted per machine. */
|
||||||
|
notesDockWidth: number;
|
||||||
|
setNotesDockWidth: (width: number) => void;
|
||||||
dockerAvailable: boolean | null;
|
dockerAvailable: boolean | null;
|
||||||
setDockerAvailable: (available: boolean | null) => void;
|
setDockerAvailable: (available: boolean | null) => void;
|
||||||
imageExists: boolean | null;
|
imageExists: boolean | null;
|
||||||
@@ -267,6 +367,9 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
pendingHomeTab: null,
|
pendingHomeTab: null,
|
||||||
clearPendingHomeTab: () => set({ pendingHomeTab: null }),
|
clearPendingHomeTab: () => set({ pendingHomeTab: null }),
|
||||||
|
pendingTerminalFocus: null,
|
||||||
|
requestTerminalFocus: (sessionId) => set({ pendingTerminalFocus: sessionId }),
|
||||||
|
clearPendingTerminalFocus: () => set({ pendingTerminalFocus: null }),
|
||||||
closeHomeTab: (projectId) =>
|
closeHomeTab: (projectId) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const key = homeTabKey(projectId);
|
const key = homeTabKey(projectId);
|
||||||
@@ -340,6 +443,22 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
return { tabOrder };
|
return { tabOrder };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
notesByProject: {},
|
||||||
|
notesLoading: {},
|
||||||
|
setProjectNotes: (projectId, notes) =>
|
||||||
|
set((state) => ({
|
||||||
|
notesByProject: { ...state.notesByProject, [projectId]: notes },
|
||||||
|
})),
|
||||||
|
setNotesLoading: (projectId, loading) =>
|
||||||
|
set((state) => {
|
||||||
|
if ((state.notesLoading[projectId] ?? false) === loading) return {};
|
||||||
|
const next = { ...state.notesLoading };
|
||||||
|
if (loading) next[projectId] = true;
|
||||||
|
else delete next[projectId];
|
||||||
|
return { notesLoading: next };
|
||||||
|
}),
|
||||||
|
|
||||||
// Container progress
|
// Container progress
|
||||||
containerProgress: {},
|
containerProgress: {},
|
||||||
setContainerProgress: (projectId, message) =>
|
setContainerProgress: (projectId, message) =>
|
||||||
@@ -396,6 +515,23 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
persistSidebarCollapsed(next);
|
persistSidebarCollapsed(next);
|
||||||
return { sidebarCollapsed: next };
|
return { sidebarCollapsed: next };
|
||||||
}),
|
}),
|
||||||
|
notesDockOpen: loadNotesDockOpen(),
|
||||||
|
setNotesDockOpen: (open) => {
|
||||||
|
persistNotesDockOpen(open);
|
||||||
|
set({ notesDockOpen: open });
|
||||||
|
},
|
||||||
|
toggleNotesDock: () =>
|
||||||
|
set((state) => {
|
||||||
|
const open = !state.notesDockOpen;
|
||||||
|
persistNotesDockOpen(open);
|
||||||
|
return { notesDockOpen: open };
|
||||||
|
}),
|
||||||
|
notesDockWidth: loadNotesDockWidth(),
|
||||||
|
setNotesDockWidth: (width) => {
|
||||||
|
const clamped = clampDockWidth(width);
|
||||||
|
persistNotesDockWidth(clamped);
|
||||||
|
set({ notesDockWidth: clamped });
|
||||||
|
},
|
||||||
dockerAvailable: null,
|
dockerAvailable: null,
|
||||||
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
||||||
imageExists: null,
|
imageExists: null,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
clampDockWidth,
|
||||||
|
NOTES_DOCK_MIN_WIDTH,
|
||||||
|
NOTES_DOCK_MAX_WIDTH,
|
||||||
|
NOTES_DOCK_DEFAULT_WIDTH,
|
||||||
|
} from "./appState";
|
||||||
|
|
||||||
|
describe("clampDockWidth", () => {
|
||||||
|
it("keeps a sensible width", () => {
|
||||||
|
expect(clampDockWidth(400)).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to squeeze the dock into uselessness", () => {
|
||||||
|
expect(clampDockWidth(10)).toBe(NOTES_DOCK_MIN_WIDTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to squeeze the terminal into uselessness", () => {
|
||||||
|
expect(clampDockWidth(5000)).toBe(NOTES_DOCK_MAX_WIDTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for a stored value that is not a number", () => {
|
||||||
|
// localStorage holds strings and can carry anything a previous version,
|
||||||
|
// a hand edit, or a different screen left behind.
|
||||||
|
expect(clampDockWidth(Number("banana"))).toBe(NOTES_DOCK_DEFAULT_WIDTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds, because a fractional px width blurs the border", () => {
|
||||||
|
expect(clampDockWidth(400.6)).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The pure function above is only half the contract: the brief calls out that
|
||||||
|
// the clamp must guard the *read* path too, because localStorage can carry
|
||||||
|
// anything a previous version, a hand edit, or a different screen left
|
||||||
|
// behind. These tests exercise the real store initialization — seeding
|
||||||
|
// localStorage, then re-importing the module fresh so its top-level
|
||||||
|
// `loadNotesDockWidth()` call runs against the seeded value — rather than a
|
||||||
|
// function pulled out just to make this testable. A future refactor that
|
||||||
|
// dropped the clamp from the load path while keeping it on the write path
|
||||||
|
// would fail these.
|
||||||
|
describe("notesDockWidth store initialization", () => {
|
||||||
|
const WIDTH_KEY = "triple-c.notes.dock.width";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.removeItem(WIDTH_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps an out-of-range stored value on load", async () => {
|
||||||
|
localStorage.setItem(WIDTH_KEY, "99999");
|
||||||
|
vi.resetModules();
|
||||||
|
const { useAppState } = await import("./appState");
|
||||||
|
expect(useAppState.getState().notesDockWidth).toBe(NOTES_DOCK_MAX_WIDTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default for a non-numeric stored value on load", async () => {
|
||||||
|
localStorage.setItem(WIDTH_KEY, "banana");
|
||||||
|
vi.resetModules();
|
||||||
|
const { useAppState } = await import("./appState");
|
||||||
|
expect(useAppState.getState().notesDockWidth).toBe(NOTES_DOCK_DEFAULT_WIDTH);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,359 @@
|
|||||||
|
# Project Notes — design
|
||||||
|
|
||||||
|
**Date:** 2026-09-01 · **Baseline:** v0.4 · Companion to [ROADMAP.md](../../../ROADMAP.md)
|
||||||
|
and [DESIGN-REVIEW.md](../../../DESIGN-REVIEW.md).
|
||||||
|
|
||||||
|
A per-project notes surface, with a per-note **Send to agent** action that puts the note
|
||||||
|
into a running Claude session's prompt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this earns a slot
|
||||||
|
|
||||||
|
DESIGN-REVIEW's coherence test says every screen answers exactly one question. Notes
|
||||||
|
answers *"what do I want to hand this agent, and what did I keep learning here?"* — and it
|
||||||
|
answers it **while the container is stopped**, which is the gap the stop/start container
|
||||||
|
model creates and the same reasoning that made Sessions/Resume the flagship.
|
||||||
|
|
||||||
|
Two things already do part of this job, and the design is shaped to avoid both:
|
||||||
|
|
||||||
|
- `Project.claude_instructions` (`models/project.rs:405`, editor at
|
||||||
|
`components/projects/ClaudeInstructionsEditor.tsx`) is per-project free text merged into
|
||||||
|
the container's `CLAUDE.md` on every start. It is **ambient** — always in context, never
|
||||||
|
addressed. Notes are **discrete and fired on demand**. If Notes drifts into a second
|
||||||
|
instructions box, it is redundant with a feature that already ships.
|
||||||
|
- A `NOTES.md` in the workspace is readable by the agent already, but unreadable by the
|
||||||
|
user when the container is stopped, and invisible to the fleet view.
|
||||||
|
|
||||||
|
What Notes uniquely adds is *addressable items with a fire-at-the-session action*.
|
||||||
|
|
||||||
|
## Decisions taken
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| Audience | Human scratchpad **and** agent prompts, one surface | See "no note types" below |
|
||||||
|
| Storage | Own file per project, host-side | Keeps prose out of `projects.json`; works with the container stopped |
|
||||||
|
| Send target | Project's own sessions; picker when >1 | Never guesses; mirrors STT's target-pinning guard |
|
||||||
|
| Surface | Side dock that takes space **inward**; never resizes the OS window | Phase 0 spike: growing corrupts under native Wayland, §6.1 |
|
||||||
|
| Formatting | Plain text, no markdown | It is a scratchpad; see §3 |
|
||||||
|
| Tab position | Last, after Browser | A companion to the work, not a step in it |
|
||||||
|
|
||||||
|
**No note *types*.** A note is a title plus a body. What makes one "for the agent" is that
|
||||||
|
you pressed the button, not a mode set at creation. The moment there is a "prompt note" vs
|
||||||
|
"scratch note" toggle, the pane is two features wearing one coat, and every note costs a
|
||||||
|
classification decision at the moment of writing — which is the moment the user is least
|
||||||
|
willing to make one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Storage
|
||||||
|
|
||||||
|
New `app/src-tauri/src/storage/notes_store.rs`, modeled on `migration_store.rs` rather than
|
||||||
|
on `projects_store.rs`:
|
||||||
|
|
||||||
|
```
|
||||||
|
<data_dir>/triple-c/notes/{project_id}.json
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`sanitize()` on the project id**, copied from `migration_store.rs:41-46`. The id arrives
|
||||||
|
over IPC; it must not be able to steer the write.
|
||||||
|
- **Atomic *and durable* write** — `.tmp`, `sync_all()`, `rename()`, then fsync the
|
||||||
|
directory, per `migration_store.rs:203-261` rather than `projects_store.rs:167-179`. That
|
||||||
|
file's comment is explicit that write-temp-then-rename alone is only half of it: `fs::write`
|
||||||
|
returns once the bytes are in the page cache, so losing power in the window leaves the
|
||||||
|
rename applied and the data not written — a truncated file produced by the very code meant
|
||||||
|
to prevent one. Notes are user prose; that is the data least worth losing to a half-write.
|
||||||
|
- **Corrupt file is copied aside and left in place**, per `migration_store.rs:49-125` —
|
||||||
|
timestamped, capped, and never overwriting an earlier copy, because the first copy is the
|
||||||
|
one taken before anything rewrote the file.
|
||||||
|
- **Path resolution is split for testability.** `dirs::data_dir()` is resolved in thin public
|
||||||
|
wrappers; the real work takes an explicit `&Path`. `ProjectsStore::new()` hardcodes
|
||||||
|
`dirs::data_dir()` and is therefore not constructible against a temp dir, which is why its
|
||||||
|
own tests only exercise free functions. The notes store should not inherit that limit.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
struct Note {
|
||||||
|
id: String, // uuid v4
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
pinned: bool,
|
||||||
|
created_at: String, // RFC 3339
|
||||||
|
updated_at: String,
|
||||||
|
}
|
||||||
|
struct ProjectNotes { version: u32, notes: Vec<Note> }
|
||||||
|
```
|
||||||
|
|
||||||
|
Order is pinned-first then `updated_at` descending. Manual reordering is deliberately out.
|
||||||
|
|
||||||
|
**`pinned` is reserved, and nothing in v1 sets it.** The field is persisted and sorted on, but
|
||||||
|
there is no pin control and no pinned indicator anywhere in the UI, so in v1 every note sorts
|
||||||
|
by `updated_at` descending and the pinned-first half of the rule is inert. It is carried from
|
||||||
|
the start because it is a field in a file: adding one later means every reader has to tolerate
|
||||||
|
its absence forever, while an unused `bool` with a serde default costs nothing. Pinning itself
|
||||||
|
is out of scope — see §8.
|
||||||
|
|
||||||
|
### Why not a field on `Project`
|
||||||
|
|
||||||
|
`projects.json` is written on **every blur** by the debounced `useProjectSave` path
|
||||||
|
(`hooks/useSaveState.ts`, threaded through `ProjectHome.tsx:79-81` into Overview and
|
||||||
|
Config). Long user prose on that record means (a) the whole project list is rewritten every
|
||||||
|
time a note changes, and (b) a note edit and a Config edit can race, with the loser's write
|
||||||
|
clobbering the winner's. `migration_store.rs:1-12` already documents this exact reasoning
|
||||||
|
for why *it* is not in `projects.json`. Notes inherit it.
|
||||||
|
|
||||||
|
A per-project file also means a corrupt notes file loses notes for one project, not the
|
||||||
|
project list.
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
`remove_project` deletes the project's notes file. A failure there is logged, never fatal —
|
||||||
|
an orphaned notes file is harmless, a project that cannot be removed is not.
|
||||||
|
|
||||||
|
## 2. Commands and frontend state
|
||||||
|
|
||||||
|
Registered in `lib.rs` via `generate_handler!`. Per CLAUDE.md, application commands need
|
||||||
|
**no** entry in `capabilities/default.json`.
|
||||||
|
|
||||||
|
- `list_notes(projectId) -> Vec<Note>`
|
||||||
|
- `save_note(projectId, note) -> Note` — upsert; stamps `updated_at` backend-side
|
||||||
|
- `delete_note(projectId, noteId)`
|
||||||
|
|
||||||
|
There is deliberately **no whole-list setter**. Bulk writes are the clobbering mechanism the
|
||||||
|
storage choice above exists to avoid.
|
||||||
|
|
||||||
|
`notes_store` is a **free-function module** keyed by project id, exactly like
|
||||||
|
`migration_store` — no struct, nothing held in `AppState`, no in-memory copy of the notes.
|
||||||
|
`ProjectsStore`'s `Mutex` exists because it caches the project list in memory; a notes store
|
||||||
|
that reads and writes the file per call has nothing to cache and nothing to guard. What it
|
||||||
|
does need is that each upsert's read-modify-write is not interleaved with another's, so the
|
||||||
|
module holds one process-wide write lock (`OnceLock<Mutex<()>>`, the idiom already in
|
||||||
|
`browser_view/popout.rs`) taken for the read-modify-write, not for the read path.
|
||||||
|
|
||||||
|
Frontend: wrappers in `lib/tauri-commands.ts`, a `hooks/useNotes.ts`, and notes cached in
|
||||||
|
zustand keyed by project id. Rust is the source of truth; the cache is a cache.
|
||||||
|
|
||||||
|
The dock and the tab live in one webview, so zustand alone suffices. The store boundary is
|
||||||
|
drawn so that a future detached window (§8) only swaps the transport: Rust emits a
|
||||||
|
`notes-changed` event, both windows listen.
|
||||||
|
|
||||||
|
## 3. Editor — plain text, deliberately
|
||||||
|
|
||||||
|
A note is a title and a `<textarea>`, saved on blur, following
|
||||||
|
`ClaudeInstructionsEditor.tsx` (which saves in `onBlur` and holds no timer) and reporting
|
||||||
|
the outcome through `ui/SaveIndicator`. There is no debounce anywhere in the existing save
|
||||||
|
path — `useSaveState.ts`'s only timer is a 2500 ms reset of the "Saved ✓" label — and notes
|
||||||
|
add none. **No rich editor, no markdown library, and no markdown
|
||||||
|
rendering** — it is a scratchpad for reminders, and it stays one.
|
||||||
|
|
||||||
|
The body is stored and displayed exactly as typed. There is no view/edit mode split, so
|
||||||
|
there is no state to get wrong and no moment where the text the user is looking at is not
|
||||||
|
the text that would be sent.
|
||||||
|
|
||||||
|
This also keeps `renderMarkdown()` (`components/layout/HelpDialog.tsx:55-160`) where it is.
|
||||||
|
It was written for Help content, it entity-escapes before converting to make its
|
||||||
|
`dangerouslySetInnerHTML` sink safe, and `HelpDialog.test.tsx` asserts that escaping as a
|
||||||
|
security rule. Reusing it here would mean extracting it and giving a hand-rolled HTML
|
||||||
|
converter a second caller with different content — cost and risk, for formatting a
|
||||||
|
scratchpad. If notes later need rendering, that extraction is the change to make; it is not
|
||||||
|
this change.
|
||||||
|
|
||||||
|
One consequence worth stating: the text sent to the agent is byte-for-byte what is in the
|
||||||
|
box. Nothing is transformed on the way out except the newline substitution in §5.
|
||||||
|
|
||||||
|
## 4. The Notes tab
|
||||||
|
|
||||||
|
- One entry in the `TABS` registry (`components/projects/home/ProjectHome.tsx:24-33`), one
|
||||||
|
line in the panel switch (`:237-257`), one new `home/NotesTab.tsx` taking the sibling prop
|
||||||
|
shape `{ project: Project }`.
|
||||||
|
- Order: Notes goes **last**, after Browser — **Overview / Sessions / Automation / Config /
|
||||||
|
Files / Browser / Notes**. It is a companion to the work, not a step in it, and the
|
||||||
|
existing order runs roughly from "what is this" to "what is in it".
|
||||||
|
- Layout is master/detail: title list left, editor right.
|
||||||
|
|
||||||
|
Note that the active sub-tab is local `useState` (`ProjectHome.tsx:47`) and is not
|
||||||
|
persisted, so a closed and reopened home tab returns to Overview. Notes inherits that; it is
|
||||||
|
not worth changing here.
|
||||||
|
|
||||||
|
## 5. Send to agent
|
||||||
|
|
||||||
|
### The newline problem, and why it is already solved
|
||||||
|
|
||||||
|
A dictated STT phrase has no newlines. A note body does. Typed as raw keystrokes, every
|
||||||
|
`\n` in a body **submits a separate prompt** — the note would arrive as N truncated
|
||||||
|
messages.
|
||||||
|
|
||||||
|
The answer is in the codebase already. `components/terminal/TerminalView.tsx` (~:400-430)
|
||||||
|
handles Shift+Enter by sending `\x1b\r`, and its comment states these are "the in-band
|
||||||
|
bytes, not a guess," with an explicit warning **not** to simplify to `\n` because a shell
|
||||||
|
would *run* the line. So:
|
||||||
|
|
||||||
|
```
|
||||||
|
payload = note.body.replace(/\r?\n/g, "\x1b\r")
|
||||||
|
```
|
||||||
|
|
||||||
|
sent with **no trailing CR** — the user presses Enter. Same rationale as STT sending
|
||||||
|
without one: a note is longer than a dictated sentence, so the chance of wanting an edit
|
||||||
|
before firing is higher, and an unsent prompt is recoverable while a sent one is not.
|
||||||
|
|
||||||
|
Two consequences follow from that same comment:
|
||||||
|
|
||||||
|
1. **Only `sessionType === "claude"` sessions are offered as targets.** `bash -l`'s readline
|
||||||
|
has no binding for `\e\r` and answers with a bell. Bash tabs are not listed in the picker
|
||||||
|
at all.
|
||||||
|
2. **The sequence lives in one shared helper**, not a second `"\x1b\r"` literal. The
|
||||||
|
knowledge in that comment is hard-won and must not be duplicated away from it.
|
||||||
|
|
||||||
|
### Target resolution
|
||||||
|
|
||||||
|
`TerminalSession` (`lib/types.ts:228-234`) already carries `projectId`, `projectName`,
|
||||||
|
`sessionType` and `sessionName`, so no new plumbing is needed.
|
||||||
|
|
||||||
|
| Claude sessions for this project | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| 0 | Button disabled, "no running session for this project" |
|
||||||
|
| 1 | Send |
|
||||||
|
| >1 | Menu of session display names (`Project.renamed_session_names` where set) |
|
||||||
|
|
||||||
|
The display-name rule is currently written **twice**, both copies non-exported and local to
|
||||||
|
`MainTabs.tsx` — `tabLabel` (:192-203) and inline in `renderTab` (:362-367). The picker would
|
||||||
|
be a third copy of a rule that already disagrees with itself the moment one copy is edited,
|
||||||
|
so it is extracted once to a shared helper and both existing sites call it. That is a
|
||||||
|
targeted improvement to code this feature depends on, not unrelated refactoring.
|
||||||
|
|
||||||
|
- **The target is pinned at click time**, per the hazard `useSTT.ts:20,30` guards against
|
||||||
|
(it pins at record-start so text does not land in whatever tab is active at stop time).
|
||||||
|
- Transport is `useTerminal`'s module-scoped ordered queue (`hooks/useTerminal.ts:32-85`,
|
||||||
|
exposed as `sendInput` at `:135-141`) → `terminal_input` → `exec_manager.send_input`. That
|
||||||
|
queue exists because parallel `invoke`s raced the session mutex and reordered keystrokes
|
||||||
|
(`useTerminal.ts:7-31`); a multi-line note is exactly the payload that would expose it.
|
||||||
|
- After sending, switch the active tab to that terminal so the user watches it land. This is
|
||||||
|
a courtesy, not a correctness requirement: if it cannot be delivered, the send still
|
||||||
|
succeeded.
|
||||||
|
- **Body only, not the title.** The title is an index label for the list, not content.
|
||||||
|
|
||||||
|
Explicitly *not* reused: `useProjectActions.ts:104-124`'s `openTerminalWithCommand`, which
|
||||||
|
opens a shell then types after a `setTimeout(700)`. Starting a container as a side effect of
|
||||||
|
clicking a note is too large an implicit action, and that timing hack should not spread.
|
||||||
|
|
||||||
|
## 6. Surface — a dock that takes space inward
|
||||||
|
|
||||||
|
`components/layout/NotesDock.tsx`, a flex sibling of the tab panels in `App.tsx:139-160`, so
|
||||||
|
it is visible over **any** top-level tab including Terminal. This is the point of the dock:
|
||||||
|
Project Home and Terminal are sibling top-level tabs (`layout/MainTabs.tsx`), so a
|
||||||
|
Notes-only-as-sub-tab design hides notes exactly when the agent is running.
|
||||||
|
|
||||||
|
Opening the dock **takes space from inside the window**. The terminal narrows and reflows;
|
||||||
|
the OS window is never resized or moved. `TerminalView.tsx:643-656` already has a
|
||||||
|
rAF-throttled `ResizeObserver` that calls `fitAddon.fit()` then `resize(sessionId, cols,
|
||||||
|
rows)` → `terminal_resize`, so narrowing reflows xterm *and* resizes the container PTY
|
||||||
|
correctly, with no new code.
|
||||||
|
|
||||||
|
- Width is drag-resizable, persisted to a `triple-c.notes.dock` localStorage key. Precedent:
|
||||||
|
`triple-c.sidebar.collapsed` (`store/appState.ts:4-20`) is the app's only such key today.
|
||||||
|
- **The dock follows the active tab's project** — terminal tab → that session's project,
|
||||||
|
home tab → that project, nothing active → empty state. `activeTabKey`/`tabKeyId` plus
|
||||||
|
`TerminalSession.projectId` already provide this.
|
||||||
|
- **No window geometry code at all.** No `set_size`, no `set_position`, no monitor work-area
|
||||||
|
arithmetic, no platform checks. §6.1 is why.
|
||||||
|
|
||||||
|
### 6.1 Why the dock does not widen the window — Phase 0 spike
|
||||||
|
|
||||||
|
The original design had the dock "expand outward" by widening the OS window, so the terminal
|
||||||
|
kept its size. A throwaway Tauri app (`geo-spike`) was built and run on the target desktop —
|
||||||
|
KDE Plasma, Wayland session, 2026-09-01 — because the app contains no window-geometry code
|
||||||
|
today and the behavior could not be predicted. It was run twice, once per GDK backend,
|
||||||
|
which turned out to matter more than the platform.
|
||||||
|
|
||||||
|
**Under XWayland** (what every Tauri AppImage gets, because `linuxdeploy-plugin-gtk` exports
|
||||||
|
`GDK_BACKEND=x11` in `AppRun`, citing
|
||||||
|
[tauri-apps/tauri#8541](https://github.com/tauri-apps/tauri/issues/8541)) everything worked:
|
||||||
|
|
||||||
|
| Test | Result |
|
||||||
|
|---|---|
|
||||||
|
| Grow while floating | asked +420, got +420 — exact |
|
||||||
|
| Shrink back | asked -420, got -420 — exact |
|
||||||
|
| `outer_position()` | readable, correct |
|
||||||
|
| `work_area` | 3840x2099 — correctly excludes the 61px Plasma panel |
|
||||||
|
| Grow while maximized / fullscreen | ignored, as designed |
|
||||||
|
|
||||||
|
**Under native Wayland** (what the `.deb` and `.rpm` builds get, since they carry no such
|
||||||
|
hook) the same binary failed — and failed *silently*, which is the part that decided this:
|
||||||
|
|
||||||
|
| Test | Result |
|
||||||
|
|---|---|
|
||||||
|
| Grow while floating | asked +420, got **+600**; height moved **+276 unrequested** |
|
||||||
|
| Shrink back | asked -420, got **-240**; height **+276** again |
|
||||||
|
| After unmaximize | window reports **5400x2900 on a 4800x2700 monitor** |
|
||||||
|
| `outer_position()` | returned `Ok(0,0)` — for a window that was not at 0,0 |
|
||||||
|
| Grow while maximized / fullscreen | ignored, as designed |
|
||||||
|
| `set_position` | ignored, as expected |
|
||||||
|
|
||||||
|
Two independent failures, either one sufficient:
|
||||||
|
|
||||||
|
1. **Resize compounds.** Under Wayland GTK owns the frame and shadows; both `outer` and
|
||||||
|
`inner` report a 0x0 decoration, so every read-back is inflated by a fixed offset and
|
||||||
|
every write built on a read-back compounds it. There is no size that can be read and
|
||||||
|
safely written back. Three calls in, the window is larger than the display.
|
||||||
|
2. **Position is a confident lie, not an honest failure.** `outer_position()` returned
|
||||||
|
`Ok(0,0)` rather than an error. A design that treats "cannot determine position" as
|
||||||
|
"do not grow" never triggers, because the value looks perfectly valid. The room check
|
||||||
|
duly reported `slack: 2820px, VERDICT: Grow` from a false origin.
|
||||||
|
|
||||||
|
The second point is what rules out a runtime fallback. A clean failure could have been
|
||||||
|
handled; a plausible wrong answer cannot be detected from the value itself.
|
||||||
|
|
||||||
|
Growing therefore works on one packaging channel and corrupts on another — the split is by
|
||||||
|
**packaging, not platform**, which is worse than a platform split because two users on
|
||||||
|
identical hardware and OS would see different behavior. A dock that takes space inward
|
||||||
|
behaves identically on every backend, OS and package, needs no detection, and reuses a
|
||||||
|
resize path that is already exercised by every terminal in the app.
|
||||||
|
|
||||||
|
**Kept as evidence, not as guidance:** `set_position` was honoured under XWayland. The design
|
||||||
|
does not move the window and must not start.
|
||||||
|
|
||||||
|
## 7. Testing
|
||||||
|
|
||||||
|
Vitest + jsdom + React Testing Library for the frontend, `#[cfg(test)]` for Rust, per
|
||||||
|
CLAUDE.md's Testing section.
|
||||||
|
|
||||||
|
**Rust (`notes_store.rs`)**
|
||||||
|
- `sanitize()` rejects traversal and separator characters in a project id
|
||||||
|
- atomic write leaves no `.tmp` behind; a crash mid-write leaves the previous file intact
|
||||||
|
- a corrupt file is moved to `.bak` and the store opens empty rather than erroring
|
||||||
|
- removing a project deletes its notes file; a delete failure does not fail removal
|
||||||
|
|
||||||
|
**Frontend**
|
||||||
|
- send-target resolution at 0 / 1 / N claude sessions, and that bash sessions are excluded
|
||||||
|
- the newline transform: a multi-line body becomes `\x1b\r`-joined, with no trailing CR
|
||||||
|
- the dock's project resolution: terminal tab, home tab, and nothing active
|
||||||
|
- save-on-blur persists, and dock width round-trips through localStorage
|
||||||
|
|
||||||
|
Note the limit `TerminalView.tsx`'s own comment records: jsdom never synthesizes the
|
||||||
|
follow-up keypress, so keyboard-path bugs of that family are invisible to unit tests. The
|
||||||
|
send path is a direct `sendInput` call rather than a synthetic keystroke, which sidesteps
|
||||||
|
that — but anything touching real key handling needs a manual check in Chromium.
|
||||||
|
|
||||||
|
## 8. Out of scope for v1
|
||||||
|
|
||||||
|
- A detached second window for a second monitor. The store boundary in §2 is drawn so it is
|
||||||
|
an additive follow-up: emit `notes-changed` from the store's write path, and add a second
|
||||||
|
narrowly scoped capability granting the notes window `core:event:allow-listen` /
|
||||||
|
`allow-unlisten` — `capabilities/default.json` scopes those to `"windows": ["main"]` today,
|
||||||
|
and cross-window sync needs them. Application commands need no ACL entry (CLAUDE.md, Key
|
||||||
|
Conventions), so only the events require it. `lib.rs:379-387`'s main-window-only close
|
||||||
|
handler would need review at that point.
|
||||||
|
- Syncing notes into the workspace as `.md` for the agent to read unprompted. There is no
|
||||||
|
generic write-a-file-to-container command today (only `write_file_to_container` for image
|
||||||
|
paste and `upload_bytes_to_container` for migration), and a second storage path with a
|
||||||
|
sync direction is a v2 conversation.
|
||||||
|
- Pinning. `Note.pinned` exists on both sides of the IPC boundary and the backend sorts on
|
||||||
|
it, but no UI sets it and none indicates it — see §1. A pin control is a user-facing
|
||||||
|
affordance and belongs in the change that adds it, not in the storage that anticipates it.
|
||||||
|
- Tags, full-text search, manual reordering, note history.
|
||||||
|
- Any change to `claude_instructions`. The two features stay distinct: ambient context
|
||||||
|
versus fired-on-demand items.
|
||||||
|
|
||||||
|
## 9. Open questions
|
||||||
|
|
||||||
|
None. The Phase 0 spike settled the surface (§6.1); every other decision is recorded in the
|
||||||
|
table above.
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# Maintainer: Triple-C Contributors
|
|
||||||
#
|
|
||||||
# This file is regenerated by .gitea/workflows/publish-aur-package.yml on every
|
|
||||||
# publish — pkgver, the source URL and sha256sums are rewritten from the real,
|
|
||||||
# already-uploaded release asset, never guessed. Editing pkgver/source/
|
|
||||||
# sha256sums by hand here only matters until the next automated run overwrites
|
|
||||||
# them; everything else (depends, pkgdesc, package()) is meant to be hand-
|
|
||||||
# maintained normally.
|
|
||||||
#
|
|
||||||
# "-bin" rather than building from source: this repackages the same .deb
|
|
||||||
# build-app.yml already produces and publishes, so a user gets exactly the
|
|
||||||
# binary the project ships and tests, and `makepkg` never needs a Rust
|
|
||||||
# toolchain, Node, or the dozen -dev packages CLAUDE.md lists for building
|
|
||||||
# Triple-C itself. The trade-off is the one every "-bin" package makes: it
|
|
||||||
# assumes the glibc the CI runner (Ubuntu 24.04) linked against is compatible
|
|
||||||
# with the installing system's — true for essentially every currently
|
|
||||||
# supported Arch install, since Arch tracks glibc newer than Ubuntu 24.04
|
|
||||||
# ships, and forward compatibility is the direction that holds.
|
|
||||||
pkgname=triple-c-bin
|
|
||||||
pkgver=0.4.0
|
|
||||||
pkgrel=1
|
|
||||||
pkgdesc="Sandbox Claude Code inside Docker containers"
|
|
||||||
arch=('x86_64')
|
|
||||||
url="https://github.com/shadowdao/triple-c"
|
|
||||||
license=('MIT')
|
|
||||||
# Verified against a real release asset (v0.4.14), not Tauri's generic docs:
|
|
||||||
# downloaded Triple-C_0.4.14_amd64.deb, installed each of these into a real
|
|
||||||
# Arch container, and re-ran `ldd` on the actual binary until nothing came
|
|
||||||
# back "not found". `pango` and `libayatana-appindicator` were both in an
|
|
||||||
# earlier draft — pango isn't directly linked (gtk3 already pulls it in
|
|
||||||
# transitively, and namcap correctly flags declaring it as redundant), and
|
|
||||||
# libayatana-appindicator is in Tauri's own linux dependency list but this
|
|
||||||
# binary never links it at all: there is no tray icon or menu in this app
|
|
||||||
# (see CLAUDE.md's note that `core:menu`/`core:tray` are dropped for the
|
|
||||||
# same reason), so it was never a real dependency to begin with.
|
|
||||||
depends=('cairo' 'desktop-file-utils' 'gdk-pixbuf2' 'glib2' 'gtk3'
|
|
||||||
'hicolor-icon-theme' 'libsoup3' 'webkit2gtk-4.1')
|
|
||||||
optdepends=('docker: to actually run the sandboxed containers'
|
|
||||||
'xdg-utils: opening links from the app in your default browser')
|
|
||||||
provides=('triple-c')
|
|
||||||
conflicts=('triple-c')
|
|
||||||
# !strip: the upstream .deb's binary is already the release build Tauri
|
|
||||||
# produced and tested; re-stripping a prebuilt binary is unnecessary risk for
|
|
||||||
# no benefit. It's also what actually suppresses makepkg's debug-package
|
|
||||||
# machinery here (debug-package extraction requires strip; verified in a
|
|
||||||
# real build — with !strip alone, no debug package is produced at all).
|
|
||||||
# !debug is kept anyway, explicit about intent rather than relying on that
|
|
||||||
# side effect. Without either, makepkg built a usr/src/debug/triple-c-bin
|
|
||||||
# tree containing a dangling .build-id symlink, which is a real namcap
|
|
||||||
# error (not just the empty-directory warning it looks like) — there is no
|
|
||||||
# debug info in this release binary for the machinery to have extracted in
|
|
||||||
# the first place.
|
|
||||||
options=('!strip' '!debug')
|
|
||||||
# Tauri names the asset after `productName` verbatim ("Triple-C"), not the
|
|
||||||
# lowercase Cargo binary name — verified against the real release, not
|
|
||||||
# assumed; a lowercase guess here would 404. The LICENSE fetch is separate
|
|
||||||
# because the .deb itself carries no license file — namcap flags an MIT
|
|
||||||
# package with nothing under /usr/share/licenses/ as an error, correctly.
|
|
||||||
source=("Triple-C_${pkgver}_amd64.deb::https://github.com/shadowdao/triple-c/releases/download/v${pkgver}/Triple-C_${pkgver}_amd64.deb"
|
|
||||||
"LICENSE::https://raw.githubusercontent.com/shadowdao/triple-c/v${pkgver}/LICENSE")
|
|
||||||
sha256sums=('SKIP'
|
|
||||||
'SKIP')
|
|
||||||
|
|
||||||
package() {
|
|
||||||
cd "$srcdir"
|
|
||||||
# A .deb is an ar archive of debian-binary, control.tar.*, data.tar.* — `ar`
|
|
||||||
# (part of base-devel's binutils) pulls just the payload out. Extracting
|
|
||||||
# that tar directly into $pkgdir works here with no path rewriting at all:
|
|
||||||
# verified against the real archive, whose entire payload is
|
|
||||||
# usr/bin/triple-c, usr/share/applications/Triple-C.desktop and
|
|
||||||
# usr/share/icons/hicolor/*/apps/triple-c.png — Tauri's Linux bundle for
|
|
||||||
# this app carries no separate resource directory under usr/lib/, so there
|
|
||||||
# is nothing that could disagree between Debian's and Arch's package trees
|
|
||||||
# for it to land in the wrong place.
|
|
||||||
#
|
|
||||||
# Globbed rather than named literally: the publish workflow discovers the
|
|
||||||
# real asset name from the release itself specifically so a Tauri bundler
|
|
||||||
# naming change can't silently break this — naming the file again here
|
|
||||||
# would throw that away and fail this one line with an opaque "No such
|
|
||||||
# file or directory" instead. `source=()` above guarantees exactly one
|
|
||||||
# `*_amd64.deb` entry, so the glob can only ever match that one file.
|
|
||||||
ar x ./*_amd64.deb
|
|
||||||
tar xf data.tar.* -C "$pkgdir"
|
|
||||||
|
|
||||||
install -Dm644 "$srcdir/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# Arch / CachyOS package
|
|
||||||
|
|
||||||
`PKGBUILD` here is the AUR `triple-c-bin` package's template — see triple-c#34
|
|
||||||
(the "I would like to also have an Arch/CachyOS native version" part of it).
|
|
||||||
|
|
||||||
## Why "-bin"
|
|
||||||
|
|
||||||
It repackages the same `.deb` `build-app.yml` already produces, rather than
|
|
||||||
building from source. That means `makepkg` never needs a Rust toolchain,
|
|
||||||
Node, or the dozen `-dev` packages CLAUDE.md lists for building Triple-C
|
|
||||||
itself — and a user gets exactly the binary the project ships and tests,
|
|
||||||
built on Ubuntu 24.04 in CI. Verified end to end against a real release
|
|
||||||
(v0.4.14): downloaded the actual `.deb`, confirmed every `depends` entry
|
|
||||||
against a real `ldd` of the actual binary (two packages that looked right
|
|
||||||
from Tauri's own docs — `pango`, `libayatana-appindicator` — turned out not
|
|
||||||
to be real dependencies of *this* binary and were dropped), and ran a real
|
|
||||||
`makepkg`/`namcap`/`pacman -U` cycle rather than guessing at the shape.
|
|
||||||
|
|
||||||
## Publishing
|
|
||||||
|
|
||||||
`.gitea/workflows/publish-aur-package.yml` does the actual work: given a
|
|
||||||
version (or "latest" if none is given), it finds that release's real Linux
|
|
||||||
asset on GitHub, downloads it, computes real checksums, renders this
|
|
||||||
template into a version-specific PKGBUILD, validates it with `makepkg` and
|
|
||||||
`namcap` inside a real Arch container, and pushes the result to AUR.
|
|
||||||
|
|
||||||
It is `workflow_dispatch`-only, deliberately — see the workflow file's own
|
|
||||||
header comment for why an automatic trigger isn't safe here (the same reason
|
|
||||||
`sync-release.yml` didn't work and was removed in triple-c#32).
|
|
||||||
|
|
||||||
**Before it can push anything**, an AUR account has to exist and the
|
|
||||||
`triple-c-bin` package has to have been created (or you added as a
|
|
||||||
co-maintainer) under it — both are one-time, manual steps on
|
|
||||||
https://aur.archlinux.org, since there's no API to automate creating an
|
|
||||||
account or a new package. Once that's done, add the account's SSH private
|
|
||||||
key as the `AUR_SSH_PRIVATE_KEY` secret on this repo. Until that secret
|
|
||||||
exists, the workflow fails at the "Push to AUR" step with a message saying
|
|
||||||
so, rather than silently doing nothing.
|
|
||||||
|
|
||||||
## What's hand-maintained vs. generated
|
|
||||||
|
|
||||||
`pkgver`/`pkgrel`/`source`/`sha256sums` in this file are placeholders —
|
|
||||||
the workflow rewrites them for every real publish and never commits the
|
|
||||||
result back here, so don't read this file's `pkgver` as "the last published
|
|
||||||
version." Everything else (`depends`, `pkgdesc`, `package()`) is meant to be
|
|
||||||
edited by hand normally, the same as any other PKGBUILD.
|
|
||||||
|
|
||||||
**A hand-edit made directly in the AUR repo is silently overwritten the
|
|
||||||
next time this workflow runs.** Every run renders fresh from *this*
|
|
||||||
repo's template rather than starting from whatever AUR's copy currently
|
|
||||||
looks like, so a quick fix pushed straight to AUR (bumping `pkgrel` for a
|
|
||||||
packaging-only issue, say) survives only until the next dispatch. Make
|
|
||||||
the fix here instead.
|
|
||||||
Executable
+127
@@ -0,0 +1,127 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Register a Triple-C AppImage with the desktop, so it appears in the app
|
||||||
|
# launcher with its icon instead of only being runnable from a file manager.
|
||||||
|
#
|
||||||
|
# An AppImage is a single executable file and nothing more: it ships a
|
||||||
|
# `.desktop` entry and icons *inside* itself, but nothing on the host ever
|
||||||
|
# reads them, because nothing installed it. This script does what a package
|
||||||
|
# manager's install hooks would — copies the icons into the user's icon theme
|
||||||
|
# and writes a `.desktop` entry pointing at wherever the AppImage actually
|
||||||
|
# lives.
|
||||||
|
#
|
||||||
|
# ./scripts/install-appimage.sh ~/Apps/Triple-C_0.4.17_amd64.AppImage
|
||||||
|
# ./scripts/install-appimage.sh --uninstall
|
||||||
|
#
|
||||||
|
# Everything goes under ~/.local/share, so there is no sudo and no root-owned
|
||||||
|
# file to clean up later. The AppImage itself is never copied or moved — the
|
||||||
|
# launcher entry points at the path you give here, so keep the file somewhere
|
||||||
|
# stable (`~/Apps` or `~/.local/bin`, not `~/Downloads`) or re-run this after
|
||||||
|
# moving it.
|
||||||
|
#
|
||||||
|
# Extraction uses `--appimage-extract`, which unpacks the payload directly and
|
||||||
|
# needs no FUSE. So this script works even on a system where *running* the
|
||||||
|
# AppImage would need `fuse2` installed first.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
APP_ID="triple-c"
|
||||||
|
DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
||||||
|
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor"
|
||||||
|
DESKTOP_FILE="${DESKTOP_DIR}/${APP_ID}.desktop"
|
||||||
|
|
||||||
|
refresh_caches() {
|
||||||
|
# Both are best-effort: a minimal desktop may ship neither, and neither
|
||||||
|
# failing means the install did not work.
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
update-desktop-database "${DESKTOP_DIR}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||||
|
gtk-update-icon-cache -f -t "${ICON_DIR}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
uninstall() {
|
||||||
|
rm -f "${DESKTOP_FILE}"
|
||||||
|
find "${ICON_DIR}" -name "${APP_ID}.png" -delete 2>/dev/null || true
|
||||||
|
refresh_caches
|
||||||
|
echo "Removed the Triple-C launcher entry and icons."
|
||||||
|
echo "The AppImage itself was not touched."
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--uninstall" ]; then
|
||||||
|
uninstall
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
APPIMAGE="${1:-}"
|
||||||
|
if [ -z "${APPIMAGE}" ]; then
|
||||||
|
echo "usage: $0 [--uninstall] /path/to/Triple-C_<version>_amd64.AppImage" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [ ! -f "${APPIMAGE}" ]; then
|
||||||
|
echo "No such file: ${APPIMAGE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# An absolute path, because the .desktop Exec line is read from anywhere.
|
||||||
|
APPIMAGE=$(cd "$(dirname "${APPIMAGE}")" && printf '%s/%s' "$(pwd)" "$(basename "${APPIMAGE}")")
|
||||||
|
|
||||||
|
if [ ! -x "${APPIMAGE}" ]; then
|
||||||
|
echo "Making ${APPIMAGE} executable"
|
||||||
|
chmod +x "${APPIMAGE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORK=$(mktemp -d)
|
||||||
|
# shellcheck disable=SC2064 # WORK is expanded now on purpose.
|
||||||
|
trap "rm -rf '${WORK}'" EXIT INT TERM
|
||||||
|
|
||||||
|
echo "Extracting bundled icons from $(basename "${APPIMAGE}")..."
|
||||||
|
( cd "${WORK}" && "${APPIMAGE}" --appimage-extract >/dev/null )
|
||||||
|
|
||||||
|
SRC="${WORK}/squashfs-root"
|
||||||
|
if [ ! -d "${SRC}/usr/share/icons/hicolor" ]; then
|
||||||
|
echo "That AppImage has no bundled icons — is it really Triple-C?" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy every size the bundle ships, keeping the theme's directory layout.
|
||||||
|
COUNT=0
|
||||||
|
while IFS= read -r icon; do
|
||||||
|
[ -n "${icon}" ] || continue
|
||||||
|
rel=${icon#"${SRC}/usr/share/icons/hicolor/"}
|
||||||
|
install -Dm644 "${icon}" "${ICON_DIR}/${rel}"
|
||||||
|
COUNT=$((COUNT + 1))
|
||||||
|
done <<EOF
|
||||||
|
$(find "${SRC}/usr/share/icons/hicolor" -name "${APP_ID}.png")
|
||||||
|
EOF
|
||||||
|
echo "Installed ${COUNT} icon size(s) into ${ICON_DIR}"
|
||||||
|
|
||||||
|
# Written rather than copied from the bundle. The bundled entry has
|
||||||
|
# `Exec=triple-c`, which resolves only inside the running AppImage's own mount
|
||||||
|
# — from the host it names a binary that is not on PATH, so the launcher entry
|
||||||
|
# would appear and then fail to start anything. `Categories` is empty in the
|
||||||
|
# bundle too, which leaves the entry to fall into "Other" in most menus.
|
||||||
|
# `StartupWMClass` is kept exactly as the bundle sets it: it is what lets the
|
||||||
|
# shell match the running window to this entry, so the taskbar shows the real
|
||||||
|
# icon instead of a generic placeholder.
|
||||||
|
mkdir -p "${DESKTOP_DIR}"
|
||||||
|
cat > "${DESKTOP_FILE}" <<DESKTOP
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Triple-C
|
||||||
|
Comment=Run Claude Code sandboxed in Docker containers
|
||||||
|
Exec=${APPIMAGE} %U
|
||||||
|
Icon=${APP_ID}
|
||||||
|
Terminal=false
|
||||||
|
Categories=Development;
|
||||||
|
StartupWMClass=${APP_ID}
|
||||||
|
DESKTOP
|
||||||
|
chmod 644 "${DESKTOP_FILE}"
|
||||||
|
echo "Wrote ${DESKTOP_FILE}"
|
||||||
|
|
||||||
|
refresh_caches
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Done. Triple-C should now be in your app launcher."
|
||||||
|
echo "If the icon is generic or the entry is missing, log out and back in —"
|
||||||
|
echo "see \"App Icon Missing After Installing (Linux)\" in HOW-TO-USE.md."
|
||||||
Reference in New Issue
Block a user