Compare commits

...

15 Commits

Author SHA1 Message Date
lucaronin
3f4e5b585f fix: refresh image asset scope after attachment writes 2026-04-25 00:19:57 +02:00
lucaronin
cd49c81bd3 feat: add status bar theme toggle 2026-04-24 23:58:26 +02:00
lucaronin
b9f3dc9e1a fix: refine contribute panel actions 2026-04-24 23:44:02 +02:00
lucaronin
122aba7878 fix: load current note in new window 2026-04-24 23:29:44 +02:00
lucaronin
05896bde19 fix: restore editor external link toolbar actions 2026-04-24 23:25:22 +02:00
lucaronin
87a933f39c fix: replace silent catch blocks with warnings 2026-04-24 22:44:36 +02:00
lucaronin
54c0efa3e4 feat: add dark mode foundation 2026-04-24 22:28:07 +02:00
lucaronin
bdde0c8943 fix: bound note prefetch cache by size 2026-04-24 21:53:47 +02:00
lucaronin
a474303eb9 fix: preserve back history after cmd-click wikilinks 2026-04-24 21:15:27 +02:00
lucaronin
4701485ee5 fix: keep empty title clicks in h1 2026-04-24 20:13:34 +02:00
lucaronin
135a8a0adf fix: handle nested release artifacts 2026-04-24 19:48:56 +02:00
lucaronin
0f5d7d5340 fix: restore folder boundary validation 2026-04-24 19:23:55 +02:00
lucaronin
d6b3c0aef3 feat: add windows desktop release support 2026-04-24 19:23:55 +02:00
lucaronin
f896c01829 fix: publish linux stable release artifacts 2026-04-24 18:53:43 +02:00
lucaronin
c3cff0c461 feat: replace feedback with contribute modal 2026-04-24 17:45:43 +02:00
123 changed files with 5423 additions and 1333 deletions

View File

@@ -50,7 +50,7 @@ jobs:
echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Phase 2: Build each architecture in parallel
# Phase 2: Build release bundles in parallel
# ─────────────────────────────────────────────────────────────
build:
name: Build (${{ matrix.arch }})
@@ -249,12 +249,209 @@ jobs:
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
retention-days: 1
build-linux:
name: Build (linux-x86_64)
needs: version
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Tauri Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
curl \
wget \
patchelf \
build-essential \
file
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Build Tauri app (Linux bundles)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
- name: Upload Linux bundles
uses: actions/upload-artifact@v4
with:
name: linux-x86_64-bundles
path: |
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
if-no-files-found: error
retention-days: 1
build-windows:
name: Build (windows-x86_64)
needs: version
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~\.cargo\registry
~\.cargo\git
src-tauri\target
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Set version
shell: pwsh
run: |
$version = "${{ needs.version.outputs.version }}"
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
$tauri.version = $version
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
- name: Validate Windows release env
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
echo "::error::$name is required to build signed Windows updater artifacts."
exit 1
fi
done
- name: Build Tauri app (Windows bundles)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
- name: Validate Windows bundles
shell: bash
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
)
signatures=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Windows build produced no installable NSIS or MSI bundle."
exit 1
fi
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Windows build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Windows bundles
uses: actions/upload-artifact@v4
with:
name: windows-x86_64-bundles
path: |
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
if-no-files-found: error
retention-days: 1
# ─────────────────────────────────────────────────────────────
# Phase 3: Publish GitHub Release
# ─────────────────────────────────────────────────────────────
release:
name: GitHub Release (stable)
needs: [version, build]
needs: [version, build, build-linux, build-windows]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -282,7 +479,7 @@ jobs:
echo "---"
echo "**Stable release — manually promoted from \`main\`**"
echo ""
echo "**Requires Apple Silicon (M1/M2/M3)**"
echo "**Includes macOS (Apple Silicon), Windows x64, and Linux x64 bundles**"
echo ""
echo "*Built from \`$(git rev-parse --short ${{ needs.version.outputs.tag }})\` on $(date -u +%Y-%m-%d)*"
} > release_notes.md
@@ -295,9 +492,34 @@ jobs:
REPO_NAME="${REPO#*/}"
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename)
find_required() {
for pattern in "$@"; do
set -- $pattern
if [ -e "$1" ]; then
printf '%s\n' "$1"
return 0
fi
done
return 1
}
ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig")
ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}"
ARM_SIG=$(cat "$ARM_SIG_FILE")
ARM_TARBALL=$(basename "$ARM_UPDATER_FILE")
ARM_DMG=$(basename "$(find_required "dmg-aarch64/*.dmg")")
LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig")
LINUX_UPDATER_FILE="${LINUX_SIG_FILE%.sig}"
LINUX_SIG=$(cat "$LINUX_SIG_FILE")
LINUX_UPDATER=$(basename "$LINUX_UPDATER_FILE")
LINUX_DOWNLOAD=$(basename "$(find_required "linux-x86_64-bundles/*/*.AppImage" "linux-x86_64-bundles/*/*.deb" "linux-x86_64-bundles/*/*.AppImage.tar.gz" "linux-x86_64-bundles/*.AppImage" "linux-x86_64-bundles/*.deb" "linux-x86_64-bundles/*.AppImage.tar.gz")")
WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*/*-setup.exe.sig" "windows-x86_64-bundles/*/*.msi.sig" "windows-x86_64-bundles/*/*.nsis.zip.sig" "windows-x86_64-bundles/*/*.msi.zip.sig" "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig")
WINDOWS_UPDATER_FILE="${WINDOWS_SIG_FILE%.sig}"
WINDOWS_SIG=$(cat "$WINDOWS_SIG_FILE")
WINDOWS_UPDATER=$(basename "$WINDOWS_UPDATER_FILE")
WINDOWS_DOWNLOAD=$(basename "$(find_required "windows-x86_64-bundles/*/*-setup.exe" "windows-x86_64-bundles/*/*.msi" "windows-x86_64-bundles/*/*.nsis.zip" "windows-x86_64-bundles/*/*.msi.zip" "windows-x86_64-bundles/*-setup.exe" "windows-x86_64-bundles/*.msi" "windows-x86_64-bundles/*.nsis.zip" "windows-x86_64-bundles/*.msi.zip")")
cat > stable-latest.json << EOF
{
@@ -309,6 +531,16 @@ jobs:
"signature": "${ARM_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}",
"dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}"
},
"linux-x86_64": {
"signature": "${LINUX_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_UPDATER}",
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_DOWNLOAD}"
},
"windows-x86_64": {
"signature": "${WINDOWS_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_UPDATER}",
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_DOWNLOAD}"
}
}
}
@@ -327,6 +559,26 @@ jobs:
dmg-aarch64/*.dmg
updater-aarch64/*.app.tar.gz
updater-aarch64/*.app.tar.gz.sig
linux-x86_64-bundles/*.deb
linux-x86_64-bundles/*.AppImage
linux-x86_64-bundles/*.AppImage.tar.gz
linux-x86_64-bundles/*.AppImage.tar.gz.sig
linux-x86_64-bundles/*/*.deb
linux-x86_64-bundles/*/*.AppImage
linux-x86_64-bundles/*/*.AppImage.tar.gz
linux-x86_64-bundles/*/*.AppImage.tar.gz.sig
windows-x86_64-bundles/*.exe
windows-x86_64-bundles/*.exe.sig
windows-x86_64-bundles/*.msi
windows-x86_64-bundles/*.msi.sig
windows-x86_64-bundles/*.zip
windows-x86_64-bundles/*.zip.sig
windows-x86_64-bundles/*/*.exe
windows-x86_64-bundles/*/*.exe.sig
windows-x86_64-bundles/*/*.msi
windows-x86_64-bundles/*/*.msi.sig
windows-x86_64-bundles/*/*.zip
windows-x86_64-bundles/*/*.zip.sig
stable-latest.json
# ─────────────────────────────────────────────────────────────

View File

@@ -303,28 +303,14 @@ jobs:
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
retention-days: 1
build-linux:
name: Build (linux-x86_64)
build-windows:
name: Build (windows-x86_64)
needs: version
runs-on: ubuntu-22.04
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install Tauri Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
build-essential \
file
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
@@ -339,29 +325,45 @@ jobs:
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
~\.cargo\registry
~\.cargo\git
src-tauri\target
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Set version
shell: pwsh
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
$version = "${{ needs.version.outputs.version }}"
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
$tauri.version = $version
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
- name: Build Tauri app (Linux bundles)
- name: Validate Windows release env
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
echo "::error::$name is required to build signed Windows updater artifacts."
exit 1
fi
done
- name: Build Tauri app (Windows bundles)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
@@ -370,26 +372,52 @@ jobs:
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
- name: Upload Linux bundles
- name: Validate Windows bundles
shell: bash
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
)
signatures=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Windows build produced no installable NSIS or MSI bundle."
exit 1
fi
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Windows build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Windows bundles
uses: actions/upload-artifact@v4
with:
name: linux-x86_64-bundles
name: windows-x86_64-bundles
path: |
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
retention-days: 7
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
retention-days: 1
# ─────────────────────────────────────────────────────────────
# Phase 3: Publish GitHub Release
# No lipo/re-signing — use the per-arch artifacts directly
# ─────────────────────────────────────────────────────────────
release:
name: GitHub Release (alpha)
needs: [version, build]
needs: [version, build, build-windows]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -437,7 +465,7 @@ jobs:
echo "---"
echo "**Alpha build — updated on every push to \`main\`**"
echo ""
echo "**Requires Apple Silicon (M1/M2/M3)**"
echo "**Includes macOS (Apple Silicon) and Windows x64 bundles**"
echo ""
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
} > release_notes.md
@@ -450,8 +478,27 @@ jobs:
REPO_NAME="${REPO#*/}"
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
find_required() {
for pattern in "$@"; do
set -- $pattern
if [ -e "$1" ]; then
printf '%s\n' "$1"
return 0
fi
done
return 1
}
ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig")
ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}"
ARM_SIG=$(cat "$ARM_SIG_FILE")
ARM_UPDATER=$(basename "$ARM_UPDATER_FILE")
WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*/*-setup.exe.sig" "windows-x86_64-bundles/*/*.msi.sig" "windows-x86_64-bundles/*/*.nsis.zip.sig" "windows-x86_64-bundles/*/*.msi.zip.sig" "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig")
WINDOWS_UPDATER_FILE="${WINDOWS_SIG_FILE%.sig}"
WINDOWS_SIG=$(cat "$WINDOWS_SIG_FILE")
WINDOWS_UPDATER=$(basename "$WINDOWS_UPDATER_FILE")
WINDOWS_DOWNLOAD=$(basename "$(find_required "windows-x86_64-bundles/*/*-setup.exe" "windows-x86_64-bundles/*/*.msi" "windows-x86_64-bundles/*/*.nsis.zip" "windows-x86_64-bundles/*/*.msi.zip" "windows-x86_64-bundles/*-setup.exe" "windows-x86_64-bundles/*.msi" "windows-x86_64-bundles/*.nsis.zip" "windows-x86_64-bundles/*.msi.zip")")
cat > alpha-latest.json << EOF
{
@@ -461,7 +508,13 @@ jobs:
"platforms": {
"darwin-aarch64": {
"signature": "${ARM_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}"
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}",
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}"
},
"windows-x86_64": {
"signature": "${WINDOWS_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_UPDATER}",
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_DOWNLOAD}"
}
}
}
@@ -479,6 +532,18 @@ jobs:
files: |
updater-aarch64/*.app.tar.gz
updater-aarch64/*.app.tar.gz.sig
windows-x86_64-bundles/*.exe
windows-x86_64-bundles/*.exe.sig
windows-x86_64-bundles/*.msi
windows-x86_64-bundles/*.msi.sig
windows-x86_64-bundles/*.zip
windows-x86_64-bundles/*.zip.sig
windows-x86_64-bundles/*/*.exe
windows-x86_64-bundles/*/*.exe.sig
windows-x86_64-bundles/*/*.msi
windows-x86_64-bundles/*/*.msi.sig
windows-x86_64-bundles/*/*.zip
windows-x86_64-bundles/*/*.zip.sig
alpha-latest.json
# ─────────────────────────────────────────────────────────────

View File

@@ -240,6 +240,7 @@ Tolaria separates **display title** from the file identifier:
- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem.
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions stage crash-safe note renames through a hidden `.tolaria-rename-txn/` transaction directory, recover unfinished renames on the next vault scan, update wikilinks across the vault, and surface any failed backlink rewrites instead of silently reporting partial success. The editor body remains the title editing surface.
- **Portable filename validation** (`vault/filename_rules.rs`): note filenames, folder names, and custom view filenames all reject Windows-reserved device names, invalid characters, and trailing dot/space suffixes so a vault created on macOS/Linux still clones and syncs cleanly on Windows.
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
### Title Surface (UI)
@@ -311,7 +312,7 @@ The folder tree hides only the dedicated `type/` directory, since note types alr
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, and folder mutations cannot step outside the active vault.
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands refresh the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
### Vault Caching
@@ -534,10 +535,11 @@ Typed ASCII arrow sequences are normalized consistently in both editor modes:
## Styling
The app uses a single light theme — the vault-based theming system was removed (see [ADR-0013](adr/0013-remove-theming-system.md)). Styling is defined in two layers:
The app uses internal light and dark themes owned by Tolaria (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md)). The previous vault-authored theming system remains removed; theme mode is an installation-local app preference.
1. **Global CSS variables** (`src/index.css`): App-wide colors via `:root`, bridged to Tailwind v4
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states via `:root` / `[data-theme]`, bridged to Tailwind v4
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
3. **Runtime theme bridge**: Applies `data-theme` and `.dark` for shadcn/ui, while CodeMirror and editor-specific consumers derive any non-CSS-variable values from the same semantic contract
## Inspector Abstraction
@@ -647,11 +649,12 @@ interface Settings {
analytics_enabled: boolean | null
anonymous_id: string | null
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
theme_mode: 'light' | 'dark' | null
default_ai_agent: 'claude_code' | 'codex' | null
}
```
Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
## Telemetry
@@ -692,5 +695,5 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
### CI/CD
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -406,10 +406,11 @@ flowchart TD
## Styling
The app uses a single light theme with no user-configurable theming (see [ADR-0013](adr/0013-remove-theming-system.md)).
The app uses internal app-owned light and dark themes (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md)). This is not the old vault-authored theming system from ADR-0013: users choose a mode, but themes are owned by the app.
1. **Global CSS variables** (`src/index.css`): App-wide colors, borders, backgrounds. Bridged to Tailwind v4 via `@theme inline`.
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`.
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states. Bridged to Tailwind v4 via `@theme inline`.
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`; editor colors resolve through the same semantic app variables.
3. **Theme runtime**: Applies `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected.
## Vault Management
@@ -592,8 +593,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
| `filename_rules.rs` | Cross-platform validation for note filenames, folder names, and custom view filenames |
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
| `getting_started.rs` | Clones and normalizes the public Getting Started starter vault |
@@ -711,8 +713,8 @@ The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bri
| `save_vault_config` | Save per-vault UI config |
| `get_default_vault_path` | Get default vault path |
| `get_build_number` | Get app build number |
| `save_image` | Save base64 image to vault |
| `copy_image_to_vault` | Copy image file to vault |
| `save_image` | Save base64 image to `attachments/` and refresh the active vault asset scope |
| `copy_image_to_vault` | Copy image file to `attachments/` and refresh the active vault asset scope |
| `update_menu_state` | Update native menu checkmarks and enabled/disabled state for selection-dependent actions |
| `trigger_menu_command` | Emit a native menu command ID for deterministic shortcut QA |
| `update_current_window_min_size` | Update the active Tauri window's minimum size and optionally grow it to fit restored panes |
@@ -750,14 +752,14 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch |
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
| `useTheme` | Editor theme CSS vars | Editor typography theme |
| `useTheme` | Editor theme CSS vars and theme-mode bridge | Editor typography and app theme runtime |
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation |
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints |
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (telemetry, release channel, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
| `useSettings` | App settings (telemetry, release channel, theme mode, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
@@ -782,6 +784,7 @@ Selection-dependent actions are wired through the command palette and the native
Shortcut routing is explicit:
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
- `formatShortcutDisplay()` derives platform-accurate visible shortcut labels (`⌘` on macOS, `Ctrl` on Windows/Linux) from that same manifest so menus, tooltips, and command-palette copy stay aligned with real accelerators
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
- macOS browser-reserved chords such as `Cmd+O` and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same command IDs for native menu clicks, accelerators, and custom titlebar menu actions
@@ -806,9 +809,9 @@ push to main
→ build job:
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app
→ upload signed .app.tar.gz + .sig updater artifacts
→ build-linux job:
→ pnpm install, stamp version, tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
→ upload .deb, .AppImage, and signed updater tarball artifacts for manual download
→ build-windows job:
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
→ release job:
→ generate alpha-latest.json
→ publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N
@@ -828,12 +831,18 @@ push stable-vYYYY.M.D tag
→ build job:
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
→ upload signed .app.tar.gz + .sig and .dmg artifacts
→ build-linux job:
→ pnpm install, stamp version, tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
→ upload .deb, .AppImage, and signed Linux updater bundles
→ build-windows job:
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
→ release job:
→ generate stable-latest.json with both updater tarball and current stable DMG URLs
→ generate stable-latest.json with macOS, Linux, and Windows updater URLs plus platform-specific manual download URLs
→ publish GitHub release Tolaria YYYY.M.D
→ pages job:
→ publish stable/latest.json
→ publish stable/download/ and download/ as permanent redirect URLs for the latest stable DMG
→ publish stable/download/ and download/ as permanent redirect URLs for the latest stable platform installer
→ preserve alpha/latest.json
→ deploy to gh-pages
```

View File

@@ -63,8 +63,8 @@ tolaria/
│ ├── App.css # App shell layout styles
│ ├── types.ts # Shared TS types (VaultEntry, Settings, etc.)
│ ├── mock-tauri.ts # Mock Tauri layer for browser testing
│ ├── theme.json # Editor theme configuration
│ ├── index.css # Global CSS variables + Tailwind setup
│ ├── theme.json # Editor typography theme configuration
│ ├── index.css # Semantic app theme variables + Tailwind setup
│ │
│ ├── components/ # UI components (~98 files)
│ │ ├── Sidebar.tsx # Left panel: filters + type groups
@@ -287,14 +287,14 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/index.css` | All CSS custom properties. The design token source of truth. |
| `src/theme.json` | Editor-specific theme (fonts, headings, lists, code blocks). |
| `src/index.css` | Semantic CSS custom properties for app-owned light/dark themes. |
| `src/theme.json` | Editor-specific typography theme (fonts, headings, lists, code blocks). |
### Settings & Config
| File | Why it matters |
|------|---------------|
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, auto-sync interval, default AI agent). |
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, auto-sync interval, default AI agent). |
| `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). |
| `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. |
| `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. |
@@ -404,7 +404,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
### Modify styling
1. **Global CSS variables**: Edit `src/index.css`
1. **Global app/theme variables**: Edit `src/index.css`
2. **Editor typography**: Edit `src/theme.json`
### Work with the AI agent

View File

@@ -2,8 +2,9 @@
type: ADR
id: "0013"
title: "Remove vault-based theming system"
status: active
status: superseded
date: 2026-03-23
superseded_by: "0081"
---
## Context

View File

@@ -0,0 +1,36 @@
---
type: ADR
id: "0080"
title: "Cross-platform desktop release artifacts and portable vault names"
status: active
date: 2026-04-24
---
## Context
Tolaria's release pipeline and file validation rules were still biased toward macOS. Alpha/stable releases only produced first-class macOS artifacts, stable download redirects assumed a DMG-only world, and vault file/folder validation allowed names that work on macOS/Linux but break on Windows clones and sync targets.
Shipping Windows as a supported desktop target requires both distribution and data portability to become explicit. A Windows installer is not enough if shared vault content can still produce invalid filenames on that platform, and cross-platform updater manifests must keep Tauri's signed updater artifact separate from the user-facing installer download.
## Decision
**Tolaria ships first-class macOS, Windows x64, and Linux x64 desktop artifacts, and its vault-facing filename rules are portable across those platforms by default.**
- Alpha and stable release workflows build and publish macOS, Windows x64, and Linux x64 artifacts from the same release tag/version computation.
- `latest.json` manifests continue to point Tauri updater clients at signed updater artifacts through `url`, while manual installer/download links are exposed separately via platform-specific fields such as `dmg_url` and `download_url`.
- The stable download page resolves the best current platform download from that manifest plus release assets, instead of assuming macOS-only DMG delivery.
- Note filename renames, folder creation/rename flows, and custom view filenames all share one portable validation rule set that rejects Windows reserved device names, invalid characters, and trailing dot/space suffixes.
- Shortcut labels shown in the UI are derived from the shared command manifest so non-macOS builds display `Ctrl`-style accelerators instead of macOS glyphs.
## Options considered
- **Cross-platform artifacts + portable filename rules** (chosen): makes Windows support real instead of nominal, keeps updater behavior compatible with Tauri, and prevents cross-OS vault breakage at the point of write. Cons: more CI matrix surface area and more platform-specific packaging constraints.
- **Ship Windows installers but keep existing filename validation**: lowers immediate implementation cost, but Windows users would still hit invalid vault content created elsewhere and trust in sync portability would stay weak.
- **Keep macOS-first updater/download metadata and infer other platforms from release assets only**: cheaper in the short term, but it weakens in-app update guarantees and makes the public download page depend on ad hoc asset naming rather than an explicit manifest contract.
## Consequences
- Tolaria's release CI now owns packaging and artifact validation on three desktop platforms instead of one.
- The public stable download page can redirect Windows/Linux users to real installers without special-case manual curation.
- Vault content created through Tolaria stays portable across macOS, Linux, and Windows, which reduces sync-time surprises and broken clones.
- Any future platform addition now needs both a release-artifact contract and an explicit portable-filename review instead of piggybacking on macOS assumptions.

View File

@@ -0,0 +1,43 @@
---
type: ADR
id: "0081"
title: "Internal light and dark theme runtime"
status: active
date: 2026-04-24
supersedes: "0013"
---
## Context
ADR-0013 removed the vault-authored theming system and made Tolaria light-only. That kept the app simpler, but dark mode has become a product requirement for long writing sessions and accessibility.
The previous theming system should not return in its old form: vault notes, live user-authored themes, and broad runtime editing created too much maintenance burden. Tolaria still needs a small app-owned theme architecture because the UI spans Tailwind/shadcn variables, BlockNote/Mantine surfaces, CodeMirror raw editing, syntax highlighting, and product-specific states such as selected rows, badges, warnings, and diff lines.
## Decision
**Tolaria will support internal app-owned light and dark themes through a semantic CSS-variable contract, with the user's theme mode persisted as installation-local app settings.**
The v1 theme runtime is deliberately smaller than a general theming system:
- Themes are defined by the app, not by vault-authored notes.
- CSS custom properties remain the public runtime contract for product components, Tailwind v4, and shadcn/ui.
- Typed TypeScript helpers may derive values for consumers that cannot read CSS variables directly, such as CodeMirror extensions.
- Existing CSS variables stay available as compatibility aliases while the UI migrates toward semantic names.
- The first persisted choices are `light` and `dark`; system-follow, high-contrast variants, custom themes, and per-vault themes are deferred.
## Options considered
- **Internal light/dark runtime with semantic tokens** (chosen): ships dark mode while keeping the product-owned theme surface small, testable, and compatible with existing CSS-variable usage.
- **Reintroduce vault-authored theme notes**: flexible, but repeats the complexity removed by ADR-0013 and makes dark mode dependent on user-editable data.
- **Ad hoc `.dark` overrides in components**: fastest initially, but would scatter color logic across the app and make future theme variants expensive.
- **Single TypeScript theme object as source of truth**: attractive for validation, but the current app already relies on CSS variables for Tailwind, shadcn/ui, BlockNote CSS overrides, and many product components.
## Consequences
- `src/index.css` owns the stable CSS custom-property contract for app chrome and shared states.
- `src/theme.json` continues to describe editor typography, but editor-facing colors should resolve through the same semantic CSS variables used by the app shell.
- `useTheme` remains responsible for editor theme flattening and can grow into the bridge between app theme mode and editor consumers.
- App settings, not vault frontmatter, store the selected theme mode because it is an installation-local comfort preference.
- Startup must avoid a light-mode flash when dark mode is selected, so the runtime needs a pre-React localStorage mirror and a minimal `index.html` prepaint style in addition to persisted Tauri settings.
- Domain tokens should be introduced only when a surface needs a role that generic semantic tokens cannot express clearly.
- Re-evaluate if Tolaria decides to support user-authored custom themes, per-vault themes, or system-synchronized mode as a first-class product requirement.

View File

@@ -68,7 +68,7 @@ proposed → active → superseded
| [0010](0010-dynamic-wikilink-relationship-detection.md) | Dynamic wikilink relationship detection | active |
| [0011](0011-mcp-server-for-ai-integration.md) | MCP server for AI tool integration | superseded → [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) |
| [0012](0012-claude-cli-for-ai-agent.md) | Claude CLI subprocess for AI agent | active |
| [0013](0013-remove-theming-system.md) | Remove vault-based theming system | active |
| [0013](0013-remove-theming-system.md) | Remove vault-based theming system | superseded -> [0081](0081-internal-light-dark-theme-runtime.md) |
| [0014](0014-git-based-vault-cache.md) | Git-based incremental vault cache | active |
| [0015](0015-auto-save-with-debounce.md) | Auto-save with 500ms debounce | active |
| [0016](0016-sentry-posthog-telemetry.md) | Sentry + PostHog telemetry with consent | active |
@@ -134,3 +134,6 @@ proposed → active → superseded
| [0076](0076-note-retargeting-separates-type-and-folder-moves.md) | Note retargeting separates type changes from folder moves | active |
| [0077](0077-concurrent-safe-vault-cache-replacement.md) | Concurrent-safe vault cache replacement | active |
| [0078](0078-scoped-unsigned-fallback-for-app-managed-git-commits.md) | Scoped unsigned fallback for app-managed git commits | active |
| [0079](0079-linux-window-chrome-and-menu-reuse.md) | Linux window chrome and menu reuse | active |
| [0080](0080-cross-platform-desktop-release-artifacts-and-portable-vault-names.md) | Cross-platform desktop release artifacts and portable vault names | active |
| [0081](0081-internal-light-dark-theme-runtime.md) | Internal light and dark theme runtime | active |

View File

@@ -1,6 +1,11 @@
import { test, expect } from '@playwright/test'
import { test, expect, type Page } from '@playwright/test'
import * as path from 'path'
import * as fs from 'fs'
import {
createFixtureVaultCopy,
openFixtureVault,
removeFixtureVaultCopy,
} from '../tests/helpers/fixtureVault'
// Minimal valid PNG: 1x1 red pixel
const TEST_PNG_BASE64 =
@@ -11,16 +16,28 @@ function createTestPng(filepath: string) {
fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64'))
}
test('image upload via file picker displays image with data URL', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(1000)
let tempVaultDir: string
// Open a note
await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 })
await page.waitForTimeout(500)
async function openImageTestNote(page: Page) {
await page.locator('[data-testid="note-list-container"]').getByText('Alpha Project', { exact: true }).click()
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 10000 })
return editor
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVault(page, tempVaultDir)
})
test.afterEach(async () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('image upload via file picker displays image with data URL', async ({ page }) => {
const editor = await openImageTestNote(page)
await editor.click()
await page.waitForTimeout(200)
@@ -63,16 +80,37 @@ test('image upload via file picker displays image with data URL', async ({ page
if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath)
})
test('image paste into editor inserts image block', async ({ page }) => {
const editor = await openImageTestNote(page)
await editor.click()
await page.evaluate((base64) => {
const editorElement = document.querySelector('.bn-editor')
if (!editorElement) throw new Error('Editor not found')
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
const file = new File([bytes], 'pasted-image.png', { type: 'image/png' })
const clipboardData = new DataTransfer()
clipboardData.items.add(file)
editorElement.dispatchEvent(new ClipboardEvent('paste', {
clipboardData,
bubbles: true,
cancelable: true,
}))
}, TEST_PNG_BASE64)
const images = page.locator('.bn-editor img')
await expect(images.first()).toBeVisible({ timeout: 5000 })
const src = await images.first().getAttribute('src')
expect(src).toMatch(/^data:/)
})
test('editor has uploadFile configured (no error on image block insert)', async ({ page }) => {
await page.goto('/')
await page.waitForTimeout(1000)
// Click first note
await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 })
await page.waitForTimeout(500)
const editor = page.locator('.bn-editor')
await expect(editor).toBeVisible({ timeout: 10000 })
const editor = await openImageTestNote(page)
// Capture console errors
const errors: string[] = []

View File

@@ -7,17 +7,48 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet" />
<style>
:root {
color-scheme: light;
background: #FFFFFF;
color: #37352F;
}
:root[data-theme="dark"] {
color-scheme: dark;
background: #1F1E1B;
color: #E6E1D8;
}
body {
background: inherit;
color: inherit;
}
</style>
<title>Tolaria</title>
</head>
<body>
<script>
// Apply saved theme before React mounts to prevent flash
var t = localStorage.getItem('tolaria-theme');
if (t === null) {
t = localStorage.getItem('laputa-theme');
if (t !== null) localStorage.setItem('tolaria-theme', t);
}
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
(function () {
var key = 'tolaria-theme';
var legacyKey = 'laputa-theme';
function normalizeTheme(value) {
return value === 'dark' || value === 'light' ? value : null;
}
function applyTheme(value) {
var mode = normalizeTheme(value) || 'light';
document.documentElement.setAttribute('data-theme', mode);
document.documentElement.classList.toggle('dark', mode === 'dark');
}
try {
var mode = normalizeTheme(localStorage.getItem(key));
if (mode === null) {
mode = normalizeTheme(localStorage.getItem(legacyKey));
if (mode !== null) localStorage.setItem(key, mode);
}
applyTheme(mode);
} catch (err) {
applyTheme('light');
}
})();
</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>

View File

@@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path'
import {
buildStableDownloadRedirectPage,
resolveStableDmgUrl,
resolveStableDownloadTargets,
} from '../src/utils/releaseDownloadPage'
function getArg(flag: string): string {
@@ -30,8 +30,8 @@ const releasesJsonPath = resolve(getArg('--releases-json'))
const outputFilePath = resolve(getArg('--output-file'))
const latestPayload = readLatestReleasePayload(latestJsonPath)
const releasesPayload = readLatestReleasePayload(releasesJsonPath)
const dmgUrl = resolveStableDmgUrl(latestPayload, releasesPayload)
const html = buildStableDownloadRedirectPage(dmgUrl)
const downloads = resolveStableDownloadTargets(latestPayload, releasesPayload)
const html = buildStableDownloadRedirectPage(downloads)
mkdirSync(dirname(outputFilePath), { recursive: true })
writeFileSync(outputFilePath, html)

View File

@@ -31,6 +31,47 @@ mod tests {
Some(vault_path.to_string_lossy().to_string())
}
fn assert_note_write_rejects_escape<T: std::fmt::Debug>(
action: impl FnOnce(std::path::PathBuf, String, Option<std::path::PathBuf>) -> Result<T, String>,
) {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let escape_path = vault_path.join("../outside.md");
let err = action(
escape_path,
"# Outside\n".to_string(),
vault_path_arg(vault_path),
)
.expect_err("expected traversal write to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
fn sample_view_definition() -> ViewDefinition {
ViewDefinition {
name: "Inbox".to_string(),
icon: None,
color: None,
sort: None,
list_properties_display: vec![],
filters: crate::vault::FilterGroup::All(vec![]),
}
}
fn assert_save_view_cmd_rejects_invalid_filename(filename: &str) {
let dir = tempfile::TempDir::new().unwrap();
let err = save_view_cmd(
dir.path().to_string_lossy().to_string(),
filename.to_string(),
sample_view_definition(),
)
.expect_err("expected invalid filename to be rejected");
assert_eq!(err, INVALID_VIEW_FILENAME_ERROR);
}
fn temp_note(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::TempDir::new().unwrap();
let note = dir.path().join("note.md");
@@ -125,34 +166,12 @@ mod tests {
#[test]
fn test_save_note_content_rejects_traversal_outside_active_vault() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let escape_path = vault_path.join("../outside.md");
let err = save_note_content(
escape_path,
"# Outside\n".to_string(),
vault_path_arg(vault_path),
)
.expect_err("expected traversal write to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
assert_note_write_rejects_escape(save_note_content);
}
#[test]
fn test_create_note_content_rejects_traversal_outside_active_vault() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let escape_path = vault_path.join("../outside.md");
let err = create_note_content(
escape_path,
"# Outside\n".to_string(),
vault_path_arg(vault_path),
)
.expect_err("expected traversal create to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
assert_note_write_rejects_escape(create_note_content);
}
#[test]
@@ -166,25 +185,23 @@ mod tests {
}
#[test]
fn test_save_view_cmd_rejects_nested_filename() {
fn test_create_vault_folder_rejects_windows_invalid_names() {
let dir = tempfile::TempDir::new().unwrap();
let definition = ViewDefinition {
name: "Inbox".to_string(),
icon: None,
color: None,
sort: None,
list_properties_display: vec![],
filters: crate::vault::FilterGroup::All(vec![]),
};
let err = save_view_cmd(
dir.path().to_string_lossy().to_string(),
"../escape.yml".to_string(),
definition,
)
.expect_err("expected nested filename to be rejected");
let err = create_vault_folder(dir.path().into(), "con".into())
.expect_err("expected Windows-invalid folder name to be rejected");
assert_eq!(err, INVALID_VIEW_FILENAME_ERROR);
assert_eq!(err, "Invalid folder name");
}
#[test]
fn test_save_view_cmd_rejects_nested_filename() {
assert_save_view_cmd_rejects_invalid_filename("../escape.yml");
}
#[test]
fn test_save_view_cmd_rejects_windows_invalid_filename() {
assert_save_view_cmd_rejects_invalid_filename("con.yml");
}
#[test]

View File

@@ -1,4 +1,5 @@
use crate::commands::expand_tilde;
use crate::vault::filename_rules::validate_view_filename_stem;
use crate::vault_list;
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
@@ -199,7 +200,11 @@ pub(crate) fn validate_view_filename(filename: &str) -> Result<(), String> {
let path = Path::new(filename);
let mut components = path.components();
match (components.next(), components.next()) {
(Some(Component::Normal(_)), None) => Ok(()),
(Some(Component::Normal(value)), None) => {
let stem = value.to_string_lossy();
let stem = stem.strip_suffix(".yml").unwrap_or(&stem);
validate_view_filename_stem(stem)
}
_ => Err(INVALID_VIEW_FILENAME_ERROR.to_string()),
}
}

View File

@@ -1,4 +1,5 @@
use crate::commands::expand_tilde;
use crate::vault::filename_rules::validate_folder_name;
use crate::vault::{self, FolderNode, VaultEntry};
use std::path::{Path, PathBuf};
@@ -39,6 +40,31 @@ fn with_requested_root_path<T>(
with_requested_root(raw_vault_path.as_ref(), action)
}
fn sync_image_asset_scope(
app_handle: &tauri::AppHandle,
requested_root: &str,
) -> Result<(), String> {
#[cfg(desktop)]
crate::sync_vault_asset_scope(app_handle, Path::new(requested_root))?;
#[cfg(not(desktop))]
let _ = requested_root;
#[cfg(not(desktop))]
let _ = app_handle;
Ok(())
}
fn with_image_asset_scope(
app_handle: &tauri::AppHandle,
vault_path: &Path,
action: impl FnOnce(&str) -> Result<String, String>,
) -> Result<String, String> {
with_requested_root_path(vault_path, |requested_root| {
let saved_path = action(requested_root)?;
sync_image_asset_scope(app_handle, requested_root)?;
Ok(saved_path)
})
}
fn with_writable_note_path<T>(
path: PathBuf,
vault_path: Option<PathBuf>,
@@ -114,6 +140,7 @@ pub fn create_vault_folder(vault_path: PathBuf, folder_name: PathBuf) -> Result<
with_boundary(Some(raw_vault_path.as_ref()), |boundary| {
let folder_name = folder_name.to_string_lossy();
let folder_path = boundary.child_path(folder_name.as_ref())?;
validate_folder_name(folder_name.as_ref())?;
ensure_missing_folder(&folder_path, folder_name.as_ref())?;
std::fs::create_dir_all(&folder_path)
.map_err(|e| format!("Failed to create folder: {}", e))?;
@@ -146,15 +173,24 @@ pub fn sync_note_title(path: PathBuf, vault_path: Option<PathBuf>) -> Result<boo
}
#[tauri::command]
pub fn save_image(vault_path: PathBuf, filename: String, data: String) -> Result<String, String> {
with_requested_root_path(vault_path.as_path(), |requested_root| {
pub fn save_image(
app_handle: tauri::AppHandle,
vault_path: PathBuf,
filename: String,
data: String,
) -> Result<String, String> {
with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| {
vault::save_image(requested_root, &filename, &data)
})
}
#[tauri::command]
pub fn copy_image_to_vault(vault_path: PathBuf, source_path: PathBuf) -> Result<String, String> {
with_requested_root_path(vault_path.as_path(), |requested_root| {
pub fn copy_image_to_vault(
app_handle: tauri::AppHandle,
vault_path: PathBuf,
source_path: PathBuf,
) -> Result<String, String> {
with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| {
vault::copy_image_to_vault(requested_root, source_path.to_string_lossy().as_ref())
})
}

View File

@@ -17,12 +17,12 @@ pub fn rename_note(
&vault_path,
&old_path,
|requested_root, validated_path| {
vault::rename_note(
requested_root,
validated_path,
&new_title,
old_title.as_deref(),
)
vault::rename_note(vault::RenameNoteRequest {
vault_path: requested_root,
old_path: validated_path,
new_title: &new_title,
old_title_hint: old_title.as_deref(),
})
},
)
}
@@ -37,7 +37,11 @@ pub fn rename_note_filename(
&vault_path,
&old_path,
|requested_root, validated_path| {
vault::rename_note_filename(requested_root, validated_path, &new_filename_stem)
vault::rename_note_filename(vault::RenameNoteFilenameRequest {
vault_path: requested_root,
old_path: validated_path,
new_filename_stem: &new_filename_stem,
})
},
)
}
@@ -67,11 +71,11 @@ pub fn move_note_to_folder(
if !validated_folder.is_dir() {
return Err(format!("Folder does not exist: {}", trimmed_folder_path));
}
vault::move_note_to_folder(
requested_root,
validated_path,
validated_folder_path,
)
vault::move_note_to_folder(vault::MoveNoteToFolderRequest {
vault_path: requested_root,
old_path: validated_path,
destination_folder_path: validated_folder_path,
})
},
)
},
@@ -87,7 +91,10 @@ pub fn auto_rename_untitled(
&vault_path,
&note_path,
|requested_root, validated_path| {
vault::auto_rename_untitled(requested_root, validated_path)
vault::auto_rename_untitled(vault::AutoRenameUntitledRequest {
vault_path: requested_root,
note_path: validated_path,
})
},
)
}
@@ -95,7 +102,7 @@ pub fn auto_rename_untitled(
#[tauri::command]
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
let vault_path = expand_tilde(&vault_path);
vault::detect_renames(&vault_path)
vault::detect_renames(Path::new(vault_path.as_ref()))
}
#[tauri::command]
@@ -104,5 +111,5 @@ pub fn update_wikilinks_for_renames(
renames: Vec<DetectedRename>,
) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::update_wikilinks_for_renames(&vault_path, &renames)
vault::update_wikilinks_for_renames(Path::new(vault_path.as_ref()), &renames)
}

View File

@@ -1,18 +1,72 @@
use crate::commands::expand_tilde;
use crate::search::SearchResponse;
use crate::vault::VaultEntry;
use crate::{search, vault};
use crate::{search, vault, vault_list};
use std::path::{Path, PathBuf};
use super::boundary::{with_validated_path, ValidatedPathMode};
fn collect_registered_vault_roots(vault_list: &vault_list::VaultList) -> Vec<PathBuf> {
let mut roots = vault_list
.vaults
.iter()
.map(|entry| PathBuf::from(expand_tilde(&entry.path).into_owned()))
.collect::<Vec<_>>();
if let Some(active_vault) = &vault_list.active_vault {
roots.push(PathBuf::from(expand_tilde(active_vault).into_owned()));
}
roots
}
fn find_registered_vault_root(path: &Path, registered_roots: &[PathBuf]) -> Option<PathBuf> {
registered_roots
.iter()
.filter_map(|root| {
let canonical_root = root.canonicalize().ok()?;
path.starts_with(&canonical_root)
.then_some((canonical_root.components().count(), root.clone()))
})
.max_by_key(|(depth, _)| *depth)
.map(|(_, root)| root)
}
fn resolve_reload_vault_path(
path: &Path,
vault_path: Option<&Path>,
) -> Result<Option<PathBuf>, String> {
if let Some(vault_path) = vault_path {
return Ok(Some(vault_path.to_path_buf()));
}
if !path.is_absolute() {
return Ok(None);
}
let canonical_path = match path.canonicalize() {
Ok(canonical_path) => canonical_path,
Err(_) => return Ok(None),
};
let vault_list = vault_list::load_vault_list()?;
let registered_roots = collect_registered_vault_roots(&vault_list);
Ok(find_registered_vault_root(
canonical_path.as_path(),
&registered_roots,
))
}
#[tauri::command]
pub fn reload_vault_entry(
path: PathBuf,
vault_path: Option<PathBuf>,
) -> Result<VaultEntry, String> {
let resolved_vault_path = resolve_reload_vault_path(path.as_path(), vault_path.as_deref())?;
let raw_path = path.to_string_lossy();
let raw_vault_path = vault_path.as_ref().map(|value| value.to_string_lossy());
let raw_vault_path = resolved_vault_path
.as_ref()
.map(|value| value.to_string_lossy().into_owned());
with_validated_path(
&raw_path,
raw_vault_path.as_deref(),
@@ -49,3 +103,68 @@ pub async fn search_vault(
.await
.map_err(|e| format!("Search task failed: {}", e))?
}
#[cfg(test)]
mod tests {
use super::{collect_registered_vault_roots, find_registered_vault_root};
use crate::vault_list::{VaultEntry as VaultListEntry, VaultList};
#[test]
fn finds_registered_vault_root_for_an_absolute_note_path() {
let dir = tempfile::TempDir::new().unwrap();
let vault_root = dir.path().join("vault");
let note_path = vault_root.join("note.md");
std::fs::create_dir_all(&vault_root).unwrap();
std::fs::write(&note_path, "# Note\n").unwrap();
let vault_list = VaultList {
vaults: vec![VaultListEntry {
label: "Test".to_string(),
path: vault_root.to_string_lossy().into_owned(),
}],
active_vault: None,
hidden_defaults: vec![],
};
let registered_roots = collect_registered_vault_roots(&vault_list);
let canonical_note_path = note_path.canonicalize().unwrap();
assert_eq!(
find_registered_vault_root(canonical_note_path.as_path(), &registered_roots),
Some(vault_root),
);
}
#[test]
fn prefers_the_deepest_registered_vault_root() {
let dir = tempfile::TempDir::new().unwrap();
let parent_root = dir.path().join("vault");
let nested_root = parent_root.join("projects");
let note_path = nested_root.join("note.md");
std::fs::create_dir_all(&nested_root).unwrap();
std::fs::write(&note_path, "# Note\n").unwrap();
let vault_list = VaultList {
vaults: vec![
VaultListEntry {
label: "Parent".to_string(),
path: parent_root.to_string_lossy().into_owned(),
},
VaultListEntry {
label: "Nested".to_string(),
path: nested_root.to_string_lossy().into_owned(),
},
],
active_vault: None,
hidden_defaults: vec![],
};
let registered_roots = collect_registered_vault_roots(&vault_list);
let canonical_note_path = note_path.canonicalize().unwrap();
assert_eq!(
find_registered_vault_root(canonical_note_path.as_path(), &registered_roots),
Some(nested_root),
);
}
}

View File

@@ -156,6 +156,11 @@ fn build_app_menu(app: &App) -> MenuResult {
}
fn build_file_menu(app: &App) -> MenuResult {
let quick_open_alias_label = if cfg!(target_os = "macos") {
"Quick Open (Cmd+O)"
} else {
"Quick Open (Ctrl+O)"
};
let new_note = MenuItemBuilder::new("New Note")
.id(FILE_NEW_NOTE)
.accelerator("CmdOrCtrl+N")
@@ -167,7 +172,7 @@ fn build_file_menu(app: &App) -> MenuResult {
.id(FILE_QUICK_OPEN)
.accelerator("CmdOrCtrl+P")
.build(app)?;
let quick_open_alias = MenuItemBuilder::new("Quick Open (Cmd+O)")
let quick_open_alias = MenuItemBuilder::new(quick_open_alias_label)
.id(FILE_QUICK_OPEN_ALIAS)
.accelerator("CmdOrCtrl+O")
.build(app)?;

View File

@@ -5,7 +5,7 @@ use std::path::PathBuf;
const APP_CONFIG_DIR: &str = "com.tolaria.app";
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Settings {
pub auto_pull_interval_minutes: Option<u32>,
pub autogit_enabled: Option<bool>,
@@ -17,6 +17,7 @@ pub struct Settings {
pub analytics_enabled: Option<bool>,
pub anonymous_id: Option<String>,
pub release_channel: Option<String>,
pub theme_mode: Option<String>,
pub initial_h1_auto_rename_enabled: Option<bool>,
pub default_ai_agent: Option<String>,
}
@@ -53,6 +54,13 @@ pub fn normalize_default_ai_agent(value: Option<&str>) -> Option<String> {
}
}
pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
Some(mode) if mode == "light" || mode == "dark" => Some(mode),
_ => None,
}
}
fn normalize_settings(settings: Settings) -> Settings {
Settings {
auto_pull_interval_minutes: settings.auto_pull_interval_minutes,
@@ -69,6 +77,7 @@ fn normalize_settings(settings: Settings) -> Settings {
analytics_enabled: settings.analytics_enabled,
anonymous_id: normalize_optional_string(settings.anonymous_id),
release_channel: normalize_release_channel(settings.release_channel.as_deref()),
theme_mode: normalize_theme_mode(settings.theme_mode.as_deref()),
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
}
@@ -166,43 +175,8 @@ pub fn set_last_vault(vault_path: &str) -> Result<(), String> {
mod tests {
use super::*;
type SettingsSnapshot<'a> = (
Option<u32>,
Option<bool>,
Option<u32>,
Option<u32>,
Option<bool>,
Option<bool>,
Option<bool>,
Option<bool>,
Option<&'a str>,
Option<&'a str>,
Option<bool>,
Option<&'a str>,
);
fn settings_snapshot(settings: &Settings) -> SettingsSnapshot<'_> {
(
settings.auto_pull_interval_minutes,
settings.autogit_enabled,
settings.autogit_idle_threshold_seconds,
settings.autogit_inactive_threshold_seconds,
settings.auto_advance_inbox_after_organize,
settings.telemetry_consent,
settings.crash_reporting_enabled,
settings.analytics_enabled,
settings.anonymous_id.as_deref(),
settings.release_channel.as_deref(),
settings.initial_h1_auto_rename_enabled,
settings.default_ai_agent.as_deref(),
)
}
fn assert_empty_settings(settings: &Settings) {
assert_eq!(
settings_snapshot(settings),
(None, None, None, None, None, None, None, None, None, None, None, None)
);
assert_eq!(settings, &Settings::default());
}
/// Helper: save settings to a temp file and reload them.
@@ -244,12 +218,13 @@ mod tests {
analytics_enabled: Some(false),
anonymous_id: Some("abc-123-uuid".to_string()),
release_channel: Some("alpha".to_string()),
theme_mode: Some("dark".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
};
let json = serde_json::to_string(&settings).unwrap();
let parsed: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(settings_snapshot(&parsed), settings_snapshot(&settings));
assert_eq!(parsed, settings);
}
#[test]
@@ -269,6 +244,7 @@ mod tests {
autogit_inactive_threshold_seconds: Some(30),
auto_advance_inbox_after_organize: Some(true),
release_channel: Some("alpha".to_string()),
theme_mode: Some("dark".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
..Default::default()
@@ -279,6 +255,7 @@ mod tests {
assert_eq!(loaded.autogit_inactive_threshold_seconds, Some(30));
assert_eq!(loaded.auto_advance_inbox_after_organize, Some(true));
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
}
@@ -288,11 +265,13 @@ mod tests {
let loaded = save_and_reload(Settings {
anonymous_id: Some(" test-uuid ".to_string()),
release_channel: Some(" alpha ".to_string()),
theme_mode: Some(" dark ".to_string()),
default_ai_agent: Some(" codex ".to_string()),
..Default::default()
});
assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid"));
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
}
@@ -334,6 +313,15 @@ mod tests {
assert!(loaded.default_ai_agent.is_none());
}
#[test]
fn test_invalid_theme_mode_is_filtered() {
let loaded = save_and_reload(Settings {
theme_mode: Some("system".to_string()),
..Default::default()
});
assert!(loaded.theme_mode.is_none());
}
#[test]
fn test_get_settings_normalizes_legacy_beta_channel() {
let dir = tempfile::TempDir::new().unwrap();
@@ -384,21 +372,14 @@ mod tests {
..Default::default()
});
assert_eq!(
settings_snapshot(&loaded),
(
None,
None,
None,
None,
None,
Some(true),
Some(true),
Some(false),
Some("test-uuid-v4"),
None,
None,
None,
)
loaded,
Settings {
telemetry_consent: Some(true),
crash_reporting_enabled: Some(true),
analytics_enabled: Some(false),
anonymous_id: Some("test-uuid-v4".to_string()),
..Default::default()
}
);
}

View File

@@ -0,0 +1,101 @@
const WINDOWS_RESERVED_DEVICE_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
pub(crate) fn validate_filename_stem(stem: &str) -> Result<(), String> {
validate_portable_name_segment(stem, "Invalid filename")
}
pub(crate) fn validate_folder_name(name: &str) -> Result<(), String> {
validate_portable_name_segment(name, "Invalid folder name")
}
pub(crate) fn validate_view_filename_stem(stem: &str) -> Result<(), String> {
validate_portable_name_segment(stem, "Invalid view filename")
}
fn validate_portable_name_segment(value: &str, message: &str) -> Result<(), String> {
if is_invalid_portable_name_segment(value) {
return Err(message.to_string());
}
Ok(())
}
fn is_invalid_portable_name_segment(value: &str) -> bool {
let trimmed = value.trim();
if trimmed.is_empty() || matches!(trimmed, "." | "..") {
return true;
}
if trimmed.ends_with('.') || trimmed.ends_with(' ') {
return true;
}
if trimmed.chars().any(is_invalid_portable_name_char) {
return true;
}
is_windows_reserved_device_name(trimmed)
}
fn is_invalid_portable_name_char(ch: char) -> bool {
ch.is_control() || matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*')
}
fn is_windows_reserved_device_name(value: &str) -> bool {
let candidate = value
.split('.')
.next()
.unwrap_or(value)
.to_ascii_uppercase();
WINDOWS_RESERVED_DEVICE_NAMES
.iter()
.any(|reserved| candidate == *reserved)
}
#[cfg(test)]
mod tests {
use super::{validate_filename_stem, validate_folder_name, validate_view_filename_stem};
#[test]
fn accepts_portable_names() {
assert_eq!(validate_filename_stem("meeting-notes"), Ok(()));
assert_eq!(validate_filename_stem("draft.v2"), Ok(()));
assert_eq!(validate_folder_name("Projects"), Ok(()));
assert_eq!(validate_view_filename_stem("active-projects"), Ok(()));
}
#[test]
fn rejects_reserved_windows_device_names() {
assert_eq!(
validate_filename_stem("con"),
Err("Invalid filename".to_string())
);
assert_eq!(
validate_folder_name("Lpt1"),
Err("Invalid folder name".to_string())
);
assert_eq!(
validate_view_filename_stem("aux"),
Err("Invalid view filename".to_string())
);
}
#[test]
fn rejects_windows_invalid_characters_and_suffixes() {
assert_eq!(
validate_filename_stem("quarterly:plan"),
Err("Invalid filename".to_string())
);
assert_eq!(
validate_folder_name("Research?"),
Err("Invalid folder name".to_string())
);
assert_eq!(
validate_view_filename_stem("overview. "),
Err("Invalid view filename".to_string())
);
}
}

View File

@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Component, Path, PathBuf};
use super::filename_rules::validate_folder_name;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FolderRenameResult {
pub old_path: String,
@@ -13,9 +15,7 @@ fn normalize_folder_name(next_name: &str) -> Result<String, String> {
if trimmed.is_empty() {
return Err("Folder name cannot be empty".to_string());
}
if trimmed == "." || trimmed == ".." || trimmed.contains('/') || trimmed.contains('\\') {
return Err("Invalid folder name".to_string());
}
validate_folder_name(trimmed)?;
Ok(trimmed.to_string())
}
@@ -160,6 +160,16 @@ mod tests {
assert_eq!(error, "Invalid folder name");
}
#[test]
fn rename_folder_rejects_windows_invalid_names() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects");
let error = rename_folder(dir.path(), "projects", "LPT1").unwrap_err();
assert_eq!(error, "Invalid folder name");
}
#[test]
fn delete_folder_removes_nested_contents() {
let dir = TempDir::new().unwrap();

View File

@@ -2,6 +2,7 @@ mod cache;
mod config_seed;
mod entry;
mod file;
pub(crate) mod filename_rules;
mod folders;
mod frontmatter;
mod getting_started;
@@ -27,7 +28,8 @@ pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{
auto_rename_untitled, detect_renames, move_note_to_folder, rename_note, rename_note_filename,
update_wikilinks_for_renames, DetectedRename, RenameResult,
update_wikilinks_for_renames, AutoRenameUntitledRequest, DetectedRename,
MoveNoteToFolderRequest, RenameNoteFilenameRequest, RenameNoteRequest, RenameResult,
};
pub use title_sync::{sync_title_on_open, SyncAction};
pub use trash::{batch_delete_notes, delete_note};

View File

@@ -6,6 +6,7 @@ use std::path::Path;
use tempfile::NamedTempFile;
use walkdir::WalkDir;
use super::filename_rules::validate_filename_stem;
use super::rename_transaction::RenameWorkspace;
use crate::frontmatter::{update_frontmatter_content, FrontmatterValue};
@@ -20,6 +21,34 @@ pub struct RenameResult {
pub failed_updates: usize,
}
#[derive(Clone, Copy)]
pub struct RenameNoteRequest<'a> {
pub vault_path: &'a str,
pub old_path: &'a str,
pub new_title: &'a str,
pub old_title_hint: Option<&'a str>,
}
#[derive(Clone, Copy)]
pub struct RenameNoteFilenameRequest<'a> {
pub vault_path: &'a str,
pub old_path: &'a str,
pub new_filename_stem: &'a str,
}
#[derive(Clone, Copy)]
pub struct MoveNoteToFolderRequest<'a> {
pub vault_path: &'a str,
pub old_path: &'a str,
pub destination_folder_path: &'a str,
}
#[derive(Clone, Copy)]
pub struct AutoRenameUntitledRequest<'a> {
pub vault_path: &'a str,
pub note_path: &'a str,
}
#[derive(Debug, Default)]
struct WikilinkUpdateSummary {
updated_files: usize,
@@ -176,12 +205,13 @@ fn update_note_title_in_content(content: &str, new_title: &str) -> String {
}
/// Strip vault prefix and .md suffix to get the relative path stem (e.g., "project/weekly-review").
fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
abs_path
.strip_prefix(vault_prefix)
.unwrap_or(abs_path)
fn to_path_stem(path: &Path, vault_root: &Path) -> String {
let relative = path.strip_prefix(vault_root).unwrap_or(path);
let normalized = relative.to_string_lossy().replace('\\', "/");
normalized
.strip_suffix(".md")
.unwrap_or(abs_path)
.unwrap_or(&normalized)
.to_string()
}
pub(crate) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
@@ -197,8 +227,7 @@ fn persist_staged_note(staged: NamedTempFile, target_path: &Path) -> Result<(),
fn finalize_rename(vault: &Path, old_targets: &[&str], new_file: &Path) -> RenameResult {
let new_path = new_file.to_string_lossy().to_string();
let vault_prefix = format!("{}/", vault.to_string_lossy());
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
let new_path_stem = to_path_stem(new_file, vault);
let summary = update_wikilinks_in_vault(vault, old_targets, &new_path_stem, new_file);
RenameResult {
new_path,
@@ -213,25 +242,19 @@ fn normalize_filename_stem(new_filename_stem: &str) -> Result<String, String> {
if stem.is_empty() {
return Err("New filename cannot be empty".to_string());
}
if is_invalid_filename_stem(stem) {
return Err("Invalid filename".to_string());
}
validate_filename_stem(stem)?;
Ok(stem.to_string())
}
fn is_invalid_filename_stem(stem: &str) -> bool {
stem == "." || stem == ".." || stem.contains('/') || stem.contains('\\')
}
struct LoadedNote {
content: String,
filename: String,
title: String,
}
fn unchanged_result(path: &str) -> RenameResult {
fn unchanged_result(path: &Path) -> RenameResult {
RenameResult {
new_path: path.to_string(),
new_path: path.to_string_lossy().to_string(),
updated_files: 0,
failed_updates: 0,
}
@@ -245,20 +268,19 @@ fn validate_new_title(new_title: &str) -> Result<&str, String> {
Ok(trimmed)
}
fn ensure_existing_note(old_file: &Path, old_path: &str) -> Result<(), String> {
fn ensure_existing_note(old_file: &Path) -> Result<(), String> {
if old_file.exists() {
return Ok(());
}
Err(format!("File does not exist: {}", old_path))
Err(format!("File does not exist: {}", old_file.display()))
}
fn load_note_for_title_rename(
old_file: &Path,
old_path: &str,
old_title_hint: Option<&str>,
) -> Result<LoadedNote, String> {
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let content = fs::read_to_string(old_file)
.map_err(|e| format!("Failed to read {}: {}", old_file.display(), e))?;
let filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
@@ -277,10 +299,9 @@ fn persist_title_only_update(
workspace: &RenameWorkspace,
old_file: &Path,
updated_content: &str,
old_path: &str,
) -> Result<RenameResult, String> {
persist_staged_note(workspace.stage_note_content(updated_content)?, old_file)?;
Ok(unchanged_result(old_path))
Ok(unchanged_result(old_file))
}
/// Rename a note: update its frontmatter title, rename the file, and update wiki links across the vault.
@@ -288,19 +309,14 @@ fn persist_title_only_update(
/// When `old_title_hint` is provided it is used instead of extracting the title from
/// the file's frontmatter/filename. This is needed when the caller has already saved
/// updated content to disk before triggering the rename.
pub fn rename_note(
vault_path: &str,
old_path: &str,
new_title: &str,
old_title_hint: Option<&str>,
) -> Result<RenameResult, String> {
let vault = Path::new(vault_path);
let old_file = Path::new(old_path);
pub fn rename_note(request: RenameNoteRequest<'_>) -> Result<RenameResult, String> {
let vault = Path::new(request.vault_path);
let old_file = Path::new(request.old_path);
recover_pending_rename_transactions(vault)?;
ensure_existing_note(old_file, old_path)?;
let new_title = validate_new_title(new_title)?;
let loaded = load_note_for_title_rename(old_file, old_path, old_title_hint)?;
ensure_existing_note(old_file)?;
let new_title = validate_new_title(request.new_title)?;
let loaded = load_note_for_title_rename(old_file, request.old_title_hint)?;
// Check both title and filename: even if the title in content matches,
// the filename may still be stale (e.g. "untitled-note.md" after user changed H1).
@@ -309,7 +325,7 @@ pub fn rename_note(
let filename_matches = loaded.filename == expected_filename;
if title_unchanged && filename_matches {
return Ok(unchanged_result(old_path));
return Ok(unchanged_result(old_file));
}
// Update content only if the title actually changed
@@ -321,53 +337,50 @@ pub fn rename_note(
let workspace = RenameWorkspace::new(vault)?;
if filename_matches {
return persist_title_only_update(&workspace, old_file, &updated_content, old_path);
return persist_title_only_update(&workspace, old_file, &updated_content);
}
let parent_dir = old_file
.parent()
.ok_or("Cannot determine parent directory")?;
let committed = workspace
.operation(old_path, old_file)
.operation(request.old_path, old_file)
.rename_with_candidates(
workspace.stage_note_content(&updated_content)?,
&expected_filename,
parent_dir,
)?;
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let old_targets = collect_legacy_wikilink_targets(&loaded.title, old_path_stem);
let old_path_stem = to_path_stem(old_file, vault);
let old_targets = collect_legacy_wikilink_targets(&loaded.title, &old_path_stem);
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
}
/// Rename only the file path stem while preserving title/frontmatter content.
pub fn rename_note_filename(
vault_path: &str,
old_path: &str,
new_filename_stem: &str,
request: RenameNoteFilenameRequest<'_>,
) -> Result<RenameResult, String> {
let vault = Path::new(vault_path);
let old_file = Path::new(old_path);
let vault = Path::new(request.vault_path);
let old_file = Path::new(request.old_path);
recover_pending_rename_transactions(vault)?;
if !old_file.exists() {
return Err(format!("File does not exist: {}", old_path));
return Err(format!("File does not exist: {}", old_file.display()));
}
let normalized_stem = normalize_filename_stem(new_filename_stem)?;
let normalized_stem = normalize_filename_stem(request.new_filename_stem)?;
let old_filename = old_file
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let content = fs::read_to_string(old_file)
.map_err(|e| format!("Failed to read {}: {}", request.old_path, e))?;
let fm_title = extract_fm_title_value(&content);
let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
let new_filename = format!("{}.md", normalized_stem);
if old_filename == new_filename {
return Ok(unchanged_result(old_path));
return Ok(unchanged_result(old_file));
}
let parent_dir = old_file
@@ -376,38 +389,33 @@ pub fn rename_note_filename(
let new_file = parent_dir.join(&new_filename);
let workspace = RenameWorkspace::new(vault)?;
let committed = workspace
.operation(old_path, old_file)
.operation(request.old_path, old_file)
.rename_exact(workspace.stage_note_content(&content)?, &new_file)?;
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
let old_path_stem = to_path_stem(old_file, vault);
let old_targets = collect_legacy_wikilink_targets(&old_title, &old_path_stem);
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
}
/// Move a note into a different folder while preserving its filename and content.
pub fn move_note_to_folder(
vault_path: &str,
old_path: &str,
destination_folder_path: &str,
) -> Result<RenameResult, String> {
let vault = Path::new(vault_path);
let old_file = Path::new(old_path);
let destination_dir = Path::new(destination_folder_path);
pub fn move_note_to_folder(request: MoveNoteToFolderRequest<'_>) -> Result<RenameResult, String> {
let vault = Path::new(request.vault_path);
let old_file = Path::new(request.old_path);
let destination_dir = Path::new(request.destination_folder_path);
recover_pending_rename_transactions(vault)?;
ensure_existing_note(old_file, old_path)?;
ensure_existing_note(old_file)?;
if !destination_dir.exists() {
return Err(format!(
"Folder does not exist: {}",
destination_folder_path
request.destination_folder_path
));
}
if !destination_dir.is_dir() {
return Err(format!(
"Folder is not a directory: {}",
destination_folder_path
request.destination_folder_path
));
}
@@ -415,24 +423,23 @@ pub fn move_note_to_folder(
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
let content =
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
let content = fs::read_to_string(old_file)
.map_err(|e| format!("Failed to read {}: {}", request.old_path, e))?;
let fm_title = extract_fm_title_value(&content);
let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
let new_file = destination_dir.join(&old_filename);
if new_file == old_file {
return Ok(unchanged_result(old_path));
return Ok(unchanged_result(old_file));
}
let workspace = RenameWorkspace::new(vault)?;
let committed = workspace
.operation(old_path, old_file)
.operation(request.old_path, old_file)
.rename_exact(workspace.stage_note_content(&content)?, &new_file)?;
let vault_prefix = format!("{}/", vault.to_string_lossy());
let old_path_stem = to_path_stem(old_path, &vault_prefix);
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
let old_path_stem = to_path_stem(old_file, vault);
let old_targets = collect_legacy_wikilink_targets(&old_title, &old_path_stem);
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
}
@@ -451,10 +458,9 @@ fn is_untitled_filename(filename: &str) -> bool {
/// Returns `Some(RenameResult)` if renamed, `None` if conditions not met.
/// This is a ONE-SHOT rename: only fires for untitled-* files with an H1.
pub fn auto_rename_untitled(
vault_path: &str,
note_path: &str,
request: AutoRenameUntitledRequest<'_>,
) -> Result<Option<RenameResult>, String> {
let path = Path::new(note_path);
let path = Path::new(request.note_path);
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
@@ -464,15 +470,20 @@ pub fn auto_rename_untitled(
return Ok(None);
}
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", note_path, e))?;
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", request.note_path, e))?;
let h1_title = match super::parsing::extract_h1_title(&content) {
Some(t) => t,
None => return Ok(None),
};
let result = rename_note(vault_path, note_path, &h1_title, None)?;
let result = rename_note(RenameNoteRequest {
vault_path: request.vault_path,
old_path: request.note_path,
new_title: &h1_title,
old_title_hint: None,
})?;
Ok(Some(result))
}
@@ -484,8 +495,7 @@ pub struct DetectedRename {
}
/// Detect renamed files by comparing working tree against HEAD using git diff.
pub fn detect_renames(vault_path: &str) -> Result<Vec<DetectedRename>, String> {
let vault = Path::new(vault_path);
pub fn detect_renames(vault: &Path) -> Result<Vec<DetectedRename>, String> {
let output = std::process::Command::new("git")
.args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"])
.current_dir(vault)
@@ -521,28 +531,21 @@ pub fn detect_renames(vault_path: &str) -> Result<Vec<DetectedRename>, String> {
/// Update wikilinks across the vault for a list of detected renames.
/// Returns the total number of files updated.
pub fn update_wikilinks_for_renames(
vault_path: &str,
vault: &Path,
renames: &[DetectedRename],
) -> Result<usize, String> {
let vault = Path::new(vault_path);
let mut total_updated = 0;
for rename in renames {
let old_stem = rename
.old_path
.strip_suffix(".md")
.unwrap_or(&rename.old_path);
let new_stem = rename
.new_path
.strip_suffix(".md")
.unwrap_or(&rename.new_path);
let old_filename_stem = old_stem.split('/').next_back().unwrap_or(old_stem);
let old_file = vault.join(&rename.old_path);
let new_file = vault.join(&rename.new_path);
let old_stem = to_path_stem(&old_file, vault);
let new_stem = to_path_stem(&new_file, vault);
let old_filename_stem = old_stem.split('/').next_back().unwrap_or(&old_stem);
// Build title from filename stem (kebab-case → Title Case)
let old_title = super::parsing::slug_to_title(old_filename_stem);
// The new file is the exclude target (don't rewrite wikilinks inside the renamed file itself)
let new_file = vault.join(&rename.new_path);
let old_targets = collect_legacy_wikilink_targets(&old_title, old_stem);
let summary = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
let old_targets = collect_legacy_wikilink_targets(&old_title, &old_stem);
let summary = update_wikilinks_in_vault(vault, &old_targets, &new_stem, &new_file);
total_updated += summary.updated_files;
}
@@ -555,13 +558,13 @@ mod tests {
use std::io::Write;
use tempfile::TempDir;
fn create_test_file(dir: &Path, name: &str, content: &str) {
fn create_test_file(dir: &Path, name: impl AsRef<Path>, content: impl AsRef<[u8]>) {
let file_path = dir.join(name);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).unwrap();
}
let mut file = fs::File::create(file_path).unwrap();
file.write_all(content.as_bytes()).unwrap();
file.write_all(content.as_ref()).unwrap();
}
struct RenameTestRequest<'a> {
@@ -577,16 +580,60 @@ mod tests {
) -> (std::path::PathBuf, RenameResult) {
create_test_file(vault, request.path, request.content);
let old_path = vault.join(request.path);
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
request.new_title,
request.old_title_hint,
)
let result = rename_note(RenameNoteRequest {
vault_path: vault.to_str().unwrap(),
old_path: old_path.to_str().unwrap(),
new_title: request.new_title,
old_title_hint: request.old_title_hint,
})
.unwrap();
(old_path, result)
}
fn create_current_note(vault: &Path, relative_path: impl AsRef<Path>) -> std::path::PathBuf {
let relative_path = relative_path.as_ref();
create_test_file(vault, relative_path, "# Current\n");
vault.join(relative_path)
}
fn assert_rename_note_filename_error<P>(
new_filename_stem: impl AsRef<str>,
existing_destination: Option<P>,
expected_error: impl AsRef<str>,
) where
P: AsRef<Path>,
{
let dir = TempDir::new().unwrap();
let vault = dir.path();
let current_path = create_current_note(vault, "note/current.md");
if let Some(existing_path) = existing_destination {
create_test_file(vault, existing_path.as_ref(), "# Existing\n");
}
let result = rename_note_filename(RenameNoteFilenameRequest {
vault_path: vault.to_str().unwrap(),
old_path: current_path.to_str().unwrap(),
new_filename_stem: new_filename_stem.as_ref(),
});
assert_eq!(result.unwrap_err(), expected_error.as_ref());
}
fn assert_move_note_to_folder_error(expected_error: impl AsRef<str>) {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
create_test_file(vault, "areas/weekly-review.md", "# Existing\n");
let result = move_note_to_folder(MoveNoteToFolderRequest {
vault_path: vault.to_str().unwrap(),
old_path: vault.join("projects/weekly-review.md").to_str().unwrap(),
destination_folder_path: vault.join("areas").to_str().unwrap(),
});
assert_eq!(result.unwrap_err(), expected_error.as_ref());
}
#[test]
fn test_title_to_slug() {
assert_eq!(title_to_slug("Weekly Review"), "weekly-review");
@@ -605,12 +652,12 @@ mod tests {
);
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retrospective",
None,
)
let result = rename_note(RenameNoteRequest {
vault_path: vault.to_str().unwrap(),
old_path: old_path.to_str().unwrap(),
new_title: "Sprint Retrospective",
old_title_hint: None,
})
.unwrap();
assert!(result.new_path.ends_with("sprint-retrospective.md"));
@@ -644,12 +691,12 @@ mod tests {
);
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retrospective",
None,
)
let result = rename_note(RenameNoteRequest {
vault_path: vault.to_str().unwrap(),
old_path: old_path.to_str().unwrap(),
new_title: "Sprint Retrospective",
old_title_hint: None,
})
.unwrap();
assert_eq!(result.updated_files, 2);
@@ -669,12 +716,12 @@ mod tests {
create_test_file(vault, "note/test.md", "# Test\n");
let old_path = vault.join("note/test.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
" ",
None,
);
let result = rename_note(RenameNoteRequest {
vault_path: vault.to_str().unwrap(),
old_path: old_path.to_str().unwrap(),
new_title: " ",
old_title_hint: None,
});
assert!(result.is_err());
}
@@ -767,12 +814,12 @@ mod tests {
);
let old_path = vault.join("note/old.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"New Name",
None,
)
let result = rename_note(RenameNoteRequest {
vault_path: vault.to_str().unwrap(),
old_path: old_path.to_str().unwrap(),
new_title: "New Name",
old_title_hint: None,
})
.unwrap();
let content = fs::read_to_string(&result.new_path).unwrap();
@@ -785,21 +832,25 @@ mod tests {
/// Helper: create a note, rename it, assert the rename succeeded and old file is gone.
/// Returns the content of the renamed file for further assertions.
fn rename_test_note(filename: &str, content: &str, new_title: &str) -> String {
fn rename_test_note(
filename: impl AsRef<Path>,
content: impl AsRef<str>,
new_title: impl AsRef<str>,
) -> String {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, filename, content);
create_test_file(vault, filename.as_ref(), content.as_ref());
let old_path = vault.join(filename);
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
new_title,
None,
)
let old_path = vault.join(filename.as_ref());
let result = rename_note(RenameNoteRequest {
vault_path: vault.to_str().unwrap(),
old_path: old_path.to_str().unwrap(),
new_title: new_title.as_ref(),
old_title_hint: None,
})
.expect("rename_note should succeed");
let expected_slug = title_to_slug(new_title);
let expected_slug = title_to_slug(new_title.as_ref());
assert!(
result.new_path.ends_with(&format!("{}.md", expected_slug)),
"new path should end with slug: {}",
@@ -869,12 +920,12 @@ mod tests {
);
let old_path = vault.join("note/untitled-note.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"My New Note",
None,
)
let result = rename_note(RenameNoteRequest {
vault_path: vault.to_str().unwrap(),
old_path: old_path.to_str().unwrap(),
new_title: "My New Note",
old_title_hint: None,
})
.unwrap();
// File should be renamed to match the title slug
@@ -910,12 +961,12 @@ mod tests {
);
let old_path = vault.join("note/untitled-note.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"My Note",
None,
)
let result = rename_note(RenameNoteRequest {
vault_path: vault.to_str().unwrap(),
old_path: old_path.to_str().unwrap(),
new_title: "My Note",
old_title_hint: None,
})
.unwrap();
// Should get a suffixed name to avoid collision
@@ -946,11 +997,11 @@ mod tests {
);
let old_path = vault.join("note/project-kickoff.md");
let result = rename_note_filename(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"manual-name",
)
let result = rename_note_filename(RenameNoteFilenameRequest {
vault_path: vault.to_str().unwrap(),
old_path: old_path.to_str().unwrap(),
new_filename_stem: "manual-name",
})
.unwrap();
assert!(result.new_path.ends_with("manual-name.md"));
@@ -968,18 +1019,16 @@ mod tests {
#[test]
fn test_rename_note_filename_rejects_existing_destination() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "note/current.md", "# Current\n");
create_test_file(vault, "note/manual-name.md", "# Existing\n");
let result = rename_note_filename(
vault.to_str().unwrap(),
vault.join("note/current.md").to_str().unwrap(),
assert_rename_note_filename_error(
"manual-name",
Some("note/manual-name.md"),
"A note with that name already exists",
);
}
assert_eq!(result.unwrap_err(), "A note with that name already exists");
#[test]
fn test_rename_note_filename_rejects_windows_invalid_names() {
assert_rename_note_filename_error("quarterly:plan", None::<&str>, "Invalid filename");
}
#[test]
@@ -997,11 +1046,11 @@ mod tests {
"Reference [[projects/weekly-review]]\n",
);
let result = move_note_to_folder(
vault.to_str().unwrap(),
vault.join("projects/weekly-review.md").to_str().unwrap(),
vault.join("areas").to_str().unwrap(),
)
let result = move_note_to_folder(MoveNoteToFolderRequest {
vault_path: vault.to_str().unwrap(),
old_path: vault.join("projects/weekly-review.md").to_str().unwrap(),
destination_folder_path: vault.join("areas").to_str().unwrap(),
})
.expect("move should succeed");
assert!(result.new_path.ends_with("areas/weekly-review.md"));
@@ -1020,11 +1069,11 @@ mod tests {
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
let source = vault.join("projects/weekly-review.md");
let result = move_note_to_folder(
vault.to_str().unwrap(),
source.to_str().unwrap(),
vault.join("projects").to_str().unwrap(),
)
let result = move_note_to_folder(MoveNoteToFolderRequest {
vault_path: vault.to_str().unwrap(),
old_path: source.to_str().unwrap(),
destination_folder_path: vault.join("projects").to_str().unwrap(),
})
.expect("move should noop");
assert_eq!(result.new_path, source.to_string_lossy());
@@ -1034,18 +1083,7 @@ mod tests {
#[test]
fn test_move_note_to_folder_rejects_existing_destination() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
create_test_file(vault, "areas/weekly-review.md", "# Existing\n");
let result = move_note_to_folder(
vault.to_str().unwrap(),
vault.join("projects/weekly-review.md").to_str().unwrap(),
vault.join("areas").to_str().unwrap(),
);
assert_eq!(result.unwrap_err(), "A note with that name already exists");
assert_move_note_to_folder_error("A note with that name already exists");
}
#[test]
@@ -1073,12 +1111,12 @@ mod tests {
let old_path = vault.join("note/weekly-review.md");
// Without old_title_hint, rename_note would see H1 = "Sprint Retrospective" == new_title → noop
// With old_title_hint = "Weekly Review", it knows to search for [[Weekly Review]] and replace
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Sprint Retrospective",
Some("Weekly Review"),
)
let result = rename_note(RenameNoteRequest {
vault_path: vault.to_str().unwrap(),
old_path: old_path.to_str().unwrap(),
new_title: "Sprint Retrospective",
old_title_hint: Some("Weekly Review"),
})
.unwrap();
assert_eq!(result.updated_files, 2);
@@ -1105,12 +1143,12 @@ mod tests {
);
let old_path = vault.join("note/old.md");
let result = rename_note(
vault.to_str().unwrap(),
old_path.to_str().unwrap(),
"Brand New Title",
None,
)
let result = rename_note(RenameNoteRequest {
vault_path: vault.to_str().unwrap(),
old_path: old_path.to_str().unwrap(),
new_title: "Brand New Title",
old_title_hint: None,
})
.unwrap();
let content = fs::read_to_string(&result.new_path).unwrap();

View File

@@ -47,6 +47,11 @@
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
}
},
"resources": {
"resources/mcp-server/**/*": "mcp-server/"
},
@@ -63,6 +68,9 @@
"endpoints": [
"https://refactoringhq.github.io/tolaria/stable/latest.json"
],
"windows": {
"installMode": "passive"
},
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE4NkQ5MDI3REVCRkFGNUMKUldSY3I3L2VKNUJ0cU5JRlRZZlp3NGhnU3ZwbkVKeGVvREpmb2sxRVJndHFpVFZPNlArbEE5R1IK"
}
}

View File

@@ -68,9 +68,9 @@
/* AI highlight animation — applied by useAiActivity when MCP calls ui_highlight */
@keyframes ai-highlight-glow {
0% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0.8); }
50% { box-shadow: inset 0 0 8px 2px rgba(99, 102, 241, 0.4); }
100% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0); }
0% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent-blue) 80%, transparent); }
50% { box-shadow: inset 0 0 8px 2px color-mix(in srgb, var(--accent-blue) 40%, transparent); }
100% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent-blue) 0%, transparent); }
}
.ai-highlight {

View File

@@ -2,6 +2,7 @@ import { act, render, screen, fireEvent, waitFor, within } from '@testing-librar
import type { ReactNode } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { DEFAULT_VAULTS } from './hooks/useVaultSwitcher'
import { formatShortcutDisplay } from './hooks/appCommandCatalog'
// Provide a localStorage mock that supports all methods (jsdom's may be incomplete)
const localStorageMock = (() => {
@@ -305,6 +306,7 @@ vi.mock('@blocknote/react', () => ({
{children}
</div>
),
LinkToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
ComponentsContext: {
Provider: ({ children }: { children?: ReactNode }) => <>{children}</>,
},
@@ -317,8 +319,30 @@ vi.mock('@blocknote/react', () => ({
focus: () => {},
onMount: (cb: () => void) => { cb(); return () => {} },
}),
LinkToolbarController: () => null,
EditLinkButton: () => null,
DeleteLinkButton: () => null,
SideMenuController: () => null,
SuggestionMenuController: () => null,
useComponentsContext: () => ({
LinkToolbar: {
Button: ({
children,
label,
onClick,
}: { children?: ReactNode; label?: string; onClick?: () => void }) => (
<button onClick={onClick} type="button">
{label}
{children}
</button>
),
},
}),
useDictionary: () => ({
link_toolbar: {
open: { tooltip: 'Open in a new tab' },
},
}),
}))
vi.mock('@blocknote/mantine', () => ({
@@ -368,9 +392,14 @@ describe('App', () => {
})
it('shows keyboard shortcut hints', async () => {
render(<App />)
const quickOpenHint = formatShortcutDisplay({ display: '⌘P / ⌘O' })
const newNoteHint = formatShortcutDisplay({ display: '⌘N' })
const { container } = render(<App />)
await waitFor(() => {
expect(screen.getByText(/Cmd\+P or Cmd\+O to search/)).toBeInTheDocument()
const shortcutHint = Array.from(container.querySelectorAll('span.text-xs.text-muted-foreground'))
.find((element) => element.textContent === `${quickOpenHint} to search · ${newNoteHint} to create`)
expect(shortcutHint).toBeInTheDocument()
})
})

View File

@@ -31,6 +31,8 @@ import { useAutoGit } from './hooks/useAutoGit'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
import { useSettings } from './hooks/useSettings'
import { useDocumentThemeMode } from './hooks/useDocumentThemeMode'
import { useThemeMode } from './hooks/useThemeMode'
import { useNoteActions } from './hooks/useNoteActions'
import { planNewTypeCreation } from './hooks/useNoteCreation'
import { useCommitFlow } from './hooks/useCommitFlow'
@@ -367,6 +369,12 @@ function App() {
})
}, [updateConfig, vaultConfig.inbox?.noteListProperties])
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
useThemeMode(settings.theme_mode, settingsLoaded)
const documentThemeMode = useDocumentThemeMode()
const handleToggleThemeMode = useCallback(() => {
const theme_mode = documentThemeMode === 'dark' ? 'light' : 'dark'
void saveSettings({ ...settings, theme_mode })
}, [documentThemeMode, saveSettings, settings])
const aiAgentPreferences = useAiAgentPreferences({
settings,
saveSettings,
@@ -421,7 +429,7 @@ function App() {
const handleFocus = () => {
invoke<DetectedRename[]>('detect_renames', { vaultPath: resolvedPath })
.then(renames => { if (renames.length > 0) setDetectedRenames(renames) })
.catch(() => {}) // ignore errors (e.g., no git)
.catch((err) => console.warn('[vault] Git rename detection failed:', err))
}
window.addEventListener('focus', handleFocus)
return () => window.removeEventListener('focus', handleFocus)
@@ -554,7 +562,7 @@ function App() {
if (!isTauri()) { document.title = title; return }
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
getCurrentWindow().setTitle(title)
}).catch(() => {})
}).catch((err) => console.warn('[window] Failed to update note window title:', err))
}, [noteWindowParams, notes.tabs, notes.activeTabPath])
// Keep note entry in sync with vault entries so banners (trash/archive)
@@ -1032,7 +1040,7 @@ function App() {
inspectorCollapsed: nextInspectorCollapsed,
})
void applyMainWindowSizeConstraints(minWidth).catch(() => {})
void applyMainWindowSizeConstraints(minWidth).catch((err) => console.warn('[window] Size constraints failed:', err))
}, [layout.inspectorCollapsed, noteWindowParams])
const handleSetViewMode = useCallback((mode: ViewMode) => {
@@ -1490,7 +1498,7 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />

View File

@@ -69,13 +69,13 @@ describe('AiActionCard', () => {
it('uses lighter background for open_note tool', () => {
render(<AiActionCard {...defaults} tool="open_note" label="Opening note" />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.06')
expect(card.style.background).toBe('var(--accent-blue-light)')
})
it('uses standard background for vault tools', () => {
render(<AiActionCard {...defaults} />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.1')
expect(card.style.background).toBe('var(--accent-blue-bg)')
})
// --- Expand / collapse ---

View File

@@ -20,6 +20,10 @@ export interface AiActionCardProps {
}
const MAX_DETAIL_LENGTH = 800
const DEFAULT_ACTION_CARD_BACKGROUND = 'var(--accent-blue-bg)'
const TOOL_BACKGROUND_MAP: Record<string, string> = {
open_note: 'var(--accent-blue-light)',
}
type IconRenderer = (size: number) => ReactNode
@@ -66,6 +70,71 @@ function formatInputForDisplay(raw: string): string {
}
}
function hasActionDetails(input?: string, output?: string): boolean {
return Boolean(input || output)
}
function resolveDirectOpenPath({
hasDetails,
onOpenNote,
path,
}: Pick<AiActionCardProps, 'onOpenNote' | 'path'> & {
hasDetails: boolean
}): string | null {
if (hasDetails || !path || !onOpenNote) return null
return path
}
function ActionCardHeader({
expanded,
hasDetails,
label,
onClick,
onKeyDown,
renderIcon,
status,
}: {
expanded: boolean
hasDetails: boolean
label: string
onClick: () => void
onKeyDown: (event: KeyboardEvent) => void
renderIcon: IconRenderer
status: AiActionStatus
}) {
return (
<div
className="flex items-center gap-2"
style={{ padding: '6px 10px', cursor: 'pointer' }}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={onClick}
onKeyDown={onKeyDown}
data-testid="action-card-header"
>
<span className="shrink-0 text-muted-foreground" style={{ width: 14, display: 'flex' }}>
<ActionIcon expanded={expanded} hasDetails={hasDetails} renderIcon={renderIcon} />
</span>
<span className="flex-1 truncate">{label}</span>
<StatusIndicator status={status} />
</div>
)
}
function ActionIcon({
expanded,
hasDetails,
renderIcon,
}: {
expanded: boolean
hasDetails: boolean
renderIcon: IconRenderer
}) {
if (!hasDetails) return <>{renderIcon(14)}</>
return expanded ? <CaretDown size={12} /> : <CaretRight size={12} />
}
function DetailBlock({ label, content, isError }: {
label: string; content: string; isError?: boolean
}) {
@@ -100,16 +169,41 @@ function DetailBlock({ label, content, isError }: {
)
}
/** Whether this tool is a Tolaria UI-only tool (lighter styling). */
function isUiOnlyTool(tool: string): boolean {
return tool === 'open_note'
function ActionCardDetails({
expanded,
hasDetails,
input,
output,
status,
}: {
expanded: boolean
hasDetails: boolean
input?: string
output?: string
status: AiActionStatus
}) {
if (!expanded || !hasDetails) return null
const formattedInput = input ? formatInputForDisplay(input) : undefined
return (
<div
data-testid="action-card-details"
style={{ padding: '0 10px 8px 10px' }}
>
{formattedInput && <DetailBlock label="Input" content={formattedInput} />}
{output && (
<DetailBlock label="Output" content={output} isError={status === 'error'} />
)}
</div>
)
}
export function AiActionCard({
tool, label, path, status, input, output, expanded, onToggle, onOpenNote,
}: AiActionCardProps) {
const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON
const hasDetails = !!(input || output)
const hasDetails = hasActionDetails(input, output)
const directOpenPath = resolveDirectOpenPath({ path, onOpenNote, hasDetails })
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -122,14 +216,13 @@ export function AiActionCard({
}, [onToggle, expanded])
const handleClick = useCallback(() => {
if (path && onOpenNote && !hasDetails) {
onOpenNote(path)
} else {
onToggle()
if (directOpenPath && onOpenNote) {
onOpenNote(directOpenPath)
return
}
}, [path, onOpenNote, hasDetails, onToggle])
const formattedInput = input ? formatInputForDisplay(input) : undefined
onToggle()
}, [directOpenPath, onOpenNote, onToggle])
return (
<div
@@ -137,38 +230,25 @@ export function AiActionCard({
className="rounded"
style={{
fontSize: 12,
background: isUiOnlyTool(tool) ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
background: TOOL_BACKGROUND_MAP[tool] ?? DEFAULT_ACTION_CARD_BACKGROUND,
}}
>
<div
className="flex items-center gap-2"
style={{ padding: '6px 10px', cursor: 'pointer' }}
role="button"
tabIndex={0}
aria-expanded={expanded}
<ActionCardHeader
expanded={expanded}
hasDetails={hasDetails}
label={label}
onClick={handleClick}
onKeyDown={handleKeyDown}
data-testid="action-card-header"
>
<span className="shrink-0 text-muted-foreground" style={{ width: 14, display: 'flex' }}>
{hasDetails
? (expanded ? <CaretDown size={12} /> : <CaretRight size={12} />)
: renderIcon(14)}
</span>
<span className="flex-1 truncate">{label}</span>
<StatusIndicator status={status} />
</div>
{expanded && hasDetails && (
<div
data-testid="action-card-details"
style={{ padding: '0 10px 8px 10px' }}
>
{formattedInput && <DetailBlock label="Input" content={formattedInput} />}
{output && (
<DetailBlock label="Output" content={output} isError={status === 'error'} />
)}
</div>
)}
renderIcon={renderIcon}
status={status}
/>
<ActionCardDetails
expanded={expanded}
hasDetails={hasDetails}
input={input}
output={output}
status={status}
/>
</div>
)
}

View File

@@ -19,7 +19,7 @@ interface AiAgentsOnboardingPromptProps {
function getPromptCopy(statuses: AiAgentsStatus) {
if (isAiAgentsStatusChecking(statuses)) {
return {
accentClassName: 'bg-slate-100 text-slate-600',
accentClassName: 'bg-muted text-muted-foreground',
description: 'Checking which AI agents are available on this machine.',
icon: <Loader2 className="size-7 animate-spin" />,
title: 'Checking AI agents',
@@ -28,7 +28,7 @@ function getPromptCopy(statuses: AiAgentsStatus) {
if (!hasAnyInstalledAiAgent(statuses)) {
return {
accentClassName: 'bg-amber-100 text-amber-700',
accentClassName: 'bg-[var(--feedback-warning-bg)] text-[var(--feedback-warning-text)]',
description: 'Tolaria works best with a local CLI AI agent installed.',
icon: <Bot className="size-7" />,
title: 'No AI agents detected',
@@ -36,7 +36,7 @@ function getPromptCopy(statuses: AiAgentsStatus) {
}
return {
accentClassName: 'bg-emerald-100 text-emerald-700',
accentClassName: 'bg-[var(--feedback-success-bg)] text-[var(--feedback-success-text)]',
description: 'Your AI agents are ready to use in Tolaria.',
icon: <CheckCircle2 className="size-7" />,
title: 'AI agents ready',
@@ -63,7 +63,7 @@ function AgentStatusList({ statuses }: { statuses: AiAgentsStatus }) {
</div>
</div>
<span
className={`rounded-full px-2 py-1 text-[11px] font-medium ${ready ? 'bg-emerald-100 text-emerald-700' : 'bg-amber-100 text-amber-700'}`}
className={`rounded-full px-2 py-1 text-[11px] font-medium ${ready ? 'bg-[var(--feedback-success-bg)] text-[var(--feedback-success-text)]' : 'bg-[var(--feedback-warning-bg)] text-[var(--feedback-warning-text)]'}`}
>
{ready ? 'Installed' : 'Missing'}
</span>
@@ -106,11 +106,11 @@ export function AiAgentsOnboardingPrompt({
<CardContent className="space-y-4">
{showLegacyClaudeCompatibility ? (
<div
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-left"
className="rounded-lg border border-[var(--feedback-warning-border)] bg-[var(--feedback-warning-bg)] px-4 py-3 text-left"
data-testid="claude-onboarding-screen"
>
<div className="text-sm font-medium text-amber-900">Claude Code not detected</div>
<p className="mt-1 text-xs leading-5 text-amber-800">
<div className="text-sm font-medium text-[var(--feedback-warning-text)]">Claude Code not detected</div>
<p className="mt-1 text-xs leading-5 text-[var(--feedback-warning-text)]">
Install Claude Code or continue without it.
</p>
</div>

View File

@@ -89,7 +89,7 @@ export function AiPanelView({
style={{
outline: 'none',
borderLeft: isActive
? '2px solid var(--accent-blue, #3b82f6)'
? '2px solid var(--accent-blue)'
: '1px solid var(--border)',
animation: isActive ? 'ai-border-pulse 2s ease-in-out infinite' : undefined,
transition: 'border-color 0.3s ease',

View File

@@ -223,7 +223,7 @@ export function AiPanelComposer({
const placeholder = getComposerPlaceholder(agentLabel, agentReady, legacyCopy, hasContext)
const sendButtonStyle = {
background: canSend ? 'var(--primary)' : 'var(--muted)',
color: canSend ? 'white' : 'var(--muted-foreground)',
color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)',
borderRadius: 8,
width: 32,
height: 34,

View File

@@ -1,6 +1,7 @@
import { render, screen, fireEvent, act } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { BreadcrumbBar } from './BreadcrumbBar'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import type { VaultEntry } from '../types'
const dragRegionMouseDown = vi.fn()
@@ -137,7 +138,11 @@ describe('BreadcrumbBar — archive/unarchive', () => {
describe('BreadcrumbBar — organized shortcut hint', () => {
it('shows Cmd+E on the organized toggle tooltip', async () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleOrganized={vi.fn()} />)
await expectTooltip(screen.getByRole('button', { name: 'Set note as organized' }), 'Set note as organized', '⌘E')
await expectTooltip(
screen.getByRole('button', { name: 'Set note as organized' }),
'Set note as organized',
formatShortcutDisplay({ display: '⌘E' }),
)
})
it('hides the organized toggle when the workflow is disabled', () => {

View File

@@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
import type { VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ActionTooltip, type ActionTooltipCopy } from '@/components/ui/action-tooltip'
@@ -130,35 +131,68 @@ function IconActionButton({
)
}
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
const copy: ActionTooltipCopy = {
label: rawMode ? 'Return to the editor' : 'Open the raw editor',
shortcut: '⌘\\',
}
interface ToggleIconActionProps {
active: boolean
activeClassName: string
activeLabel: string
children: ReactNode
inactiveClassName?: string
inactiveLabel: string
onClick?: () => void
shortcut: string
}
function ToggleIconAction({
active,
activeClassName,
activeLabel,
children,
inactiveClassName = 'hover:text-foreground',
inactiveLabel,
onClick,
shortcut,
}: ToggleIconActionProps) {
return (
<IconActionButton
copy={copy}
onClick={onToggleRaw}
className={cn(rawMode ? 'text-foreground' : 'hover:text-foreground')}
copy={{
label: active ? activeLabel : inactiveLabel,
shortcut,
}}
onClick={onClick}
className={cn(active ? activeClassName : inactiveClassName)}
>
<Code size={16} className={BREADCRUMB_ICON_CLASS} />
{children}
</IconActionButton>
)
}
function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) {
const copy: ActionTooltipCopy = {
label: favorite ? 'Remove from favorites' : 'Add to favorites',
shortcut: '⌘D',
}
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
return (
<IconActionButton
copy={copy}
<ToggleIconAction
active={!!rawMode}
activeClassName="text-foreground"
activeLabel="Return to the editor"
inactiveLabel="Open the raw editor"
onClick={onToggleRaw}
shortcut={formatShortcutDisplay({ display: '⌘\\' })}
>
<Code size={16} className={BREADCRUMB_ICON_CLASS} />
</ToggleIconAction>
)
}
function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) {
return (
<ToggleIconAction
active={favorite}
activeClassName="text-[var(--accent-yellow)]"
activeLabel="Remove from favorites"
inactiveLabel="Add to favorites"
onClick={onToggleFavorite}
className={cn(favorite ? 'text-yellow-500' : 'hover:text-foreground')}
shortcut={formatShortcutDisplay({ display: '⌘D' })}
>
<Star size={16} weight={favorite ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
</ToggleIconAction>
)
}
@@ -170,18 +204,17 @@ function OrganizedAction({
onToggleOrganized?: () => void
}) {
if (!onToggleOrganized) return null
const copy: ActionTooltipCopy = {
label: organized ? 'Set note as not organized' : 'Set note as organized',
shortcut: '⌘E',
}
return (
<IconActionButton
copy={copy}
<ToggleIconAction
active={organized}
activeClassName="text-[var(--accent-green)]"
activeLabel="Set note as not organized"
inactiveLabel="Set note as organized"
onClick={onToggleOrganized}
className={cn(organized ? 'text-green-600' : 'hover:text-foreground')}
shortcut={formatShortcutDisplay({ display: '⌘E' })}
>
<CheckCircle size={16} weight={organized ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
</ToggleIconAction>
)
}
@@ -214,18 +247,17 @@ function DiffAction({
}
function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'onToggleAIChat'>) {
const copy: ActionTooltipCopy = {
label: showAIChat ? 'Close the AI panel' : 'Open the AI panel',
shortcut: '⇧⌘L',
}
return (
<IconActionButton
copy={copy}
<ToggleIconAction
active={!!showAIChat}
activeClassName="text-primary"
activeLabel="Close the AI panel"
inactiveLabel="Open the AI panel"
onClick={onToggleAIChat}
className={cn(showAIChat ? 'text-primary' : 'hover:text-foreground')}
shortcut={formatShortcutDisplay({ display: '⌘⇧L' })}
>
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
</ToggleIconAction>
)
}
@@ -251,7 +283,14 @@ function ArchiveAction({
function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
return (
<IconActionButton copy={{ label: 'Delete this note', shortcut: '⌘⌫' }} onClick={onDelete} className="hover:text-destructive">
<IconActionButton
copy={{
label: 'Delete this note',
shortcut: formatShortcutDisplay({ display: '⌘⌫ / ⌘⌦' }),
}}
onClick={onDelete}
className="hover:text-destructive"
>
<Trash size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)
@@ -263,7 +302,15 @@ function InspectorAction({
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'onToggleInspector'>) {
if (!inspectorCollapsed) return null
return (
<IconActionButton copy={{ label: 'Open the properties panel', shortcut: '⌘⇧I' }} onClick={onToggleInspector} className="hover:text-foreground" tooltipAlign="end">
<IconActionButton
copy={{
label: 'Open the properties panel',
shortcut: formatShortcutDisplay({ display: '⌘⇧I' }),
}}
onClick={onToggleInspector}
className="hover:text-foreground"
tooltipAlign="end"
>
<SlidersHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)

View File

@@ -28,8 +28,8 @@ function BulkActionButton({ ariaLabel, children, destructive = false, onClick, t
variant={destructive ? 'destructive' : 'ghost'}
className={
destructive
? 'h-8 w-8 rounded-lg bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/30'
: 'h-8 w-8 rounded-lg bg-white/10 text-background hover:bg-white/20 focus-visible:ring-white/35 disabled:bg-white/5 disabled:text-white/35'
? 'h-8 w-8 rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/30'
: 'h-8 w-8 rounded-lg bg-background/10 text-background hover:bg-background/20 focus-visible:ring-background/35 disabled:bg-background/5 disabled:text-background/35'
}
onClick={onClick}
disabled={!onClick}
@@ -89,7 +89,7 @@ function BulkActionBarInner({ count, isArchivedView, onOrganize, onArchive, onDe
type="button"
size="icon-sm"
variant="ghost"
className="h-8 w-8 rounded-lg text-white/55 hover:bg-white/10 hover:text-background focus-visible:ring-white/30"
className="h-8 w-8 rounded-lg text-background/55 hover:bg-background/10 hover:text-background focus-visible:ring-background/30"
onClick={onClear}
aria-label="Clear selection"
title="Clear selection"

View File

@@ -14,7 +14,7 @@ interface ClaudeCodeOnboardingPromptProps {
function getPromptCopy(status: ClaudeCodeStatus) {
if (status === 'installed') {
return {
accentClassName: 'bg-emerald-100 text-emerald-700',
accentClassName: 'bg-[var(--feedback-success-bg)] text-[var(--feedback-success-text)]',
description: "Tolaria's AI features are ready to use.",
icon: <CheckCircle2 className="size-7" />,
title: 'Claude Code detected',
@@ -23,7 +23,7 @@ function getPromptCopy(status: ClaudeCodeStatus) {
if (status === 'missing') {
return {
accentClassName: 'bg-amber-100 text-amber-700',
accentClassName: 'bg-[var(--feedback-warning-bg)] text-[var(--feedback-warning-text)]',
description: 'Tolaria works best with an AI coding agent installed.',
icon: <Bot className="size-7" />,
title: 'Claude Code not detected',
@@ -31,7 +31,7 @@ function getPromptCopy(status: ClaudeCodeStatus) {
}
return {
accentClassName: 'bg-slate-100 text-slate-600',
accentClassName: 'bg-muted text-muted-foreground',
description: 'Checking whether Claude Code is available on this machine.',
icon: <Loader2 className="size-7 animate-spin" />,
title: 'Checking for Claude Code',

View File

@@ -6,6 +6,7 @@ import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
import type { NoteReference } from '../utils/ai-context'
import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry'
import { groupSortKey } from '../hooks/useCommandRegistry'
import { rememberFeedbackDialogOpener } from '../lib/feedbackDialogOpener'
import { CommandPaletteAiMode } from './CommandPaletteAiMode'
interface CommandPaletteProps {
@@ -105,6 +106,14 @@ function usePaletteResults(commands: CommandAction[], query: string) {
}
}
function rememberCommandOpener(
command: CommandAction,
target: HTMLInputElement | HTMLDivElement | null,
) {
if (command.id !== 'open-contribute') return
rememberFeedbackDialogOpener(target instanceof HTMLElement ? target : null)
}
function CommandPaletteInput({
inputRef,
query,
@@ -274,6 +283,7 @@ function OpenCommandPalette({
event.preventDefault()
const command = flatList[selectedIndex]
if (!command) return
rememberCommandOpener(command, inputRef.current)
onClose()
command.execute()
}
@@ -306,6 +316,7 @@ function OpenCommandPalette({
}
const handleSelectCommand = (command: CommandAction) => {
rememberCommandOpener(command, inputRef.current)
onClose()
command.execute()
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { CommitDialog } from './CommitDialog'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
describe('CommitDialog', () => {
const onCommit = vi.fn()
@@ -105,11 +106,12 @@ describe('CommitDialog', () => {
})
it('switches to local-only copy when commitMode is local', () => {
const submitShortcut = formatShortcutDisplay({ display: '⌘↵' })
render(<CommitDialog open={true} modifiedCount={2} commitMode="local" onCommit={onCommit} onClose={onClose} />)
expect(screen.getByRole('heading', { name: 'Commit' })).toBeInTheDocument()
expect(screen.getByText('This vault has no git remote configured. Tolaria will create a local commit only.')).toBeInTheDocument()
expect(screen.getByText('Cmd+Enter to commit locally')).toBeInTheDocument()
expect(screen.getByText(`${submitShortcut} to commit locally`)).toBeInTheDocument()
expect(getActionButton('Commit')).toBeDisabled()
})
})

View File

@@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import type { CommitMode } from '../hooks/useCommitFlow'
type CommitDialogCopy = {
@@ -13,12 +14,14 @@ type CommitDialogCopy = {
}
function getDialogCopy(commitMode: CommitMode): CommitDialogCopy {
const submitShortcut = formatShortcutDisplay({ display: '⌘↵' })
if (commitMode === 'local') {
return {
title: 'Commit',
description: 'This vault has no git remote configured. Tolaria will create a local commit only.',
actionLabel: 'Commit',
shortcutHint: 'Cmd+Enter to commit locally',
shortcutHint: `${submitShortcut} to commit locally`,
}
}
@@ -26,7 +29,7 @@ function getDialogCopy(commitMode: CommitMode): CommitDialogCopy {
title: 'Commit & Push',
description: 'Review changed files and enter a commit message before committing and pushing.',
actionLabel: 'Commit & Push',
shortcutHint: 'Cmd+Enter to commit',
shortcutHint: `${submitShortcut} to commit`,
}
}

View File

@@ -1,8 +1,44 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AlertTriangle, FileText, Check, Loader2 } from 'lucide-react'
import type { ConflictFileState } from '../hooks/useConflictResolver'
import { cn } from '@/lib/utils'
type ConflictResolutionStrategy = 'ours' | 'theirs'
const BINARY_FILE_EXTENSIONS = [
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.ico',
'.pdf',
'.zip',
'.tar',
'.gz',
'.mp3',
'.mp4',
'.wav',
'.ogg',
'.woff',
'.woff2',
'.ttf',
'.otf',
'.eot',
]
const RESOLUTION_LABELS: Record<NonNullable<ConflictFileState['resolution']>, string> = {
manual: 'Edited manually',
ours: 'Keeping mine',
theirs: 'Keeping theirs',
}
const RESOLUTION_SHORTCUTS: Record<string, ConflictResolutionStrategy | undefined> = {
k: 'ours',
t: 'theirs',
}
interface ConflictResolverModalProps {
open: boolean
@@ -17,8 +53,8 @@ interface ConflictResolverModalProps {
}
function isBinaryFile(file: string): boolean {
const binaryExts = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf', '.zip', '.tar', '.gz', '.mp3', '.mp4', '.wav', '.ogg', '.woff', '.woff2', '.ttf', '.otf', '.eot']
return binaryExts.some(ext => file.toLowerCase().endsWith(ext))
const normalizedFile = file.toLowerCase()
return BINARY_FILE_EXTENSIONS.some(ext => normalizedFile.endsWith(ext))
}
function fileName(path: string): string {
@@ -27,10 +63,9 @@ function fileName(path: string): string {
function ResolutionLabel({ resolution }: { resolution: ConflictFileState['resolution'] }) {
if (!resolution) return null
const labels = { ours: 'Keeping mine', theirs: 'Keeping theirs', manual: 'Edited manually' }
return (
<span className="flex items-center gap-1 text-xs text-green-600">
<Check size={12} />{labels[resolution]}
<span className="flex items-center gap-1 text-xs text-[var(--feedback-success-text)]">
<Check size={12} />{RESOLUTION_LABELS[resolution]}
</span>
)
}
@@ -62,9 +97,11 @@ function ConflictFileRow({
role="row"
tabIndex={0}
onFocus={onFocus}
className={`flex items-center justify-between gap-2 rounded-md border px-3 py-2 transition-colors ${
focused ? 'border-ring bg-accent/50' : 'border-border bg-background'
} ${resolved ? 'opacity-70' : ''}`}
className={cn(
'flex items-center justify-between gap-2 rounded-md border px-3 py-2 transition-colors',
focused ? 'border-ring bg-accent/50' : 'border-border bg-background',
resolved && 'opacity-70',
)}
data-testid={`conflict-file-${state.file}`}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
@@ -118,8 +155,214 @@ function ConflictFileRow({
)
}
function clampFocusIndex(index: number, fileCount: number): number {
if (fileCount === 0) return 0
return Math.min(Math.max(index, 0), fileCount - 1)
}
function useConflictFocus(fileCount: number) {
const [focusIdx, setFocusIdx] = useState(0)
const focusIdxRef = useRef(0)
const visibleFocusIdx = clampFocusIndex(focusIdx, fileCount)
const syncFocusIdx = useCallback((nextIndex: number) => {
const clampedIndex = clampFocusIndex(nextIndex, fileCount)
setFocusIdx(clampedIndex)
focusIdxRef.current = clampedIndex
}, [fileCount])
const moveFocus = useCallback((offset: number) => {
const currentIndex = clampFocusIndex(focusIdxRef.current, fileCount)
syncFocusIdx(currentIndex + offset)
}, [fileCount, syncFocusIdx])
return {
focusIdx: visibleFocusIdx,
focusIdxRef,
moveFocus,
syncFocusIdx,
}
}
function hasCommandModifier(event: KeyboardEvent): boolean {
return event.metaKey || event.ctrlKey
}
function isNextRowKey(event: KeyboardEvent): boolean {
if (event.key === 'ArrowDown') return true
return event.key === 'Tab' && !event.shiftKey
}
function isPreviousRowKey(event: KeyboardEvent): boolean {
if (event.key === 'ArrowUp') return true
return event.key === 'Tab' && event.shiftKey
}
function handleNavigationKey(event: KeyboardEvent, moveFocus: (offset: number) => void): boolean {
if (isNextRowKey(event)) {
event.preventDefault()
moveFocus(1)
return true
}
if (isPreviousRowKey(event)) {
event.preventDefault()
moveFocus(-1)
return true
}
return false
}
function handleResolutionShortcut(
event: KeyboardEvent,
file: ConflictFileState | undefined,
onResolveFile: ConflictResolverModalProps['onResolveFile'],
): boolean {
const strategy = RESOLUTION_SHORTCUTS[event.key.toLowerCase()]
if (!strategy || !file || file.resolving || hasCommandModifier(event)) return false
event.preventDefault()
onResolveFile(file.file, strategy)
return true
}
function handleOpenShortcut(
event: KeyboardEvent,
file: ConflictFileState | undefined,
onOpenInEditor: ConflictResolverModalProps['onOpenInEditor'],
): boolean {
if (event.key.toLowerCase() !== 'o' || !file || file.resolving || hasCommandModifier(event)) return false
if (isBinaryFile(file.file)) return false
event.preventDefault()
onOpenInEditor(file.file)
return true
}
function handleCommitShortcut({
allResolved,
committing,
event,
onCommit,
}: {
allResolved: boolean
committing: boolean
event: KeyboardEvent
onCommit: ConflictResolverModalProps['onCommit']
}): boolean {
if (event.key !== 'Enter' || !allResolved || committing) return false
event.preventDefault()
onCommit()
return true
}
function ConflictDialogHeader({ fileCount }: { fileCount: number }) {
return (
<DialogHeader>
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-[var(--accent-orange)]" />
<DialogTitle>Resolve Merge Conflicts</DialogTitle>
</div>
<DialogDescription>
{fileCount} file{fileCount !== 1 ? 's have' : ' has'} merge conflicts. Choose how to resolve each file.
</DialogDescription>
</DialogHeader>
)
}
function ConflictFileList({
fileStates,
focusIdx,
onFocusRow,
onOpenInEditor,
onResolveFile,
}: {
fileStates: ConflictFileState[]
focusIdx: number
onFocusRow: (index: number) => void
onOpenInEditor: (file: string) => void
onResolveFile: (file: string, strategy: ConflictResolutionStrategy) => void
}) {
return (
<div
className="flex flex-col gap-2 max-h-[300px] overflow-y-auto"
role="grid"
data-testid="conflict-file-list"
>
{fileStates.map((state, index) => (
<ConflictFileRow
key={state.file}
state={state}
focused={index === focusIdx}
onResolve={(strategy) => onResolveFile(state.file, strategy)}
onOpenInEditor={() => onOpenInEditor(state.file)}
onFocus={() => onFocusRow(index)}
/>
))}
</div>
)
}
function CommitButtonContent({ committing }: { committing: boolean }) {
if (!committing) return 'Commit & continue'
return (
<>
<Loader2 size={14} className="animate-spin mr-1" />Committing
</>
)
}
function ConflictDialogFooter({
allResolved,
committing,
onClose,
onCommit,
}: {
allResolved: boolean
committing: boolean
onClose: () => void
onCommit: () => void
}) {
return (
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
<span className="text-[11px] text-muted-foreground">
K = keep mine · T = keep theirs · O = open · Enter = commit
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
onClick={onCommit}
disabled={!allResolved || committing}
data-testid="conflict-commit-btn"
>
<CommitButtonContent committing={committing} />
</Button>
</div>
</DialogFooter>
)
}
export function ConflictResolverModal({
open,
onClose,
...contentProps
}: ConflictResolverModalProps) {
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
{open ? (
<ConflictResolverDialogContent
open={open}
onClose={onClose}
{...contentProps}
/>
) : null}
</Dialog>
)
}
function ConflictResolverDialogContent({
fileStates,
allResolved,
committing,
@@ -129,118 +372,54 @@ export function ConflictResolverModal({
onCommit,
onClose,
}: ConflictResolverModalProps) {
const [focusIdx, setFocusIdx] = useState(0)
const focusIdxRef = useRef(0)
const listRef = useRef<HTMLDivElement>(null)
const {
focusIdx,
focusIdxRef,
moveFocus,
syncFocusIdx,
} = useConflictFocus(fileStates.length)
useEffect(() => {
if (open) {
setFocusIdx(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
focusIdxRef.current = 0
}
}, [open])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
return
}
const idx = focusIdxRef.current
const file = fileStates[idx]
if (handleNavigationKey(e, moveFocus)) return
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault()
const next = Math.min(idx + 1, fileStates.length - 1)
setFocusIdx(next)
focusIdxRef.current = next
return
}
if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
e.preventDefault()
const prev = Math.max(idx - 1, 0)
setFocusIdx(prev)
focusIdxRef.current = prev
return
}
if ((e.key === 'k' || e.key === 'K') && file && !file.resolving && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
onResolveFile(file.file, 'ours')
} else if ((e.key === 't' || e.key === 'T') && file && !file.resolving && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
onResolveFile(file.file, 'theirs')
} else if ((e.key === 'o' || e.key === 'O') && file && !isBinaryFile(file.file) && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
onOpenInEditor(file.file)
} else if (e.key === 'Enter' && allResolved && !committing) {
e.preventDefault()
onCommit()
}
}, [fileStates, allResolved, committing, onResolveFile, onOpenInEditor, onCommit, onClose])
const focusedIndex = clampFocusIndex(focusIdxRef.current, fileStates.length)
const file = fileStates[focusedIndex]
if (handleResolutionShortcut(e, file, onResolveFile)) return
if (handleOpenShortcut(e, file, onOpenInEditor)) return
handleCommitShortcut({ allResolved, committing, event: e, onCommit })
}, [allResolved, committing, fileStates, focusIdxRef, moveFocus, onClose, onCommit, onOpenInEditor, onResolveFile])
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent
showCloseButton={false}
className="sm:max-w-[520px]"
onKeyDown={handleKeyDown}
>
<DialogHeader>
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-orange-500" />
<DialogTitle>Resolve Merge Conflicts</DialogTitle>
</div>
<DialogDescription>
{fileStates.length} file{fileStates.length !== 1 ? 's have' : ' has'} merge conflicts. Choose how to resolve each file.
</DialogDescription>
</DialogHeader>
<DialogContent
showCloseButton={false}
className="sm:max-w-[520px]"
onKeyDown={handleKeyDown}
>
<ConflictDialogHeader fileCount={fileStates.length} />
<div
ref={listRef}
className="flex flex-col gap-2 max-h-[300px] overflow-y-auto"
role="grid"
data-testid="conflict-file-list"
>
{fileStates.map((state, i) => (
<ConflictFileRow
key={state.file}
state={state}
focused={i === focusIdx}
onResolve={(strategy) => onResolveFile(state.file, strategy)}
onOpenInEditor={() => onOpenInEditor(state.file)}
onFocus={() => {
setFocusIdx(i)
focusIdxRef.current = i
}}
/>
))}
</div>
<ConflictFileList
fileStates={fileStates}
focusIdx={focusIdx}
onFocusRow={syncFocusIdx}
onOpenInEditor={onOpenInEditor}
onResolveFile={onResolveFile}
/>
{error && (
<p className="text-xs text-destructive" data-testid="conflict-error">{error}</p>
)}
{error && (
<p className="text-xs text-destructive" data-testid="conflict-error">{error}</p>
)}
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
<span className="text-[11px] text-muted-foreground">
K = keep mine · T = keep theirs · O = open · Enter = commit
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
onClick={onCommit}
disabled={!allResolved || committing}
data-testid="conflict-commit-btn"
>
{committing ? (
<><Loader2 size={14} className="animate-spin mr-1" />Committing</>
) : (
'Commit & continue'
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<ConflictDialogFooter
allResolved={allResolved}
committing={committing}
onClose={onClose}
onCommit={onCommit}
/>
</DialogContent>
)
}

View File

@@ -97,7 +97,7 @@ export function CreateNoteDialog({ open, onClose, onCreate, defaultType, customT
className={cn(
"rounded-full text-xs",
type === t
? "bg-[var(--accent-blue)] text-white"
? "bg-[var(--accent-blue)] text-primary-foreground"
: "border-[var(--accent-blue)] text-[var(--accent-blue)]"
)}
onClick={() => setType(t)}

View File

@@ -19,16 +19,18 @@ describe('DiffView', () => {
it('applies green styling to added lines', () => {
const diff = '+added line'
const { container } = render(<DiffView diff={diff} />)
const addedLine = container.querySelector('.text-\\[\\#4caf50\\]')
render(<DiffView diff={diff} />)
const addedLine = screen.getByText('+added line').closest('div')
expect(addedLine).toBeInTheDocument()
expect(addedLine).toHaveClass('text-[var(--diff-added-text)]')
})
it('applies red styling to removed lines', () => {
const diff = '-removed line'
const { container } = render(<DiffView diff={diff} />)
const removedLine = container.querySelector('.text-\\[\\#f44336\\]')
render(<DiffView diff={diff} />)
const removedLine = screen.getByText('-removed line').closest('div')
expect(removedLine).toBeInTheDocument()
expect(removedLine).toHaveClass('text-[var(--diff-removed-text)]')
})
it('applies hunk header styling to @@ lines', () => {

View File

@@ -7,9 +7,9 @@ interface DiffViewProps {
const DIFF_HEADER_PREFIXES = ['diff', 'index', '---', '+++', 'new file']
function classifyDiffLine(line: string): string {
if (line.startsWith('+') && !line.startsWith('+++')) return 'bg-[rgba(76,175,80,0.12)] text-[#4caf50]'
if (line.startsWith('-') && !line.startsWith('---')) return 'bg-[rgba(244,67,54,0.12)] text-[#f44336]'
if (line.startsWith('@@')) return 'bg-[rgba(33,150,243,0.08)] text-primary italic'
if (line.startsWith('+') && !line.startsWith('+++')) return 'bg-[var(--diff-added-bg)] text-[var(--diff-added-text)]'
if (line.startsWith('-') && !line.startsWith('---')) return 'bg-[var(--diff-removed-bg)] text-[var(--diff-removed-text)]'
if (line.startsWith('@@')) return 'bg-[var(--diff-hunk-bg)] text-primary italic'
if (DIFF_HEADER_PREFIXES.some((p) => line.startsWith(p))) return 'bg-muted text-muted-foreground font-semibold'
return 'text-secondary-foreground'
}

View File

@@ -68,7 +68,7 @@
/* Drag-over state: subtle border highlight */
.editor__blocknote-container--drag-over {
outline: 2px dashed var(--primary, #155DFF);
outline: 2px dashed var(--border-focus);
outline-offset: -2px;
}
@@ -80,18 +80,18 @@
display: flex;
align-items: center;
justify-content: center;
background: rgba(21, 93, 255, 0.06);
background: var(--state-drag-target);
pointer-events: none;
}
.editor__drop-overlay-label {
padding: 10px 20px;
border-radius: 8px;
background: var(--background, #fff);
color: var(--primary, #155DFF);
background: var(--surface-popover);
color: var(--primary);
font-size: 14px;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 8px var(--shadow-dialog);
}
.editor__blocknote-container .bn-container {
@@ -246,7 +246,7 @@
display: flex;
align-items: center;
gap: 4px;
color: var(--text-faint, #999);
color: var(--text-faint);
font-size: 13px;
opacity: 0;
transition: opacity 0.15s;
@@ -274,9 +274,9 @@
flex-direction: column;
min-width: 140px;
border-radius: 8px;
border: 1px solid var(--border-dialog, var(--border));
border: 1px solid var(--border-dialog);
background: var(--popover);
box-shadow: 0 4px 16px var(--shadow-dialog, rgba(0,0,0,0.1));
box-shadow: 0 4px 16px var(--shadow-dialog);
padding: 4px;
}
@@ -297,7 +297,7 @@
}
.note-icon-menu__item--danger {
color: var(--destructive, #ef4444);
color: var(--destructive);
}
/* =============================================

View File

@@ -1,6 +1,7 @@
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
import type { ComponentProps, PropsWithChildren, ReactElement } from 'react'
import { describe, it, expect, vi } from 'vitest'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -61,6 +62,7 @@ vi.mock('@blocknote/react', () => ({
createReactInlineContentSpec: () => ({ render: () => null }),
useCreateBlockNote: () => mockEditor,
FormattingToolbar: ({ children }: PropsWithChildren) => <>{children}</>,
LinkToolbar: ({ children }: PropsWithChildren) => <>{children}</>,
getFormattingToolbarItems: () => [],
getDefaultReactSlashMenuItems: () => [],
ComponentsContext: {
@@ -72,6 +74,9 @@ vi.mock('@blocknote/react', () => ({
</div>
),
FormattingToolbarController: () => null,
LinkToolbarController: () => null,
EditLinkButton: () => null,
DeleteLinkButton: () => null,
SideMenuController: () => null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
SuggestionMenuController: (props: any) => {
@@ -79,6 +84,25 @@ vi.mock('@blocknote/react', () => ({
if (props.triggerCharacter === '[[') capturedGetItems = props.getItems
return null
},
useComponentsContext: () => ({
LinkToolbar: {
Button: ({
children,
label,
onClick,
}: PropsWithChildren<{ label?: string; onClick?: () => void }>) => (
<button onClick={onClick} type="button">
{label}
{children}
</button>
),
},
}),
useDictionary: () => ({
link_toolbar: {
open: { tooltip: 'Open in a new tab' },
},
}),
}))
vi.mock('@blocknote/mantine', () => ({
@@ -181,9 +205,14 @@ function renderEditor(overrides: Partial<EditorComponentProps> = {}) {
describe('Editor', () => {
it('shows empty state when no tabs are open', () => {
renderEditor()
const quickOpenHint = formatShortcutDisplay({ display: '⌘P / ⌘O' })
const newNoteHint = formatShortcutDisplay({ display: '⌘N' })
const { container } = renderEditor()
expect(screen.getByText('Select a note to start editing')).toBeInTheDocument()
expect(screen.getByText(/Cmd\+P or Cmd\+O to search/)).toBeInTheDocument()
const shortcutHint = Array.from(container.querySelectorAll('span.text-xs.text-muted-foreground'))
.find((element) => element.textContent === `${quickOpenHint} to search · ${newNoteHint} to create`)
expect(shortcutHint).toBeInTheDocument()
})
it('renders an invisible drag region in the empty state', () => {

View File

@@ -11,6 +11,7 @@ import { ResizeHandle } from './ResizeHandle'
import { useDiffMode, type CommitDiffRequest } from '../hooks/useDiffMode'
import { useEditorFocus } from '../hooks/useEditorFocus'
import { useDragRegion } from '../hooks/useDragRegion'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import { EditorRightPanel } from './EditorRightPanel'
import { EditorContent } from './EditorContent'
import { schema } from './editorSchema'
@@ -127,6 +128,8 @@ function useEditorModeExclusion({
function EditorEmptyState() {
const breadcrumbBarHeight = 52
const { onMouseDown } = useDragRegion()
const quickOpenShortcut = formatShortcutDisplay({ display: '⌘P / ⌘O' })
const newNoteShortcut = formatShortcutDisplay({ display: '⌘N' })
return (
<div className="flex flex-1 flex-col overflow-hidden">
@@ -140,7 +143,7 @@ function EditorEmptyState() {
/>
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<p className="m-0 text-[15px]">Select a note to start editing</p>
<span className="text-xs text-muted-foreground">Cmd+P or Cmd+O to search &middot; Cmd+N to create</span>
<span className="text-xs text-muted-foreground">{quickOpenShortcut} to search &middot; {newNoteShortcut} to create</span>
</div>
</div>
)

View File

@@ -67,7 +67,7 @@
margin-top: var(--headings-h1-margin-top) !important;
margin-bottom: var(--headings-h1-margin-bottom) !important;
padding-bottom: 16px !important;
border-bottom: 1px solid var(--border-primary, rgba(0,0,0,0.1));
border-bottom: 1px solid var(--border-primary);
}
.editor__blocknote-container [data-content-type="heading"]:not([data-level]) .bn-inline-content,
.editor__blocknote-container [data-content-type="heading"][data-level="1"] .bn-inline-content {

View File

@@ -1,7 +1,15 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FeedbackDialog } from './FeedbackDialog'
import { TOLARIA_GITHUB_ISSUES_URL } from '../constants/feedback'
import {
TOLARIA_GITHUB_CONTRIBUTING_URL,
TOLARIA_GITHUB_DISCUSSIONS_URL,
TOLARIA_GITHUB_ISSUES_URL,
TOLARIA_GITHUB_PULL_REQUESTS_URL,
TOLARIA_PRODUCT_BOARD_URL,
} from '../constants/feedback'
import { APP_COMMAND_EVENT_NAME, APP_COMMAND_IDS } from '../hooks/appCommandDispatcher'
import { rememberFeedbackDialogOpener } from '../lib/feedbackDialogOpener'
vi.mock('../utils/url', () => ({
openExternalUrl: vi.fn().mockResolvedValue(undefined),
@@ -12,42 +20,126 @@ const { openExternalUrl } = await import('../utils/url') as typeof import('../ut
}
describe('FeedbackDialog', () => {
it('renders the instructional copy when open', () => {
render(<FeedbackDialog open={true} onClose={vi.fn()} />)
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window.navigator, 'clipboard', {
configurable: true,
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
})
})
it('renders the contribution paths when open', () => {
render(<FeedbackDialog open={true} onClose={vi.fn()} buildNumber="b281" releaseChannel="alpha" />)
expect(screen.getByTestId('feedback-dialog')).toBeInTheDocument()
expect(screen.getByText('Share feedback')).toBeInTheDocument()
expect(screen.getByText(/best way to share product feedback/i)).toBeInTheDocument()
expect(screen.getByText(/check whether a similar one already exists/i)).toBeInTheDocument()
expect(screen.getByText('Contribute to Tolaria')).toBeInTheDocument()
expect(screen.getByText('Pick the path that fits what you want to do! Any type of help is appreciated')).toBeInTheDocument()
expect(screen.getByText('Feature requests')).toBeInTheDocument()
expect(screen.getByText('Discussions')).toBeInTheDocument()
expect(screen.getByText('Contribute code')).toBeInTheDocument()
expect(screen.getByText('Report a bug')).toBeInTheDocument()
expect(screen.getByText(/Search on the product board first/i)).toBeInTheDocument()
expect(screen.getByText(/Use GitHub Discussions for/i)).toBeInTheDocument()
expect(screen.getByText(/Attach the sanitized diagnostic bundle please!/i)).toBeInTheDocument()
expect(screen.queryByText(/Sanitized and optional/i)).not.toBeInTheDocument()
})
it('focuses the primary CTA when opened', async () => {
render(<FeedbackDialog open={true} onClose={vi.fn()} />)
const cta = screen.getByRole('button', { name: 'Go to Issues' })
render(<FeedbackDialog open={true} onClose={vi.fn()} buildNumber="b281" releaseChannel={null} />)
const cta = screen.getByRole('button', { name: 'Open Product Board' })
await waitFor(() => expect(cta).toHaveFocus())
})
it('opens GitHub Issues without closing the modal', async () => {
it('opens the expected contribution links without closing the modal', async () => {
const onClose = vi.fn()
render(<FeedbackDialog open={true} onClose={onClose} />)
render(<FeedbackDialog open={true} onClose={onClose} buildNumber="b281" releaseChannel={null} />)
fireEvent.click(screen.getByRole('button', { name: 'Go to Issues' }))
fireEvent.click(screen.getByRole('button', { name: 'Open Product Board' }))
fireEvent.click(screen.getByRole('button', { name: 'Open Discussions' }))
fireEvent.click(screen.getByRole('button', { name: 'Open Pull Requests' }))
fireEvent.click(screen.getByRole('button', { name: 'Open Contributing Guide' }))
fireEvent.click(screen.getByRole('button', { name: 'Open GitHub Issues' }))
await waitFor(() => expect(openExternalUrl).toHaveBeenCalledWith(TOLARIA_GITHUB_ISSUES_URL))
await waitFor(() => expect(openExternalUrl).toHaveBeenNthCalledWith(1, TOLARIA_PRODUCT_BOARD_URL))
expect(openExternalUrl).toHaveBeenNthCalledWith(2, TOLARIA_GITHUB_DISCUSSIONS_URL)
expect(openExternalUrl).toHaveBeenNthCalledWith(3, TOLARIA_GITHUB_PULL_REQUESTS_URL)
expect(openExternalUrl).toHaveBeenNthCalledWith(4, TOLARIA_GITHUB_CONTRIBUTING_URL)
expect(openExternalUrl).toHaveBeenNthCalledWith(5, TOLARIA_GITHUB_ISSUES_URL)
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByTestId('feedback-dialog')).toBeInTheDocument()
})
it('copies a sanitized diagnostic bundle', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(window.navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
render(<FeedbackDialog open={true} onClose={vi.fn()} buildNumber="b281" releaseChannel="alpha" />)
fireEvent.click(screen.getByRole('button', { name: 'Copy sanitized diagnostics' }))
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1))
expect(writeText.mock.calls[0]?.[0]).toContain('Tolaria sanitized diagnostics')
expect(writeText.mock.calls[0]?.[0]).toContain('Build: b281')
expect(writeText.mock.calls[0]?.[0]).toContain('Release channel: alpha')
expect(screen.getByText('Diagnostics copied.')).toBeInTheDocument()
})
it('shows a fallback message when a contribution link cannot be opened', async () => {
openExternalUrl.mockRejectedValueOnce(new Error('blocked'))
render(<FeedbackDialog open={true} onClose={vi.fn()} buildNumber="b281" releaseChannel={null} />)
fireEvent.click(screen.getByRole('button', { name: 'Open Product Board' }))
expect(await screen.findByText(/couldnt open Product Board automatically/i)).toBeInTheDocument()
expect(screen.getByText(TOLARIA_PRODUCT_BOARD_URL)).toBeInTheDocument()
})
it('closes when pressing Escape', () => {
const onClose = vi.fn()
render(<FeedbackDialog open={true} onClose={onClose} />)
render(<FeedbackDialog open={true} onClose={onClose} buildNumber="b281" releaseChannel={null} />)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('closes when clicking Close', () => {
it('closes when clicking the top-right Close control', () => {
const onClose = vi.fn()
render(<FeedbackDialog open={true} onClose={onClose} />)
render(<FeedbackDialog open={true} onClose={onClose} buildNumber="b281" releaseChannel={null} />)
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('reopens the command palette after closing when launched from it', () => {
vi.useFakeTimers()
const opener = document.createElement('input')
opener.setAttribute('placeholder', 'Type a command...')
document.body.appendChild(opener)
rememberFeedbackDialogOpener(opener)
const onClose = vi.fn()
const handleReopen = vi.fn()
window.addEventListener(APP_COMMAND_EVENT_NAME, handleReopen)
const { rerender } = render(
<FeedbackDialog open={false} onClose={onClose} buildNumber="b281" releaseChannel={null} />,
)
rerender(<FeedbackDialog open={true} onClose={onClose} buildNumber="b281" releaseChannel={null} />)
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
vi.advanceTimersByTime(100)
expect(onClose).toHaveBeenCalledOnce()
expect(handleReopen).toHaveBeenCalledTimes(1)
expect(handleReopen.mock.calls[0]?.[0]).toMatchObject({
detail: APP_COMMAND_IDS.viewCommandPalette,
})
window.removeEventListener(APP_COMMAND_EVENT_NAME, handleReopen)
opener.remove()
vi.useRealTimers()
})
})

View File

@@ -1,58 +1,434 @@
import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Megaphone } from '@phosphor-icons/react'
import { ArrowUpRight, Bug, Check, Copy, GitPullRequest, Lightbulb, MessagesSquare } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { TOLARIA_GITHUB_ISSUES_URL } from '../constants/feedback'
import {
TOLARIA_GITHUB_CONTRIBUTING_URL,
TOLARIA_GITHUB_DISCUSSIONS_URL,
TOLARIA_GITHUB_ISSUES_URL,
TOLARIA_GITHUB_PULL_REQUESTS_URL,
TOLARIA_PRODUCT_BOARD_URL,
} from '../constants/feedback'
import {
buildSanitizedDiagnosticBundle,
startFeedbackDiagnosticsCapture,
} from '../lib/feedbackDiagnostics'
import { cn } from '../lib/utils'
import { takeFeedbackDialogOpener } from '../lib/feedbackDialogOpener'
import { useBuildNumber } from '../hooks/useBuildNumber'
import { APP_COMMAND_EVENT_NAME, APP_COMMAND_IDS } from '../hooks/appCommandDispatcher'
import { openExternalUrl } from '../utils/url'
interface FeedbackDialogProps {
open: boolean
onClose: () => void
buildNumber?: string
releaseChannel?: string | null
}
export function FeedbackDialog({ open, onClose }: FeedbackDialogProps) {
const handleOpenIssues = () => {
void openExternalUrl(TOLARIA_GITHUB_ISSUES_URL)
interface ContributionCardProps {
title: string
description: string
ctaLabel: string
icon: typeof Lightbulb
tone: ContributionTone
onAction: () => void
autoFocus?: boolean
secondaryAction?: ReactNode
}
interface LinkFallback {
label: string
url: string
}
interface ContributionPath {
title: string
description: string
ctaLabel: string
label: string
url: string
icon: typeof Lightbulb
tone: ContributionTone
secondaryLink?: ContributionLink
}
interface ContributionLink {
ctaLabel: string
label: string
url: string
}
const EMPTY_DIALOG_OPENER: ReturnType<typeof takeFeedbackDialogOpener> = {
element: null,
reopenCommandPalette: false,
}
type ContributionTone = 'green' | 'yellow' | 'purple' | 'red'
const CONTRIBUTION_TONE_CLASSES: Record<ContributionTone, string> = {
green: 'bg-[var(--accent-green-light)] text-[var(--accent-green)]',
yellow: 'bg-[var(--accent-yellow-light)] text-[var(--accent-yellow)]',
purple: 'bg-[var(--accent-purple-light)] text-[var(--accent-purple)]',
red: 'bg-[var(--accent-red-light)] text-[var(--accent-red)]',
}
const CONTRIBUTION_BUTTON_CLASSES: Record<ContributionTone, string> = {
green: 'border-[var(--accent-green)] hover:bg-[var(--accent-green-light)] [&_svg]:text-[var(--accent-green)]',
yellow: 'border-[var(--accent-yellow)] hover:bg-[var(--accent-yellow-light)] [&_svg]:text-[var(--accent-yellow)]',
purple: 'border-[var(--accent-purple)] hover:bg-[var(--accent-purple-light)] [&_svg]:text-[var(--accent-purple)]',
red: 'border-[var(--accent-red)] hover:bg-[var(--accent-red-light)] [&_svg]:text-[var(--accent-red)]',
}
const CONTRIBUTION_PATHS: ContributionPath[] = [
{
title: 'Feature requests',
description: 'Search on the product board first, upvote an existing idea if already there, and only create a new post when genuinely new!',
ctaLabel: 'Open Product Board',
label: 'Product Board',
url: TOLARIA_PRODUCT_BOARD_URL,
icon: Lightbulb,
tone: 'green',
},
{
title: 'Discussions',
description: 'Use GitHub Discussions for questions, broader conversations, idea sharing, and community context that is not a concrete bug report.',
ctaLabel: 'Open Discussions',
label: 'GitHub Discussions',
url: TOLARIA_GITHUB_DISCUSSIONS_URL,
icon: MessagesSquare,
tone: 'purple',
},
{
title: 'Contribute code',
description: 'Small, focused pull requests are welcome. Check planned or in-progress work first so you are building in the right place.',
ctaLabel: 'Open Pull Requests',
label: 'GitHub Pull Requests',
url: TOLARIA_GITHUB_PULL_REQUESTS_URL,
icon: GitPullRequest,
tone: 'yellow',
secondaryLink: {
ctaLabel: 'Open Contributing Guide',
label: 'the contributing guide',
url: TOLARIA_GITHUB_CONTRIBUTING_URL,
},
},
]
function ContributionLinkButton({
label,
tone,
onAction,
autoFocus = false,
accented = true,
}: {
label: string
tone: ContributionTone
onAction: () => void
autoFocus?: boolean
accented?: boolean
}) {
return (
<Button
type="button"
variant="outline"
className={cn(
'w-full justify-between',
accented && 'bg-background text-foreground hover:text-foreground',
accented && CONTRIBUTION_BUTTON_CLASSES[tone],
)}
autoFocus={autoFocus}
onClick={onAction}
>
{label}
<ArrowUpRight size={14} />
</Button>
)
}
function ContributionCard({
title,
description,
ctaLabel,
icon: Icon,
tone,
onAction,
autoFocus = false,
secondaryAction,
}: ContributionCardProps) {
return (
<Card className="gap-4 border-border/70 py-4 shadow-none">
<CardHeader className="gap-3 px-4">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<span className={cn('rounded-md p-2', CONTRIBUTION_TONE_CLASSES[tone])}>
<Icon size={16} />
</span>
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
</div>
<CardDescription className="text-sm leading-6 text-muted-foreground">
{description}
</CardDescription>
</CardHeader>
<CardContent className="px-4">
<ContributionLinkButton label={ctaLabel} tone={tone} autoFocus={autoFocus} onAction={onAction} />
</CardContent>
{secondaryAction ? <CardFooter className="px-4 pt-0">{secondaryAction}</CardFooter> : null}
</Card>
)
}
function LinkFallbackBanner({ linkFallback }: { linkFallback: LinkFallback | null }) {
if (!linkFallback) return null
return (
<div
className="rounded-lg border px-4 py-3 text-sm"
style={{
background: 'var(--feedback-warning-bg)',
borderColor: 'var(--feedback-warning-border)',
color: 'var(--feedback-warning-text)',
}}
>
<p className="font-medium">Couldnt open {linkFallback.label} automatically.</p>
<p className="mt-1">Open this URL manually instead:</p>
<p className="mt-2 break-all rounded-md bg-popover px-3 py-2 font-mono text-xs text-foreground">
{linkFallback.url}
</p>
</div>
)
}
function getCopyDiagnosticsLabel(copyState: 'idle' | 'copied' | 'failed') {
return copyState === 'copied' ? 'Diagnostics copied' : 'Copy sanitized diagnostics'
}
function BugReportActions({
copyState,
canCopyDiagnostics,
onCopyDiagnostics,
}: {
copyState: 'idle' | 'copied' | 'failed'
canCopyDiagnostics: boolean
onCopyDiagnostics: () => void
}) {
return (
<div className="flex w-full flex-col gap-2">
<Button
type="button"
variant="outline"
className="w-full justify-between"
onClick={onCopyDiagnostics}
disabled={!canCopyDiagnostics}
>
{getCopyDiagnosticsLabel(copyState)}
{copyState === 'copied' ? <Check size={14} /> : <Copy size={14} />}
</Button>
{copyState === 'copied' ? (
<p className="text-xs font-medium text-foreground">Diagnostics copied.</p>
) : null}
{copyState === 'failed' ? (
<p className="text-xs font-medium text-[var(--feedback-warning-text)]">
Clipboard access is unavailable right now. You can still open GitHub Issues directly.
</p>
) : null}
</div>
)
}
function useDialogReturnFocus(open: boolean, onClose: () => void) {
const openerRef = useRef(EMPTY_DIALOG_OPENER)
useLayoutEffect(() => {
if (open) {
openerRef.current = takeFeedbackDialogOpener()
}
}, [open])
return () => {
const { element: opener, reopenCommandPalette } = openerRef.current
openerRef.current = takeFeedbackDialogOpener()
onClose()
window.setTimeout(() => {
if (reopenCommandPalette) {
window.dispatchEvent(new CustomEvent(APP_COMMAND_EVENT_NAME, {
detail: APP_COMMAND_IDS.viewCommandPalette,
}))
return
}
if (opener?.isConnected) {
opener.focus()
}
}, 80)
}
}
function useFeedbackDialogActions(diagnosticsBundle: string) {
const [linkFallback, setLinkFallback] = useState<LinkFallback | null>(null)
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
const canCopyDiagnostics = typeof navigator !== 'undefined' && typeof navigator.clipboard?.writeText === 'function'
const handleOpenLink = (label: string, url: string) => {
void openExternalUrl(url)
.then(() => {
setLinkFallback(null)
})
.catch(() => {
setLinkFallback({ label, url })
})
}
const handleCopyDiagnostics = () => {
if (!canCopyDiagnostics) {
setCopyState('failed')
return
}
void navigator.clipboard.writeText(diagnosticsBundle)
.then(() => {
setCopyState('copied')
})
.catch(() => {
setCopyState('failed')
})
}
const reset = () => {
setLinkFallback(null)
setCopyState('idle')
}
return {
linkFallback,
copyState,
canCopyDiagnostics,
handleOpenLink,
handleCopyDiagnostics,
reset,
}
}
function ContributionGrid({
onOpenLink,
copyState,
canCopyDiagnostics,
onCopyDiagnostics,
}: {
onOpenLink: (label: string, url: string) => void
copyState: 'idle' | 'copied' | 'failed'
canCopyDiagnostics: boolean
onCopyDiagnostics: () => void
}) {
return (
<div className="grid gap-4 sm:grid-cols-2">
{CONTRIBUTION_PATHS.map((path, index) => {
const secondaryLink = path.secondaryLink
return (
<ContributionCard
key={path.title}
title={path.title}
description={path.description}
ctaLabel={path.ctaLabel}
icon={path.icon}
tone={path.tone}
autoFocus={index === 0}
onAction={() => onOpenLink(path.label, path.url)}
secondaryAction={secondaryLink ? (
<ContributionLinkButton
label={secondaryLink.ctaLabel}
tone={path.tone}
accented={false}
onAction={() => onOpenLink(secondaryLink.label, secondaryLink.url)}
/>
) : undefined}
/>
)
})}
<ContributionCard
title="Report a bug"
description="Explain how to reproduce, what you expected, and what actually happened. Check for duplicate bugs first. Attach the sanitized diagnostic bundle please!"
ctaLabel="Open GitHub Issues"
icon={Bug}
tone="red"
onAction={() => onOpenLink('GitHub Issues', TOLARIA_GITHUB_ISSUES_URL)}
secondaryAction={(
<BugReportActions
copyState={copyState}
canCopyDiagnostics={canCopyDiagnostics}
onCopyDiagnostics={onCopyDiagnostics}
/>
)}
/>
</div>
)
}
export function FeedbackDialog({
open,
onClose,
buildNumber,
releaseChannel,
}: FeedbackDialogProps) {
const detectedBuildNumber = useBuildNumber()
const resolvedBuildNumber = buildNumber ?? detectedBuildNumber
const diagnosticsBundle = useMemo(
() => buildSanitizedDiagnosticBundle({ buildNumber: resolvedBuildNumber, releaseChannel }),
[releaseChannel, resolvedBuildNumber],
)
const handleRequestClose = useDialogReturnFocus(open, onClose)
const {
linkFallback,
copyState,
canCopyDiagnostics,
handleOpenLink,
handleCopyDiagnostics,
reset,
} = useFeedbackDialogActions(diagnosticsBundle)
useEffect(() => startFeedbackDiagnosticsCapture(), [])
const handleClose = () => {
reset()
handleRequestClose()
}
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[460px]" data-testid="feedback-dialog">
<DialogHeader>
<Dialog open={open} onOpenChange={(next) => { if (!next) handleClose() }}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-[760px]" data-testid="feedback-dialog">
<DialogHeader className="space-y-2">
<DialogTitle className="flex items-center gap-2">
<Megaphone size={18} weight="duotone" />
Share feedback
Contribute to Tolaria
</DialogTitle>
<DialogDescription>
The best way to share product feedback is through a GitHub Issue.
Pick the path that fits what you want to do! Any type of help is appreciated
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm leading-6 text-muted-foreground">
<p>
Before opening a new issue, please check whether a similar one already exists.
If it does, add an upvote or comment there instead of opening a duplicate.
</p>
<p>
When you do open a new issue, include the steps to reproduce, what you expected,
and what actually happened so it is easier to triage.
</p>
</div>
<DialogFooter className="sm:justify-between">
<Button type="button" variant="outline" onClick={onClose}>
Close
</Button>
<Button type="button" autoFocus onClick={handleOpenIssues}>
Go to Issues
</Button>
</DialogFooter>
<LinkFallbackBanner linkFallback={linkFallback} />
<ContributionGrid
onOpenLink={handleOpenLink}
copyState={copyState}
canCopyDiagnostics={canCopyDiagnostics}
onCopyDiagnostics={handleCopyDiagnostics}
/>
</DialogContent>
</Dialog>
)

View File

@@ -163,7 +163,7 @@ function TitlebarButton({
aria-label={ariaLabel}
className={[
'h-full w-[46px] rounded-none text-foreground/70 hover:text-foreground',
close ? 'hover:bg-red-500 hover:text-white' : 'hover:bg-foreground/10',
close ? 'hover:bg-destructive hover:text-destructive-foreground' : 'hover:bg-foreground/10',
].join(' ')}
onClick={onClick}
data-no-drag

View File

@@ -60,9 +60,9 @@ const STATUS_ICON = {
} as const
const STATUS_COLOR = {
added: 'var(--accent-green, #16a34a)',
modified: 'var(--accent-orange, #ea580c)',
deleted: 'var(--destructive, #dc2626)',
added: 'var(--accent-green)',
modified: 'var(--accent-orange)',
deleted: 'var(--destructive)',
} as const
const PULSE_ROW_FOCUS_CLASS_NAME = 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/70 focus-visible:ring-inset'

View File

@@ -31,29 +31,119 @@ export interface RawEditorViewProps {
const DEBOUNCE_MS = 500
const DROPDOWN_MAX_HEIGHT = 200
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pathRef = useRef(path)
const onContentChangeRef = useRef(onContentChange)
const onSaveRef = useRef(onSave)
const latestDocRef = useRef(content)
useEffect(() => { pathRef.current = path }, [path])
// Expose latest doc content to parent via ref
useEffect(() => { if (latestContentRef) latestContentRef.current = content }, [latestContentRef, content])
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
useEffect(() => { onSaveRef.current = onSave }, [onSave])
type PendingChangeRefs = {
debounceRef: React.MutableRefObject<ReturnType<typeof setTimeout> | null>
latestDocRef: React.MutableRefObject<string>
onContentChangeRef: React.MutableRefObject<RawEditorViewProps['onContentChange']>
pathRef: React.MutableRefObject<string>
}
const [autocomplete, setAutocomplete] = useState<RawEditorAutocompleteState | null>(null)
function useLatestRef<T>(value: T): React.MutableRefObject<T> {
const ref = useRef(value)
useEffect(() => { ref.current = value }, [value])
return ref
}
function flushPendingRawEditorChange({
debounceRef,
latestDocRef,
onContentChangeRef,
pathRef,
}: PendingChangeRefs): void {
if (!debounceRef.current) return
clearTimeout(debounceRef.current)
debounceRef.current = null
onContentChangeRef.current(pathRef.current, latestDocRef.current)
}
function moveRawEditorAutocompleteSelection(
autocomplete: RawEditorAutocompleteState,
direction: 'next' | 'previous',
): RawEditorAutocompleteState {
const selectedIndex = direction === 'next'
? Math.min(autocomplete.selectedIndex + 1, autocomplete.items.length - 1)
: Math.max(autocomplete.selectedIndex - 1, 0)
return { ...autocomplete, selectedIndex }
}
function RawEditorYamlErrorBanner({ error }: { error: string | null }) {
if (!error) return null
return (
<div
className="flex items-center gap-2 px-4 py-2 text-xs border-b shrink-0"
style={{
background: 'var(--feedback-warning-bg)',
borderColor: 'var(--feedback-warning-border)',
color: 'var(--feedback-warning-text)',
}}
role="alert"
data-testid="raw-editor-yaml-error"
>
<span style={{ fontWeight: 600 }}>YAML error:</span>
<span>{error}</span>
</div>
)
}
function RawEditorAutocompleteDropdown({
autocomplete,
onItemHover,
position,
}: {
autocomplete: RawEditorAutocompleteState | null
onItemHover: (index: number) => void
position: { top: number; left: number }
}) {
if (!autocomplete || autocomplete.items.length === 0) return null
return (
<div
className="fixed z-50 min-w-64 max-w-xs overflow-auto rounded-md border shadow-[0_12px_30px_var(--shadow-dialog)]"
style={{
top: position.top,
left: position.left,
maxHeight: DROPDOWN_MAX_HEIGHT,
background: 'var(--popover)',
borderColor: 'var(--border)',
}}
data-testid="raw-editor-wikilink-dropdown"
>
<NoteSearchList
items={autocomplete.items}
selectedIndex={autocomplete.selectedIndex}
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
onItemClick={(item) => item.onItemClick()}
onItemHover={onItemHover}
/>
</div>
)
}
type RawEditorPendingChanges = PendingChangeRefs & {
handleDocChange: (doc: string) => void
handleSave: () => void
yamlError: string | null
}
function useRawEditorPendingChanges({
content,
latestContentRef,
onContentChange,
onSave,
path,
}: Pick<RawEditorViewProps, 'content' | 'latestContentRef' | 'onContentChange' | 'onSave' | 'path'>): RawEditorPendingChanges {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pathRef = useLatestRef(path)
const onContentChangeRef = useLatestRef(onContentChange)
const onSaveRef = useLatestRef(onSave)
const latestContentRefStable = useRef(latestContentRef)
const latestDocRef = useRef(content)
const [yamlError, setYamlError] = useState<string | null>(() => detectYamlError(content))
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(() => buildRawEditorBaseItems(entries), [entries])
const insertWikilinkRef = useRef<(target: string) => void>(() => {})
const latestContentRefStable = useRef(latestContentRef)
useEffect(() => { if (latestContentRef) latestContentRef.current = content }, [latestContentRef, content])
useEffect(() => { latestContentRefStable.current = latestContentRef }, [latestContentRef])
const handleDocChange = useCallback((doc: string) => {
@@ -64,48 +154,149 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
debounceRef.current = setTimeout(() => {
onContentChangeRef.current(pathRef.current, doc)
}, DEBOUNCE_MS)
}, [])
const handleCursorActivity = useCallback((view: EditorView) => {
const doc = view.state.doc.toString()
const cursor = view.state.selection.main.head
const query = extractWikilinkQuery(doc, cursor)
if (query === null || query.length < MIN_QUERY_LENGTH) {
setAutocomplete(null)
return
}
const nextAutocomplete = buildRawEditorAutocompleteState({
view,
baseItems,
query,
typeEntryMap,
onInsertTarget: (target: string) => insertWikilinkRef.current(target),
vaultPath: vaultPath ?? '',
})
setAutocomplete(nextAutocomplete)
}, [baseItems, typeEntryMap, vaultPath])
}, [latestContentRefStable, onContentChangeRef, pathRef])
const handleSave = useCallback(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
debounceRef.current = null
onContentChangeRef.current(pathRef.current, latestDocRef.current)
}
flushPendingRawEditorChange({ debounceRef, latestDocRef, onContentChangeRef, pathRef })
onSaveRef.current()
}, [])
}, [onContentChangeRef, onSaveRef, pathRef])
const handleEscape = useCallback(() => {
useEffect(() => {
return () => {
flushPendingRawEditorChange({ debounceRef, latestDocRef, onContentChangeRef, pathRef })
}
}, [onContentChangeRef, pathRef])
return {
debounceRef,
handleDocChange,
handleSave,
latestDocRef,
onContentChangeRef,
pathRef,
yamlError,
}
}
type RawEditorAutocompleteDirection = 'next' | 'previous'
type RawEditorSetAutocomplete = React.Dispatch<React.SetStateAction<RawEditorAutocompleteState | null>>
type RawEditorTypeEntryMap = ReturnType<typeof buildTypeEntryMap>
function getRawEditorAutocompleteDirection(key: string): RawEditorAutocompleteDirection | null {
if (key === 'ArrowDown') return 'next'
if (key === 'ArrowUp') return 'previous'
return null
}
function buildNextRawEditorAutocomplete({
baseItems,
insertWikilinkRef,
typeEntryMap,
vaultPath,
view,
}: {
baseItems: ReturnType<typeof buildRawEditorBaseItems>
insertWikilinkRef: React.MutableRefObject<(target: string) => void>
typeEntryMap: RawEditorTypeEntryMap
vaultPath?: string
view: EditorView
}): RawEditorAutocompleteState | null {
const doc = view.state.doc.toString()
const cursor = view.state.selection.main.head
const query = extractWikilinkQuery(doc, cursor)
if (query === null || query.length < MIN_QUERY_LENGTH) return null
return buildRawEditorAutocompleteState({
view,
baseItems,
query,
typeEntryMap,
onInsertTarget: (target: string) => insertWikilinkRef.current(target),
vaultPath: vaultPath ?? '',
})
}
function useRawEditorAutocompleteEscape(
autocomplete: RawEditorAutocompleteState | null,
setAutocomplete: RawEditorSetAutocomplete,
) {
return useCallback(() => {
if (autocomplete) { setAutocomplete(null); return true }
return false
}, [autocomplete])
}, [autocomplete, setAutocomplete])
}
const viewRef = useCodeMirror(containerRef, content, {
onDocChange: handleDocChange,
onCursorActivity: handleCursorActivity,
onSave: handleSave,
onEscape: handleEscape,
})
function useRawEditorAutocompleteKeyboard(
autocomplete: RawEditorAutocompleteState | null,
setAutocomplete: RawEditorSetAutocomplete,
) {
return useCallback((e: React.KeyboardEvent) => {
if (!autocomplete) return
if (e.key === 'Enter') {
e.preventDefault()
autocomplete.items[autocomplete.selectedIndex]?.onItemClick()
return
}
const direction = getRawEditorAutocompleteDirection(e.key)
if (!direction) return
e.preventDefault()
setAutocomplete(prev => prev ? moveRawEditorAutocompleteSelection(prev, direction) : null)
}, [autocomplete, setAutocomplete])
}
function useRawEditorAutocompleteController({
entries,
vaultPath,
}: Pick<RawEditorViewProps, 'entries' | 'vaultPath'>) {
const [autocomplete, setAutocomplete] = useState<RawEditorAutocompleteState | null>(null)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(() => buildRawEditorBaseItems(entries), [entries])
const insertWikilinkRef = useRef<(target: string) => void>(() => {})
const handleCursorActivity = useCallback((view: EditorView) => {
setAutocomplete(buildNextRawEditorAutocomplete({
baseItems,
insertWikilinkRef,
typeEntryMap,
vaultPath,
view,
}))
}, [baseItems, typeEntryMap, vaultPath])
const handleItemHover = useCallback((index: number) => {
setAutocomplete(prev => prev ? { ...prev, selectedIndex: index } : null)
}, [])
const handleEscape = useRawEditorAutocompleteEscape(autocomplete, setAutocomplete)
const handleAutocompleteKey = useRawEditorAutocompleteKeyboard(autocomplete, setAutocomplete)
return {
autocomplete,
handleAutocompleteKey,
handleCursorActivity,
handleEscape,
handleItemHover,
insertWikilinkRef,
setAutocomplete,
}
}
function useRawEditorWikilinkInsertion({
debounceRef,
insertWikilinkRef,
latestDocRef,
onContentChangeRef,
pathRef,
setAutocomplete,
viewRef,
}: PendingChangeRefs & {
insertWikilinkRef: React.MutableRefObject<(target: string) => void>
setAutocomplete: RawEditorSetAutocomplete
viewRef: React.MutableRefObject<EditorView | null>
}) {
const insertWikilink = useCallback((target: string) => {
const view = viewRef.current
if (!view) return
@@ -127,81 +318,48 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
onContentChangeRef.current(pathRef.current, replacement.text)
view.focus()
}, [viewRef])
}, [debounceRef, latestDocRef, onContentChangeRef, pathRef, setAutocomplete, viewRef])
useEffect(() => { insertWikilinkRef.current = insertWikilink }, [insertWikilink])
useEffect(() => { insertWikilinkRef.current = insertWikilink }, [insertWikilinkRef, insertWikilink])
}
const handleAutocompleteKey = useCallback((e: React.KeyboardEvent) => {
if (!autocomplete) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setAutocomplete(prev => prev
? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, prev.items.length - 1) }
: null)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setAutocomplete(prev => prev
? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) }
: null)
} else if (e.key === 'Enter') {
e.preventDefault()
const item = autocomplete.items[autocomplete.selectedIndex]
if (item) item.onItemClick()
}
}, [autocomplete])
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const pendingChanges = useRawEditorPendingChanges({ content, latestContentRef, onContentChange, onSave, path })
const autocompleteController = useRawEditorAutocompleteController({ entries, vaultPath })
const viewRef = useCodeMirror(containerRef, content, {
onDocChange: pendingChanges.handleDocChange,
onCursorActivity: autocompleteController.handleCursorActivity,
onSave: pendingChanges.handleSave,
onEscape: autocompleteController.handleEscape,
})
// Flush pending debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
onContentChangeRef.current(pathRef.current, latestDocRef.current)
}
}
}, [])
useRawEditorWikilinkInsertion({
debounceRef: pendingChanges.debounceRef,
insertWikilinkRef: autocompleteController.insertWikilinkRef,
latestDocRef: pendingChanges.latestDocRef,
onContentChangeRef: pendingChanges.onContentChangeRef,
pathRef: pendingChanges.pathRef,
setAutocomplete: autocompleteController.setAutocomplete,
viewRef,
})
const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window)
const dropdownPosition = getRawEditorDropdownPosition(autocompleteController.autocomplete, DROPDOWN_MAX_HEIGHT, window)
return (
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={handleAutocompleteKey} role="presentation">
{yamlError && (
<div
className="flex items-center gap-2 px-4 py-2 text-xs border-b shrink-0"
style={{ background: '#fef3c7', borderColor: '#d97706', color: '#92400e' }}
role="alert"
data-testid="raw-editor-yaml-error"
>
<span style={{ fontWeight: 600 }}>YAML error:</span>
<span>{yamlError}</span>
</div>
)}
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={autocompleteController.handleAutocompleteKey} role="presentation">
<RawEditorYamlErrorBanner error={pendingChanges.yamlError} />
<div
ref={containerRef}
className="flex flex-1 min-h-0"
data-testid="raw-editor-codemirror"
aria-label="Raw editor"
/>
{autocomplete && autocomplete.items.length > 0 && (
<div
className="fixed z-50 min-w-64 max-w-xs rounded-md border shadow-lg overflow-auto"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
maxHeight: DROPDOWN_MAX_HEIGHT,
background: 'var(--popover)',
borderColor: 'var(--border)',
}}
data-testid="raw-editor-wikilink-dropdown"
>
<NoteSearchList
items={autocomplete.items}
selectedIndex={autocomplete.selectedIndex}
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
onItemClick={(item) => item.onItemClick()}
onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)}
/>
</div>
)}
<RawEditorAutocompleteDropdown
autocomplete={autocompleteController.autocomplete}
onItemHover={autocompleteController.handleItemHover}
position={dropdownPosition}
/>
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { SettingsPanel } from './SettingsPanel'
import type { Settings } from '../types'
import { THEME_MODE_STORAGE_KEY } from '../lib/themeMode'
const emptySettings: Settings = {
auto_pull_interval_minutes: null,
@@ -14,6 +15,7 @@ const emptySettings: Settings = {
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
theme_mode: null,
}
function installPointerCapturePolyfill() {
@@ -28,12 +30,27 @@ function installPointerCapturePolyfill() {
}
}
function createStorageMock(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size },
clear: vi.fn(() => { values.clear() }),
getItem: vi.fn((key: string) => values.get(key) ?? null),
key: vi.fn((index: number) => Array.from(values.keys())[index] ?? null),
removeItem: vi.fn((key: string) => { values.delete(key) }),
setItem: vi.fn((key: string, value: string) => { values.set(key, value) }),
}
}
describe('SettingsPanel', () => {
const onSave = vi.fn()
const onClose = vi.fn()
const localStorageMock = createStorageMock()
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true })
window.localStorage.clear()
installPointerCapturePolyfill()
})
@@ -65,10 +82,62 @@ describe('SettingsPanel', () => {
autogit_idle_threshold_seconds: 90,
autogit_inactive_threshold_seconds: 30,
release_channel: null,
theme_mode: 'light',
}))
expect(onClose).toHaveBeenCalled()
})
it('defaults the color mode control to light', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByTestId('settings-theme-mode')).toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'Light' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'false')
})
it('uses the stored color mode mirror when settings have no saved mode', () => {
window.localStorage.setItem(THEME_MODE_STORAGE_KEY, 'dark')
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'true')
})
it('saves the selected dark color mode', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
)
fireEvent.click(screen.getByRole('radio', { name: 'Dark' }))
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
theme_mode: 'dark',
}))
})
it('preserves a saved dark color mode until changed', () => {
render(
<SettingsPanel
open={true}
settings={{ ...emptySettings, theme_mode: 'dark' }}
onSave={onSave}
onClose={onClose}
/>
)
expect(screen.getByRole('radio', { name: 'Dark' })).toHaveAttribute('aria-checked', 'true')
fireEvent.click(screen.getByTestId('settings-save'))
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
theme_mode: 'dark',
}))
})
it('defaults the release channel trigger to stable', () => {
render(
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />

View File

@@ -15,8 +15,13 @@ import {
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from 'react'
import { X } from '@phosphor-icons/react'
import { Moon, Sun, X } from '@phosphor-icons/react'
import type { Settings } from '../types'
import {
DEFAULT_THEME_MODE,
readStoredThemeMode,
type ThemeMode,
} from '../lib/themeMode'
import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel'
import { trackEvent } from '../lib/telemetry'
import { Button } from './ui/button'
@@ -50,6 +55,7 @@ interface SettingsDraft {
autoAdvanceInboxAfterOrganize: boolean
defaultAiAgent: AiAgentId
releaseChannel: ReleaseChannel
themeMode: ThemeMode
initialH1AutoRename: boolean
crashReporting: boolean
analytics: boolean
@@ -73,6 +79,8 @@ interface SettingsBodyProps {
setDefaultAiAgent: (value: AiAgentId) => void
releaseChannel: ReleaseChannel
setReleaseChannel: (value: ReleaseChannel) => void
themeMode: ThemeMode
setThemeMode: (value: ThemeMode) => void
initialH1AutoRename: boolean
setInitialH1AutoRename: (value: boolean) => void
explicitOrganization: boolean
@@ -109,6 +117,7 @@ function createSettingsDraft(
autoAdvanceInboxAfterOrganize: settings.auto_advance_inbox_after_organize ?? false,
defaultAiAgent: resolveDefaultAiAgent(settings.default_ai_agent),
releaseChannel: normalizeReleaseChannel(settings.release_channel),
themeMode: resolveSettingsDraftThemeMode(settings.theme_mode),
initialH1AutoRename: settings.initial_h1_auto_rename_enabled ?? true,
crashReporting: settings.crash_reporting_enabled ?? false,
analytics: settings.analytics_enabled ?? false,
@@ -116,6 +125,12 @@ function createSettingsDraft(
}
}
function resolveSettingsDraftThemeMode(themeMode: Settings['theme_mode']): ThemeMode {
if (themeMode) return themeMode
if (typeof window === 'undefined') return DEFAULT_THEME_MODE
return readStoredThemeMode(window.localStorage) ?? DEFAULT_THEME_MODE
}
function resolveTelemetryConsent(settings: Settings, draft: SettingsDraft): boolean | null {
if (draft.crashReporting || draft.analytics) return true
return settings.telemetry_consent === null ? null : false
@@ -141,6 +156,7 @@ function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Setti
analytics_enabled: draft.analytics,
anonymous_id: resolveAnonymousId(settings, draft),
release_channel: serializeReleaseChannel(draft.releaseChannel),
theme_mode: draft.themeMode,
initial_h1_auto_rename_enabled: draft.initialH1AutoRename,
default_ai_agent: draft.defaultAiAgent,
}
@@ -251,14 +267,14 @@ function SettingsPanelInner({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.4)' }}
style={{ background: 'var(--shadow-overlay)' }}
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
data-testid="settings-panel"
>
<div
ref={panelRef}
className="bg-background border border-border rounded-lg shadow-xl"
className="rounded-lg border border-border bg-background shadow-[0_18px_55px_var(--shadow-dialog)]"
style={{ width: 520, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}
>
<SettingsHeader onClose={onClose} />
@@ -279,6 +295,8 @@ function SettingsPanelInner({
setDefaultAiAgent={(value) => updateDraft('defaultAiAgent', value)}
releaseChannel={draft.releaseChannel}
setReleaseChannel={(value) => updateDraft('releaseChannel', value)}
themeMode={draft.themeMode}
setThemeMode={(value) => updateDraft('themeMode', value)}
initialH1AutoRename={draft.initialH1AutoRename}
setInitialH1AutoRename={(value) => updateDraft('initialH1AutoRename', value)}
explicitOrganization={draft.explicitOrganization}
@@ -331,6 +349,8 @@ function SettingsBody({
setDefaultAiAgent,
releaseChannel,
setReleaseChannel,
themeMode,
setThemeMode,
initialH1AutoRename,
setInitialH1AutoRename,
explicitOrganization,
@@ -363,6 +383,13 @@ function SettingsBody({
/>
</SettingsSection>
<SettingsSection>
<AppearanceSettingsSection
themeMode={themeMode}
setThemeMode={setThemeMode}
/>
</SettingsSection>
<SettingsSection>
<TitleSettingsSection
initialH1AutoRename={initialH1AutoRename}
@@ -438,6 +465,81 @@ function SyncAndUpdatesSection({
)
}
function AppearanceSettingsSection({
themeMode,
setThemeMode,
}: Pick<SettingsBodyProps, 'themeMode' | 'setThemeMode'>) {
return (
<>
<SectionHeading
title="Appearance"
description="Choose the app color mode used for Tolaria chrome, editor surfaces, menus, and dialogs."
/>
<ThemeModeControl value={themeMode} onChange={setThemeMode} />
</>
)
}
function ThemeModeControl({
value,
onChange,
}: {
value: ThemeMode
onChange: (value: ThemeMode) => void
}) {
return (
<div
className="inline-flex w-full rounded-md border border-border bg-muted p-1"
role="radiogroup"
aria-label="Theme"
data-testid="settings-theme-mode"
>
<ThemeModeButton label="Light" selected={value === 'light'} value="light" onSelect={onChange}>
<Sun size={14} />
</ThemeModeButton>
<ThemeModeButton label="Dark" selected={value === 'dark'} value="dark" onSelect={onChange}>
<Moon size={14} />
</ThemeModeButton>
</div>
)
}
function ThemeModeButton({
children,
label,
selected,
value,
onSelect,
}: {
children: ReactNode
label: string
selected: boolean
value: ThemeMode
onSelect: (value: ThemeMode) => void
}) {
return (
<Button
type="button"
variant="ghost"
size="sm"
role="radio"
aria-checked={selected}
aria-label={label}
data-testid={`settings-theme-${value}`}
className={
selected
? 'h-7 flex-1 border border-border bg-background text-foreground shadow-xs hover:bg-background'
: 'h-7 flex-1 text-muted-foreground hover:text-foreground'
}
onClick={() => onSelect(value)}
>
{children}
{label}
</Button>
)
}
function autoGitSectionDescription(isGitVault: boolean): string {
return isGitVault
? 'Automatically create conservative Git checkpoints after editing pauses or when the app is no longer active.'

View File

@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { type IconProps } from '@phosphor-icons/react'
import { SIDEBAR_ITEM_PADDING } from './sidebar/sidebarStyles'
import { Button } from './ui/button'
const SIDEBAR_COUNT_PILL_STYLE = {
borderRadius: 9999,
@@ -359,6 +360,19 @@ function getSectionContextMenuHandler(
return onContextMenu
}
function resolveInlineRenameHandlers({
isRenaming,
onRenameCancel,
onRenameSubmit,
}: {
isRenaming?: boolean
onRenameCancel?: () => void
onRenameSubmit?: (value: string) => void
}): { onRenameCancel: () => void; onRenameSubmit: (value: string) => void } | null {
if (!isRenaming || !onRenameSubmit || !onRenameCancel) return null
return { onRenameCancel, onRenameSubmit }
}
function SectionHeaderLabel({
type,
label,
@@ -378,13 +392,19 @@ function SectionHeaderLabel({
onRenameSubmit?: (value: string) => void
onRenameCancel?: () => void
}) {
if (isRenaming && onRenameSubmit && onRenameCancel) {
const inlineRenameHandlers = resolveInlineRenameHandlers({
isRenaming,
onRenameCancel,
onRenameSubmit,
})
if (inlineRenameHandlers) {
return (
<InlineRenameInput
key={`rename-${type}`}
initialValue={renameInitialValue ?? label}
onSubmit={onRenameSubmit}
onCancel={onRenameCancel}
onSubmit={inlineRenameHandlers.onRenameSubmit}
onCancel={inlineRenameHandlers.onRenameCancel}
/>
)
}
@@ -406,7 +426,7 @@ function SectionHeaderCountPill({
<SidebarCountPill
count={itemCount}
className={!isActive ? 'text-muted-foreground' : undefined}
style={isActive ? { background: sectionColor, color: 'white' } : { background: 'var(--muted)' }}
style={isActive ? { background: sectionColor, color: 'var(--text-inverse)' } : { background: 'var(--muted)' }}
/>
)
}
@@ -458,9 +478,11 @@ function VisibilityPopoverItem({
const { sectionColor } = resolveSectionColors(type, customColor)
return (
<button
<Button
type="button"
className="flex w-full cursor-pointer items-center border-none bg-transparent transition-colors hover:bg-accent"
variant="ghost"
size="sm"
className="h-auto w-full justify-start rounded-none px-3 py-1.5"
style={{ padding: '6px 12px', gap: 8 }}
onClick={() => onToggle(type)}
aria-label={`Toggle ${label}`}
@@ -468,7 +490,7 @@ function VisibilityPopoverItem({
<Icon size={14} style={{ color: sectionColor }} />
<span className="flex-1 text-left text-[13px] text-foreground">{label}</span>
<ToggleSwitch on={isVisible} />
</button>
</Button>
)
}
@@ -482,7 +504,7 @@ export function VisibilityPopover({ sections, isSectionVisible, onToggle }: {
return (
<div
className="border border-border bg-popover text-popover-foreground"
style={{ position: 'absolute', top: '100%', left: 6, right: 6, zIndex: 50, borderRadius: 8, padding: '8px 0', boxShadow: '0 4px 12px rgba(0,0,0,0.12)' }}
style={{ position: 'absolute', top: '100%', left: 6, right: 6, zIndex: 50, borderRadius: 8, padding: '8px 0', boxShadow: '0 4px 12px var(--shadow-dialog)' }}
>
<div className="text-[12px] font-semibold text-muted-foreground" style={{ padding: '0 12px 4px' }}>Show in sidebar</div>
{sections.map((group) => (
@@ -500,7 +522,7 @@ export function VisibilityPopover({ sections, isSectionVisible, onToggle }: {
function ToggleSwitch({ on }: { on: boolean }) {
return (
<div className="flex items-center" style={{ width: 32, height: 18, borderRadius: 9, padding: 2, backgroundColor: on ? 'var(--primary)' : 'var(--muted)', justifyContent: on ? 'flex-end' : 'flex-start', transition: 'background-color 150ms' }}>
<div style={{ width: 14, height: 14, borderRadius: 7, backgroundColor: 'white', transition: 'transform 150ms' }} />
<div style={{ width: 14, height: 14, borderRadius: 7, backgroundColor: 'var(--background)', transition: 'transform 150ms' }} />
</div>
)
}

View File

@@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
import type { VaultEntry } from '../types'
const state = vi.hoisted(() => ({
capturedLinkToolbarProps: null as null | Record<string, unknown>,
capturedToolbarProps: null as null | Record<string, unknown>,
capturedSuggestionProps: {} as Record<string, Record<string, unknown>>,
capturedImageDropArgs: null as null | Record<string, unknown>,
@@ -23,15 +24,18 @@ vi.mock('@blocknote/react', () => ({
editable?: boolean
className?: string
formattingToolbar?: boolean
linkToolbar?: boolean
slashMenu?: boolean
sideMenu?: boolean
onChange?: () => void
theme?: string
}) => {
const {
children,
editable,
className,
formattingToolbar,
linkToolbar,
slashMenu,
sideMenu,
...restProps
@@ -45,6 +49,7 @@ vi.mock('@blocknote/react', () => ({
<div
data-testid="blocknote-view"
data-editable={editable !== false ? 'true' : 'false'}
data-link-toolbar={linkToolbar !== false ? 'true' : 'false'}
className={className}
{...restProps}
>
@@ -52,12 +57,47 @@ vi.mock('@blocknote/react', () => ({
</div>
)
},
LinkToolbarController: (props: Record<string, unknown>) => {
state.capturedLinkToolbarProps = props
return <div data-testid="link-toolbar-controller" />
},
LinkToolbar: ({ children }: { children?: ReactNode }) => (
<div className="bn-link-toolbar">{children}</div>
),
EditLinkButton: () => <button type="button">Edit Link</button>,
DeleteLinkButton: () => <button type="button">Remove Link</button>,
SideMenuController: () => <div data-testid="side-menu-controller" />,
SuggestionMenuController: (props: Record<string, unknown>) => {
state.capturedSuggestionProps[String(props.triggerCharacter)] = props
return <div data-testid={`suggestion-${String(props.triggerCharacter)}`} />
},
useComponentsContext: () => ({
LinkToolbar: {
Button: ({
children,
icon,
label,
onClick,
}: {
children?: ReactNode
icon?: ReactNode
label?: string
onClick?: () => void
}) => (
<button onClick={onClick} type="button">
{icon}
{label}
{children}
</button>
),
},
}),
useCreateBlockNote: vi.fn(),
useDictionary: () => ({
link_toolbar: {
open: { tooltip: 'Open in a new tab' },
},
}),
}))
vi.mock('@blocknote/mantine', () => ({
@@ -83,6 +123,10 @@ vi.mock('../hooks/useImageDrop', () => ({
},
}))
vi.mock('../utils/url', () => ({
openExternalUrl: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('../utils/typeColors', () => ({
buildTypeEntryMap: () => ({}),
}))
@@ -137,8 +181,11 @@ vi.mock('./useEditorLinkActivation', () => ({
),
}))
import { openExternalUrl } from '../utils/url'
import { SingleEditorView } from './SingleEditorView'
const mockOpenExternalUrl = vi.mocked(openExternalUrl)
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: '/vault/project/alpha.md',
@@ -199,12 +246,16 @@ function createEditor() {
describe('SingleEditorView', () => {
beforeEach(() => {
vi.clearAllMocks()
state.capturedLinkToolbarProps = null
state.capturedToolbarProps = null
state.capturedSuggestionProps = {}
state.capturedImageDropArgs = null
state.capturedBlockNoteOnChange = null
state.imageDropState.isDragOver = false
state.wikilinkEntriesRef.current = []
mockOpenExternalUrl.mockClear()
document.documentElement.removeAttribute('data-theme')
document.documentElement.classList.remove('dark')
delete window.__laputaTest
})
@@ -282,6 +333,15 @@ describe('SingleEditorView', () => {
expect(state.hoverGuardMock).toHaveBeenCalledOnce()
expect(state.linkActivationMock).toHaveBeenCalledOnce()
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-link-toolbar', 'false')
expect(state.capturedLinkToolbarProps).toEqual(expect.objectContaining({
linkToolbar: expect.any(Function),
floatingUIOptions: expect.objectContaining({
elementProps: expect.objectContaining({
onMouseDownCapture: expect.any(Function),
}),
}),
}))
const onMouseDownCapture = (
(state.capturedToolbarProps?.floatingUIOptions as { elementProps: { onMouseDownCapture: (event: { target: HTMLElement; preventDefault: () => void }) => void } })
@@ -297,6 +357,19 @@ describe('SingleEditorView', () => {
onMouseDownCapture({ target: normalTarget, preventDefault: normalPreventDefault })
expect(normalPreventDefault).toHaveBeenCalledOnce()
const linkToolbarMouseDownCapture = (
(state.capturedLinkToolbarProps?.floatingUIOptions as { elementProps: { onMouseDownCapture: (event: { target: HTMLElement; preventDefault: () => void }) => void } })
).elementProps.onMouseDownCapture
const linkInput = document.createElement('input')
const linkInputPreventDefault = vi.fn()
linkToolbarMouseDownCapture({ target: linkInput, preventDefault: linkInputPreventDefault })
expect(linkInputPreventDefault).not.toHaveBeenCalled()
const linkActionTarget = document.createElement('button')
const linkActionPreventDefault = vi.fn()
linkToolbarMouseDownCapture({ target: linkActionTarget, preventDefault: linkActionPreventDefault })
expect(linkActionPreventDefault).toHaveBeenCalledOnce()
const onWikiItemClick = vi.fn()
const onMentionItemClick = vi.fn()
;(state.capturedSuggestionProps['[['].onItemClick as (item: { onItemClick: () => void }) => void)({ onItemClick: onWikiItemClick })
@@ -306,6 +379,22 @@ describe('SingleEditorView', () => {
expect(onMentionItemClick).toHaveBeenCalledOnce()
})
it('passes the active document theme to BlockNote', () => {
document.documentElement.setAttribute('data-theme', 'dark')
document.documentElement.classList.add('dark')
render(
<SingleEditorView
editor={createEditor() as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('theme', 'dark')
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-mantine-color-scheme', 'dark')
})
it('defers rich-editor change propagation until IME composition ends', async () => {
const editor = createEditor()
const onChange = vi.fn()
@@ -334,4 +423,102 @@ describe('SingleEditorView', () => {
expect(onChange).toHaveBeenCalledTimes(1)
})
it('routes clicks on the empty title wrapper back into the H1 block', async () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
const titleBlockOuter = document.createElement('div')
titleBlockOuter.className = 'bn-block-outer'
const titleBlock = document.createElement('div')
titleBlock.className = 'bn-block'
const titleHeading = document.createElement('div')
titleHeading.setAttribute('data-content-type', 'heading')
titleHeading.setAttribute('data-level', '1')
const inlineHeading = document.createElement('div')
inlineHeading.className = 'bn-inline-content'
titleHeading.appendChild(inlineHeading)
titleBlock.appendChild(titleHeading)
titleBlockOuter.appendChild(titleBlock)
container?.appendChild(titleBlockOuter)
fireEvent.click(titleBlockOuter)
await act(async () => {
await Promise.resolve()
})
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('heading-block', 'end')
expect(editor.focus).toHaveBeenCalled()
})
it('ignores editor-container click handling for link toolbar interactions', () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
const linkToolbar = document.createElement('div')
linkToolbar.className = 'bn-link-toolbar'
const linkAction = document.createElement('button')
linkAction.type = 'button'
linkAction.textContent = 'Open in a new tab'
linkToolbar.appendChild(linkAction)
container?.appendChild(linkToolbar)
fireEvent.click(linkAction)
expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
expect(editor.focus).not.toHaveBeenCalled()
})
it('routes the custom link-toolbar open action through openExternalUrl', () => {
render(
<SingleEditorView
editor={createEditor() as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const LinkToolbarComponent = state.capturedLinkToolbarProps?.linkToolbar as React.ComponentType<{
url: string
text: string
range: { from: number; to: number }
setToolbarOpen?: (open: boolean) => void
setToolbarPositionFrozen?: (open: boolean) => void
}>
render(
<LinkToolbarComponent
url="https://example.com/docs"
text="Example"
range={{ from: 1, to: 8 }}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Open in a new tab' }))
expect(mockOpenExternalUrl).toHaveBeenCalledWith('https://example.com/docs')
})
})

View File

@@ -5,16 +5,26 @@ import {
SuggestionMenuController,
BlockNoteViewRaw,
ComponentsContext,
DeleteLinkButton,
EditLinkButton,
LinkToolbar,
LinkToolbarController,
SideMenuController,
useComponentsContext,
useDictionary,
type LinkToolbarProps,
} from '@blocknote/react'
import { components } from '@blocknote/mantine'
import { MantineContext, MantineProvider } from '@mantine/core'
import { ExternalLink } from 'lucide-react'
import { useDocumentThemeMode } from '../hooks/useDocumentThemeMode'
import { useEditorTheme } from '../hooks/useTheme'
import { useImageDrop } from '../hooks/useImageDrop'
import { buildTypeEntryMap } from '../utils/typeColors'
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from '../utils/personMentionSuggestions'
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
import { openExternalUrl } from '../utils/url'
import { WikilinkSuggestionMenu, type WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
import type { VaultEntry } from '../types'
import { _wikilinkEntriesRef } from './editorSchema'
@@ -32,6 +42,22 @@ const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 |
| A | B | C |
| D | E | F |
`
const CONTAINER_CLICK_IGNORE_SELECTOR = [
'[contenteditable="true"]',
'.bn-formatting-toolbar',
'.bn-link-toolbar',
'.bn-form-popover',
'[role="menu"]',
'[role="dialog"]',
].join(', ')
const TOOLBAR_MOUSE_DOWN_ALLOW_SELECTOR = [
'[role="menu"]',
'[role="dialog"]',
'button[aria-haspopup]',
'input',
'textarea',
'[contenteditable="true"]',
].join(', ')
type TestTableBlock = {
type?: string
@@ -68,6 +94,60 @@ function SharedContextBlockNoteView(props: React.ComponentProps<typeof BlockNote
)
}
function shouldAllowToolbarMouseDown(target: HTMLElement) {
return Boolean(target.closest(TOOLBAR_MOUSE_DOWN_ALLOW_SELECTOR))
}
function handleToolbarMouseDownCapture(
event: Pick<React.MouseEvent<HTMLElement>, 'target' | 'preventDefault'>,
) {
if (!(event.target instanceof HTMLElement) || shouldAllowToolbarMouseDown(event.target)) {
return
}
event.preventDefault()
}
function TolariaOpenLinkButton({ url }: Pick<LinkToolbarProps, 'url'>) {
const Components = useComponentsContext()!
const dict = useDictionary()
const handleOpen = useCallback(() => {
void openExternalUrl(url).catch((error) => {
console.warn('[link] Failed to open URL from toolbar:', error)
})
}, [url])
return (
<Components.LinkToolbar.Button
className="bn-button"
label={dict.link_toolbar.open.tooltip}
mainTooltip={dict.link_toolbar.open.tooltip}
isSelected={false}
onClick={handleOpen}
icon={<ExternalLink size={16} />}
/>
)
}
function TolariaLinkToolbar(props: LinkToolbarProps) {
return (
<LinkToolbar {...props}>
<EditLinkButton
url={props.url}
text={props.text}
range={props.range}
setToolbarOpen={props.setToolbarOpen}
setToolbarPositionFrozen={props.setToolbarPositionFrozen}
/>
<TolariaOpenLinkButton url={props.url} />
<DeleteLinkButton
range={props.range}
setToolbarOpen={props.setToolbarOpen}
/>
</LinkToolbar>
)
}
function applySeededColumnWidths(
parsedBlocks: Array<TestTableBlock>,
columnWidths?: Array<number | null>,
@@ -121,11 +201,7 @@ function useSeedBlockNoteTableBridge(editor: ReturnType<typeof useCreateBlockNot
}
function shouldIgnoreContainerClick(target: HTMLElement) {
return Boolean(
target.closest(
'[contenteditable="true"], .bn-formatting-toolbar, [role="menu"], [role="dialog"]',
),
)
return Boolean(target.closest(CONTAINER_CLICK_IGNORE_SELECTOR))
}
function normalizeSuggestionQuery(query: string, triggerCharacter: string): string {
@@ -141,13 +217,22 @@ function isSelectionInsideElement(element: HTMLElement): boolean {
return Boolean(anchorElement && element.contains(anchorElement))
}
const TITLE_HEADING_SELECTOR = 'h1, [data-content-type="heading"][data-level="1"], [data-content-type="heading"]:not([data-level])'
const TITLE_HEADING_WRAPPER_SELECTOR = '.bn-block-outer, .bn-block'
function findTitleHeadingElement(target: HTMLElement): HTMLElement | null {
const directHeading = target.closest<HTMLElement>(TITLE_HEADING_SELECTOR)
if (directHeading) return directHeading
const titleWrapper = target.closest<HTMLElement>(TITLE_HEADING_WRAPPER_SELECTOR)
return titleWrapper?.querySelector<HTMLElement>(TITLE_HEADING_SELECTOR) ?? null
}
function queueTitleHeadingCursorRepair(
target: HTMLElement,
editor: ReturnType<typeof useCreateBlockNote>,
): boolean {
const titleHeading = target.closest<HTMLElement>(
'h1, [data-content-type="heading"][data-level="1"], [data-content-type="heading"]:not([data-level])',
)
const titleHeading = findTitleHeadingElement(target)
if (!titleHeading) return false
queueMicrotask(() => {
@@ -322,6 +407,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
editable?: boolean
}) {
const { cssVars } = useEditorTheme()
const themeMode = useDocumentThemeMode()
const containerRef = useRef<HTMLDivElement>(null)
const handleContainerClick = useEditorContainerClickHandler({ editable, editor })
const handleEditorChange = useCompositionAwareEditorChange({ containerRef, onChange })
@@ -360,10 +446,11 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
)}
<SharedContextBlockNoteView
editor={editor}
theme="light"
theme={themeMode}
onChange={handleEditorChange}
editable={editable}
formattingToolbar={false}
linkToolbar={false}
slashMenu={false}
sideMenu={false}
>
@@ -372,17 +459,15 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
formattingToolbar={TolariaFormattingToolbar}
floatingUIOptions={{
elementProps: {
onMouseDownCapture: (event) => {
const target = event.target as HTMLElement
if (
target.closest(
'[role="menu"], [role="dialog"], button[aria-haspopup]',
)
) {
return
}
event.preventDefault()
},
onMouseDownCapture: handleToolbarMouseDownCapture,
},
}}
/>
<LinkToolbarController
linkToolbar={TolariaLinkToolbar}
floatingUIOptions={{
elementProps: {
onMouseDownCapture: handleToolbarMouseDownCapture,
},
}}
/>

View File

@@ -71,19 +71,52 @@ describe('StatusBar', () => {
expect(screen.queryByText('main')).not.toBeInTheDocument()
})
it('shows Feedback button when callback is provided', () => {
it('shows Contribute button when callback is provided', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenFeedback={vi.fn()} />)
expect(screen.getByTestId('status-feedback')).toBeInTheDocument()
expect(screen.getByText('Feedback')).toBeInTheDocument()
expect(screen.getByText('Contribute')).toBeInTheDocument()
})
it('calls onOpenFeedback when Feedback is clicked', () => {
it('calls onOpenFeedback when Contribute is clicked', () => {
const onOpenFeedback = vi.fn()
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenFeedback={onOpenFeedback} />)
fireEvent.click(screen.getByTestId('status-feedback'))
expect(onOpenFeedback).toHaveBeenCalledOnce()
})
it('shows a theme toggle instead of the notifications placeholder', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
themeMode="light"
onToggleThemeMode={vi.fn()}
/>,
)
expect(screen.getByTestId('status-theme-mode')).toHaveAccessibleName('Switch to dark mode')
expect(screen.queryByLabelText('Notifications are coming soon')).not.toBeInTheDocument()
})
it('calls onToggleThemeMode from the bottom bar', () => {
const onToggleThemeMode = vi.fn()
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
themeMode="dark"
onToggleThemeMode={onToggleThemeMode}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Switch to light mode' }))
expect(onToggleThemeMode).toHaveBeenCalledOnce()
})
it('displays active vault name', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
expect(screen.getByText('Main Vault')).toBeInTheDocument()

View File

@@ -3,6 +3,7 @@ import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
import { useEffect, useState } from 'react'
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../hooks/useMcpStatus'
import type { ThemeMode } from '../lib/themeMode'
import type { GitRemoteStatus, SyncStatus } from '../types'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
@@ -37,7 +38,9 @@ interface StatusBarProps {
onPullAndPush?: () => void
onOpenConflictResolver?: () => void
zoomLevel?: number
themeMode?: ThemeMode
onZoomReset?: () => void
onToggleThemeMode?: () => void
onOpenFeedback?: () => void
buildNumber?: string
onCheckForUpdates?: () => void
@@ -77,7 +80,9 @@ export function StatusBar({
onPullAndPush,
onOpenConflictResolver,
zoomLevel = 100,
themeMode = 'light',
onZoomReset,
onToggleThemeMode,
onOpenFeedback,
buildNumber,
onCheckForUpdates,
@@ -154,7 +159,9 @@ export function StatusBar({
<StatusBarSecondarySection
noteCount={noteCount}
zoomLevel={zoomLevel}
themeMode={themeMode}
onZoomReset={onZoomReset}
onToggleThemeMode={onToggleThemeMode}
onOpenFeedback={onOpenFeedback}
onOpenSettings={onOpenSettings}
/>

View File

@@ -1,7 +1,17 @@
import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo } from 'react'
import { useState, useRef, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { getStatusStyle, SUGGESTED_STATUSES, setStatusColor, getStatusColorKey } from '../utils/statusStyles'
import { ACCENT_COLORS } from '../utils/typeColors'
import {
getNextHighlightIndex,
getPreviousHighlightIndex,
isCreateOptionVisible,
useAnchoredDropdownPosition,
useAutoFocus,
} from './propertyDropdownUtils'
const PROPERTY_DROPDOWN_WIDTH = 208
const SELECTED_SWATCH_CHECK_STYLE = { color: 'var(--text-inverse)', fontSize: 8, lineHeight: 1 } as const
export function StatusPill({ status, className }: { status: string; className?: string }) {
const style = getStatusStyle(status)
@@ -40,7 +50,7 @@ function ColorPickerRow({ status, onColorChange }: { status: string; onColorChan
data-testid={`color-option-${c.key}`}
>
{currentKey === c.key && (
<span style={{ color: 'white', fontSize: 8, lineHeight: 1 }}>{'\u2713'}</span>
<span style={SELECTED_SWATCH_CHECK_STYLE}>{'\u2713'}</span>
)}
</button>
))}
@@ -162,6 +172,25 @@ interface KeyboardNavOptions {
listRef: React.RefObject<HTMLDivElement | null>
}
interface StatusSelectionOptions {
highlightIndex: number
allFiltered: string[]
showCreateOption: boolean
query: string
}
function getStatusValueToSave({
highlightIndex,
allFiltered,
showCreateOption,
query,
}: StatusSelectionOptions) {
const trimmed = query.trim()
if (highlightIndex >= 0 && highlightIndex < allFiltered.length) return allFiltered[highlightIndex]
if (showCreateOption && highlightIndex === allFiltered.length) return trimmed
return trimmed || null
}
function useStatusKeyboard(opts: KeyboardNavOptions) {
const { allFiltered, totalOptions, showCreateOption, query, onSave, onCancel, listRef } = opts
const [highlightIndex, setHighlightIndex] = useState(-1)
@@ -173,29 +202,32 @@ function useStatusKeyboard(opts: KeyboardNavOptions) {
items[index]?.scrollIntoView({ block: 'nearest' })
}, [listRef])
const moveHighlight = useCallback((nextIndex: number) => {
setHighlightIndex(nextIndex)
scrollIntoView(nextIndex)
}, [scrollIntoView])
const submitHighlightedStatus = useCallback(() => {
const value = getStatusValueToSave({ highlightIndex, allFiltered, showCreateOption, query })
if (value) onSave(value)
}, [highlightIndex, allFiltered, showCreateOption, query, onSave])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0
setHighlightIndex(next)
scrollIntoView(next)
moveHighlight(getNextHighlightIndex(highlightIndex, totalOptions))
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1
setHighlightIndex(prev)
scrollIntoView(prev)
moveHighlight(getPreviousHighlightIndex(highlightIndex, totalOptions))
break
}
case 'Enter': {
e.preventDefault()
const trimmed = query.trim()
if (highlightIndex >= 0 && highlightIndex < allFiltered.length) onSave(allFiltered[highlightIndex])
else if (showCreateOption && highlightIndex === allFiltered.length) onSave(trimmed)
else if (trimmed) onSave(trimmed)
submitHighlightedStatus()
break
}
case 'Escape':
@@ -204,7 +236,7 @@ function useStatusKeyboard(opts: KeyboardNavOptions) {
break
}
},
[highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, scrollIntoView],
[highlightIndex, totalOptions, moveHighlight, submitHighlightedStatus, onCancel],
)
const resetHighlight = useCallback(() => setHighlightIndex(-1), [])
@@ -226,31 +258,15 @@ export function StatusDropdown({
const [colorEditingStatus, setColorEditingStatus] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLSpanElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const node = dropdownRef.current
if (!node) return
const anchor = anchorRef.current?.parentElement
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const dropW = 208
let left = rect.right - dropW
if (left < 8) left = 8
if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [])
useEffect(() => { inputRef.current?.focus() }, [])
useAnchoredDropdownPosition({ anchorRef, dropdownRef, width: PROPERTY_DROPDOWN_WIDTH })
useAutoFocus(inputRef)
const { suggestedFiltered, vaultFiltered, allFiltered } = useStatusFiltering(query, vaultStatuses)
const showCreateOption = useMemo(() => {
if (!query.trim()) return false
return !allFiltered.some(s => s.toLowerCase() === query.trim().toLowerCase())
}, [query, allFiltered])
const showCreateOption = useMemo(() => isCreateOptionVisible(query, allFiltered), [query, allFiltered])
const totalOptions = allFiltered.length + (showCreateOption ? 1 : 0)

View File

@@ -1,7 +1,17 @@
import { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo } from 'react'
import { useState, useRef, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { getTagStyle, setTagColor, getTagColorKey } from '../utils/tagStyles'
import { ACCENT_COLORS } from '../utils/typeColors'
import {
getNextHighlightIndex,
getPreviousHighlightIndex,
isCreateOptionVisible,
useAnchoredDropdownPosition,
useAutoFocus,
} from './propertyDropdownUtils'
const PROPERTY_DROPDOWN_WIDTH = 208
const SELECTED_SWATCH_CHECK_STYLE = { color: 'var(--text-inverse)', fontSize: 8, lineHeight: 1 } as const
export function TagPill({ tag, className }: { tag: string; className?: string }) {
const style = getTagStyle(tag)
@@ -40,7 +50,7 @@ function ColorPickerRow({ tag, onColorChange }: { tag: string; onColorChange: (t
data-testid={`tag-color-option-${c.key}`}
>
{currentKey === c.key && (
<span style={{ color: 'white', fontSize: 8, lineHeight: 1 }}>{'\u2713'}</span>
<span style={SELECTED_SWATCH_CHECK_STYLE}>{'\u2713'}</span>
)}
</button>
))}
@@ -111,6 +121,28 @@ function useTagFiltering(query: string, vaultTags: string[]) {
}, [query, vaultTags])
}
interface TagSelectionOptions {
highlightIndex: number
filtered: string[]
showCreateOption: boolean
query: string
selectedTags: Set<string>
}
function getTagValueToToggle({
highlightIndex,
filtered,
showCreateOption,
query,
selectedTags,
}: TagSelectionOptions) {
const trimmed = query.trim()
if (highlightIndex >= 0 && highlightIndex < filtered.length) return filtered[highlightIndex]
if (showCreateOption && highlightIndex === filtered.length && trimmed) return trimmed
if (trimmed && !selectedTags.has(trimmed)) return trimmed
return null
}
function useTagKeyboard(opts: {
filtered: string[]; totalOptions: number; showCreateOption: boolean
query: string; selectedTags: Set<string>
@@ -127,33 +159,32 @@ function useTagKeyboard(opts: {
items[index]?.scrollIntoView({ block: 'nearest' })
}, [listRef])
const moveHighlight = useCallback((nextIndex: number) => {
setHighlightIndex(nextIndex)
scrollIntoView(nextIndex)
}, [scrollIntoView])
const submitHighlightedTag = useCallback(() => {
const value = getTagValueToToggle({ highlightIndex, filtered, showCreateOption, query, selectedTags })
if (value) onToggle(value)
}, [highlightIndex, filtered, showCreateOption, query, selectedTags, onToggle])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0
setHighlightIndex(next)
scrollIntoView(next)
moveHighlight(getNextHighlightIndex(highlightIndex, totalOptions))
break
}
case 'ArrowUp': {
e.preventDefault()
const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1
setHighlightIndex(prev)
scrollIntoView(prev)
moveHighlight(getPreviousHighlightIndex(highlightIndex, totalOptions))
break
}
case 'Enter': {
e.preventDefault()
const trimmed = query.trim()
if (highlightIndex >= 0 && highlightIndex < filtered.length) {
onToggle(filtered[highlightIndex])
} else if (showCreateOption && highlightIndex === filtered.length && trimmed) {
onToggle(trimmed)
} else if (trimmed && !selectedTags.has(trimmed)) {
onToggle(trimmed)
}
submitHighlightedTag()
break
}
case 'Escape':
@@ -162,7 +193,7 @@ function useTagKeyboard(opts: {
break
}
},
[highlightIndex, totalOptions, filtered, showCreateOption, query, selectedTags, onToggle, onClose, scrollIntoView],
[highlightIndex, totalOptions, moveHighlight, submitHighlightedTag, onClose],
)
const resetHighlight = useCallback(() => setHighlightIndex(-1), [])
@@ -180,33 +211,17 @@ export function TagsDropdown({
const [colorEditingTag, setColorEditingTag] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLSpanElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const selectedSet = useMemo(() => new Set(selectedTags), [selectedTags])
useLayoutEffect(() => {
const node = dropdownRef.current
if (!node) return
const anchor = anchorRef.current?.parentElement
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const dropW = 208
let left = rect.right - dropW
if (left < 8) left = 8
if (left + dropW > window.innerWidth - 8) left = window.innerWidth - dropW - 8
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [])
useEffect(() => { inputRef.current?.focus() }, [])
useAnchoredDropdownPosition({ anchorRef, dropdownRef, width: PROPERTY_DROPDOWN_WIDTH })
useAutoFocus(inputRef)
const { filtered } = useTagFiltering(query, vaultTags)
const showCreateOption = useMemo(() => {
if (!query.trim()) return false
return !filtered.some(t => t.toLowerCase() === query.trim().toLowerCase())
}, [query, filtered])
const showCreateOption = useMemo(() => isCreateOptionVisible(query, filtered), [query, filtered])
const totalOptions = filtered.length + (showCreateOption ? 1 : 0)
@@ -250,46 +265,25 @@ export function TagsDropdown({
/>
</div>
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
{filtered.length > 0 && (
<div>
<SectionLabel>From vault</SectionLabel>
{filtered.map((tag, i) => (
<TagOption
key={tag} tag={tag}
selected={selectedSet.has(tag)}
highlighted={highlightIndex === i}
onToggle={onToggle}
onMouseEnter={() => setHighlightIndex(i)}
colorEditing={colorEditingTag === tag}
onToggleColor={handleToggleColor}
onColorChange={handleColorChange}
/>
))}
</div>
)}
{showCreateOption && (
<>
{filtered.length > 0 && <div className="my-1 h-px bg-border" />}
<button
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
style={{
borderRadius: 4,
backgroundColor: highlightIndex === filtered.length ? 'var(--muted)' : 'transparent',
color: 'var(--muted-foreground)',
}}
onClick={() => onToggle(query.trim())}
onMouseEnter={() => setHighlightIndex(filtered.length)}
data-testid="tag-create-option"
>
Create <TagPill tag={query.trim()} />
</button>
</>
)}
{filtered.length === 0 && !showCreateOption && (
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
No matching tags
</div>
)}
<VaultTagSection
tags={filtered}
selectedTags={selectedSet}
highlightIndex={highlightIndex}
colorEditingTag={colorEditingTag}
onToggle={onToggle}
onHighlight={setHighlightIndex}
onToggleColor={handleToggleColor}
onColorChange={handleColorChange}
/>
<CreateTagSection
show={showCreateOption}
query={query}
showDivider={filtered.length > 0}
highlighted={highlightIndex === filtered.length}
onToggle={onToggle}
onMouseEnter={() => setHighlightIndex(filtered.length)}
/>
<EmptyTagMessage show={filtered.length === 0 && !showCreateOption} />
</div>
</div>
</>,
@@ -298,3 +292,80 @@ export function TagsDropdown({
</span>
)
}
interface VaultTagSectionProps {
tags: string[]
selectedTags: Set<string>
highlightIndex: number
colorEditingTag: string | null
onToggle: (tag: string) => void
onHighlight: (index: number) => void
onToggleColor: (tag: string) => void
onColorChange: (tag: string, colorKey: string) => void
}
function VaultTagSection({
tags,
selectedTags,
highlightIndex,
colorEditingTag,
onToggle,
onHighlight,
onToggleColor,
onColorChange,
}: VaultTagSectionProps) {
if (tags.length === 0) return null
return (
<div>
<SectionLabel>From vault</SectionLabel>
{tags.map((tag, i) => (
<TagOption
key={tag}
tag={tag}
selected={selectedTags.has(tag)}
highlighted={highlightIndex === i}
onToggle={onToggle}
onMouseEnter={() => onHighlight(i)}
colorEditing={colorEditingTag === tag}
onToggleColor={onToggleColor}
onColorChange={onColorChange}
/>
))}
</div>
)
}
function CreateTagSection({ show, query, showDivider, highlighted, onToggle, onMouseEnter }: {
show: boolean; query: string; showDivider: boolean; highlighted: boolean
onToggle: (tag: string) => void; onMouseEnter: () => void
}) {
if (!show) return null
const trimmed = query.trim()
return (
<>
{showDivider && <div className="my-1 h-px bg-border" />}
<button
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
style={{
borderRadius: 4,
backgroundColor: highlighted ? 'var(--muted)' : 'transparent',
color: 'var(--muted-foreground)',
}}
onClick={() => onToggle(trimmed)}
onMouseEnter={onMouseEnter}
data-testid="tag-create-option"
>
Create <TagPill tag={trimmed} />
</button>
</>
)
}
function EmptyTagMessage({ show }: { show: boolean }) {
if (!show) return null
return (
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
No matching tags
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { ShieldCheck } from '@phosphor-icons/react'
import { OnboardingShell } from './OnboardingShell'
import { Button } from './ui/button'
interface TelemetryConsentDialogProps {
onAccept: () => void
@@ -10,8 +11,8 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
return (
<OnboardingShell
className="fixed inset-0 z-50"
contentClassName="w-full rounded-lg border border-border bg-background shadow-xl"
style={{ background: 'rgba(0,0,0,0.4)' }}
contentClassName="w-full rounded-lg border border-border bg-background shadow-[0_18px_55px_var(--shadow-dialog)]"
style={{ background: 'var(--shadow-overlay)' }}
contentStyle={{
width: 'min(440px, 100%)',
padding: 32,
@@ -49,23 +50,24 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
</div>
<div style={{ display: 'flex', gap: 12, width: '100%', marginTop: 4 }}>
<button
className="border border-border bg-transparent text-foreground rounded cursor-pointer hover:bg-accent"
<Button
type="button"
variant="outline"
style={{ flex: 1, fontSize: 13, padding: '10px 16px' }}
onClick={onDecline}
data-testid="telemetry-decline"
autoFocus
>
No thanks
</button>
<button
className="border-none rounded cursor-pointer"
style={{ flex: 1, fontSize: 13, padding: '10px 16px', background: 'var(--primary)', color: 'white', fontWeight: 500 }}
</Button>
<Button
type="button"
style={{ flex: 1, fontSize: 13, padding: '10px 16px', fontWeight: 500 }}
onClick={onAccept}
data-testid="telemetry-accept"
>
Allow anonymous reporting
</button>
</Button>
</div>
<p style={{ fontSize: 11, color: 'var(--muted-foreground)', margin: 0, textAlign: 'center' }}>

View File

@@ -132,7 +132,7 @@ function MissingTypeWarning({
variant="ghost"
size="icon-xs"
className={cn(
'h-6 w-6 shrink-0 rounded-md border border-amber-300/80 bg-amber-50 p-0 text-amber-700 shadow-none hover:bg-amber-100 hover:text-amber-800',
'h-6 w-6 shrink-0 rounded-md border border-[var(--feedback-warning-border)] bg-[var(--feedback-warning-bg)] p-0 text-[var(--feedback-warning-text)] shadow-none hover:brightness-95',
!canCreateMissingType && 'cursor-default',
)}
data-testid="missing-type-warning"

View File

@@ -16,29 +16,29 @@ const bannerStyle = {
alignItems: 'center',
gap: 10,
padding: '6px 12px',
background: '#1a56db',
background: 'var(--accent-blue)',
borderBottom: 'none',
fontSize: 13,
color: '#fff',
color: 'var(--text-inverse)',
flexShrink: 0,
} satisfies CSSProperties
const iconStyle = {
color: '#fff',
color: 'var(--text-inverse)',
flexShrink: 0,
} satisfies CSSProperties
const primaryActionStyle = {
marginLeft: 'auto',
padding: '3px 10px',
background: 'var(--primary)',
color: '#fff',
background: 'var(--text-inverse)',
color: 'var(--accent-blue)',
fontSize: 12,
fontWeight: 500,
} satisfies CSSProperties
const dismissButtonStyle = {
color: '#fff',
color: 'var(--text-inverse)',
display: 'flex',
padding: 2,
} satisfies CSSProperties
@@ -47,18 +47,18 @@ const progressTrackStyle = {
flex: 1,
maxWidth: 200,
height: 4,
background: 'rgba(255,255,255,0.3)',
background: 'color-mix(in srgb, var(--text-inverse) 30%, transparent)',
borderRadius: 2,
overflow: 'hidden',
} satisfies CSSProperties
const progressTextStyle = {
fontSize: 11,
color: 'rgba(255,255,255,0.85)',
color: 'color-mix(in srgb, var(--text-inverse) 85%, transparent)',
} satisfies CSSProperties
const readyIconStyle = {
color: 'var(--accent-green, #0F7B0F)',
color: 'var(--accent-green)',
flexShrink: 0,
} satisfies CSSProperties
@@ -75,7 +75,7 @@ function renderAvailableContent(status: Extract<VisibleUpdateStatus, { state: 'a
size="xs"
data-testid="update-release-notes"
onClick={actions.openReleaseNotes}
style={{ color: '#fff', padding: 0, height: 'auto' }}
style={{ color: 'var(--text-inverse)', padding: 0, height: 'auto' }}
>
Release Notes <ExternalLink size={11} />
</Button>
@@ -114,7 +114,7 @@ function renderDownloadingContent(status: Extract<VisibleUpdateStatus, { state:
style={{
width: `${Math.round(status.progress * 100)}%`,
height: '100%',
background: 'var(--primary)',
background: 'var(--text-inverse)',
borderRadius: 2,
transition: 'width 0.2s ease',
}}
@@ -139,7 +139,8 @@ function renderReadyContent(status: Extract<VisibleUpdateStatus, { state: 'ready
onClick={restartApp}
style={{
...primaryActionStyle,
background: 'var(--accent-green, #0F7B0F)',
background: 'var(--accent-green)',
color: 'var(--text-inverse)',
}}
>
Restart Now

View File

@@ -175,7 +175,7 @@ const OPTION_DESC_STYLE: React.CSSProperties = {
const ERROR_STYLE: React.CSSProperties = {
fontSize: 13,
color: 'var(--destructive, #e03e3e)',
color: 'var(--destructive)',
textAlign: 'center',
margin: 0,
}
@@ -286,7 +286,7 @@ function getWelcomeScreenPresentation(
}
return {
heroBackground: 'var(--accent-yellow-light, #FFF3E0)',
heroBackground: 'var(--accent-yellow-light)',
heroIcon: <AlertTriangle size={28} style={{ color: 'var(--accent-orange)' }} />,
openFolderLabel: 'Choose a different folder',
subtitle: 'The vault folder could not be found on disk.\nIt may have been moved or deleted.',
@@ -419,7 +419,7 @@ export function WelcomeScreen({
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 10 }}>
<OptionButton
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
iconBg="var(--accent-purple-light, #F3E8FF)"
iconBg="var(--accent-purple-light)"
label="Get started with a template"
description={presentation.templateDescription}
loadingLabel="Downloading template…"
@@ -434,7 +434,7 @@ export function WelcomeScreen({
<OptionButton
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
iconBg="var(--accent-blue-light, #EBF4FF)"
iconBg="var(--accent-blue-light)"
label="Create empty vault"
description="Start fresh in an empty folder with Tolaria defaults"
loadingLabel="Creating vault…"
@@ -448,7 +448,7 @@ export function WelcomeScreen({
<OptionButton
icon={<FolderOpen size={18} style={{ color: 'var(--accent-green)' }} />}
iconBg="var(--accent-green-light, #E8F5E9)"
iconBg="var(--accent-green-light)"
label={presentation.openFolderLabel}
description="Point to a folder you already have"
onClick={onOpenFolder}

View File

@@ -1,9 +1,9 @@
/* Wikilink autocomplete — floating menu container (positioned by BlockNote) */
.wikilink-menu {
background: var(--bg-primary, #fff);
border: 1px solid var(--border, rgba(0, 0, 0, 0.1));
background: var(--popover);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
box-shadow: 0 4px 12px var(--shadow-dialog);
max-height: 320px;
overflow: hidden;
min-width: 260px;
@@ -24,7 +24,7 @@
.wikilink-menu__item:hover,
.wikilink-menu__item--selected {
background: hsl(var(--accent));
background: var(--accent);
}
.wikilink-menu__title {

View File

@@ -52,7 +52,7 @@ export function FolderItemRow({
className={cn(
'group relative flex items-center gap-1 rounded transition-colors',
isSelected
? 'bg-[var(--accent-blue-light,rgba(0,100,255,0.08))] text-primary'
? 'bg-[var(--accent-blue-light)] text-primary'
: 'text-foreground hover:bg-accent',
)}
style={{ paddingLeft: depthIndent, borderRadius: 4 }}

View File

@@ -11,11 +11,11 @@ type ChangeStatsEntry = VaultEntry & {
}
const CHANGE_STATUS_DISPLAY: Record<ChangeStatus, { label: string; color: string; symbol: string }> = {
modified: { label: 'Modified', color: 'var(--accent-orange, #f59e0b)', symbol: '·' },
added: { label: 'Added', color: 'var(--accent-green, #22c55e)', symbol: '+' },
untracked: { label: 'Added', color: 'var(--accent-green, #22c55e)', symbol: '+' },
deleted: { label: 'Deleted', color: 'var(--destructive, #ef4444)', symbol: '' },
renamed: { label: 'Renamed', color: 'var(--accent-orange, #f59e0b)', symbol: 'R' },
modified: { label: 'Modified', color: 'var(--accent-orange)', symbol: '·' },
added: { label: 'Added', color: 'var(--accent-green)', symbol: '+' },
untracked: { label: 'Added', color: 'var(--accent-green)', symbol: '+' },
deleted: { label: 'Deleted', color: 'var(--destructive)', symbol: '' },
renamed: { label: 'Renamed', color: 'var(--accent-orange)', symbol: 'R' },
}
function readChangeStats(entry: VaultEntry): Required<Pick<ChangeStatsEntry, '__changeAddedLines' | '__changeDeletedLines' | '__changeBinary'>> {
@@ -72,7 +72,7 @@ function buildChangeStatBadges(
if (shouldShowAddedStat(status, addedLines, deletedLines)) {
badges.push({
className: 'text-[var(--accent-green,#22c55e)]',
className: 'text-[var(--accent-green)]',
testId: 'change-stat-added',
value: `+${addedLines ?? 0}`,
})
@@ -80,7 +80,7 @@ function buildChangeStatBadges(
if (shouldShowDeletedStat(status, addedLines, deletedLines)) {
badges.push({
className: 'text-[var(--destructive,#ef4444)]',
className: 'text-[var(--destructive)]',
testId: 'change-stat-deleted',
value: `-${deletedLines ?? 0}`,
})

View File

@@ -87,7 +87,7 @@ async function handleChipClick(
return
}
await openExternalUrl(chip.action.url).catch(() => {})
await openExternalUrl(chip.action.url).catch((err) => console.warn('[link] Failed to open URL:', err))
}
export function PropertyChips({

View File

@@ -13,7 +13,7 @@ const PILLS: { value: NoteListFilter; label: string }[] = [
{ value: 'archived', label: 'Archived' },
]
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card, #fff) 30%, var(--card, #fff) 100%)'
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card) 30%, var(--card) 100%)'
function FilterPillsInner({ active, counts, onChange, position = 'top' }: FilterPillsProps) {
const isBottom = position === 'bottom'

View File

@@ -14,7 +14,7 @@ const PILLS: { value: InboxPeriod; label: string }[] = [
{ value: 'all', label: 'All' },
]
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card, #fff) 30%, var(--card, #fff) 100%)'
const BOTTOM_GRADIENT = 'linear-gradient(to bottom, transparent 0%, var(--card) 30%, var(--card) 100%)'
function InboxFilterPillsInner({ active, counts, onChange, position = 'top' }: InboxFilterPillsProps) {
const isBottom = position === 'bottom'

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import {
getAnchoredDropdownLeft,
getNextHighlightIndex,
getPreviousHighlightIndex,
isCreateOptionVisible,
} from './propertyDropdownUtils'
describe('propertyDropdownUtils', () => {
it('wraps highlight movement across the available option range', () => {
expect(getNextHighlightIndex(-1, 3)).toBe(0)
expect(getNextHighlightIndex(2, 3)).toBe(0)
expect(getPreviousHighlightIndex(0, 3)).toBe(2)
expect(getPreviousHighlightIndex(-1, 0)).toBe(-1)
})
it('hides create options for blank or duplicate values', () => {
expect(isCreateOptionVisible('', ['Draft'])).toBe(false)
expect(isCreateOptionVisible(' draft ', ['Draft'])).toBe(false)
expect(isCreateOptionVisible('Review', ['Draft'])).toBe(true)
})
it('keeps an anchored dropdown within the viewport margins', () => {
expect(getAnchoredDropdownLeft(300, 208, 800)).toBe(92)
expect(getAnchoredDropdownLeft(100, 208, 800)).toBe(8)
expect(getAnchoredDropdownLeft(900, 208, 800)).toBe(584)
})
})

View File

@@ -0,0 +1,57 @@
import { useEffect, useLayoutEffect, type RefObject } from 'react'
const DEFAULT_DROPDOWN_MARGIN = 8
export function getAnchoredDropdownLeft(
anchorRight: number,
dropdownWidth: number,
viewportWidth: number,
margin = DEFAULT_DROPDOWN_MARGIN,
) {
const rightAlignedLeft = anchorRight - dropdownWidth
const minLeft = margin
const maxLeft = viewportWidth - dropdownWidth - margin
return Math.min(Math.max(rightAlignedLeft, minLeft), maxLeft)
}
export function getNextHighlightIndex(current: number, total: number) {
if (total <= 0) return 0
return current < total - 1 ? current + 1 : 0
}
export function getPreviousHighlightIndex(current: number, total: number) {
if (total <= 0) return -1
return current > 0 ? current - 1 : total - 1
}
export function isCreateOptionVisible(query: string, options: string[]) {
const trimmed = query.trim()
if (!trimmed) return false
return !options.some((option) => option.toLowerCase() === trimmed.toLowerCase())
}
export function useAnchoredDropdownPosition({
anchorRef,
dropdownRef,
width,
}: {
anchorRef: RefObject<HTMLElement | null>
dropdownRef: RefObject<HTMLElement | null>
width: number
}) {
useLayoutEffect(() => {
const node = dropdownRef.current
const anchor = anchorRef.current?.parentElement
if (!node || !anchor) return
const rect = anchor.getBoundingClientRect()
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${getAnchoredDropdownLeft(rect.right, width, window.innerWidth)}px`
}, [anchorRef, dropdownRef, width])
}
export function useAutoFocus<T extends HTMLElement>(ref: RefObject<T | null>) {
useEffect(() => {
ref.current?.focus()
}, [ref])
}

View File

@@ -254,7 +254,7 @@ function GitStatusPopup({
borderRadius: 6,
padding: 8,
minWidth: 220,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
boxShadow: '0 4px 12px var(--shadow-dialog)',
zIndex: 1000,
fontSize: 12,
color: 'var(--foreground)',
@@ -310,8 +310,8 @@ export function OfflineBadge({ isOffline }: { isOffline?: boolean }) {
<span
style={{
...ICON_STYLE,
color: 'var(--destructive, #e03e3e)',
background: 'rgba(224, 62, 62, 0.12)',
color: 'var(--destructive)',
background: 'var(--feedback-error-bg)',
borderRadius: 999,
padding: '2px 6px',
fontWeight: 600,
@@ -443,7 +443,7 @@ export function ConflictBadge({ count, onClick }: { count: number; onClick?: ()
copy={{ label: 'Resolve merge conflicts' }}
onClick={onClick}
testId="status-conflict-count"
className="text-[var(--destructive,#e03e3e)]"
className="text-[var(--destructive)]"
>
<span style={ICON_STYLE}>
<AlertTriangle size={13} />
@@ -469,7 +469,7 @@ export function ChangesBadge({ count, onClick }: { count: number; onClick?: () =
alignItems: 'center',
justifyContent: 'center',
background: 'var(--accent-orange)',
color: '#fff',
color: 'var(--text-inverse)',
borderRadius: 9,
padding: '0 5px',
fontSize: 10,

View File

@@ -1,11 +1,13 @@
import { Bell, Package, Settings } from 'lucide-react'
import { Moon, Package, Settings, Sun } from 'lucide-react'
import { Megaphone } from '@phosphor-icons/react'
import type { AiAgentId, AiAgentsStatus } from '../../lib/aiAgents'
import type { VaultAiGuidanceStatus } from '../../lib/vaultAiGuidance'
import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus'
import type { McpStatus } from '../../hooks/useMcpStatus'
import type { ThemeMode } from '../../lib/themeMode'
import { useStatusBarAddRemote } from '../../hooks/useStatusBarAddRemote'
import type { GitRemoteStatus, SyncStatus } from '../../types'
import { rememberFeedbackDialogOpener } from '../../lib/feedbackDialogOpener'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { AiAgentsBadge } from './AiAgentsBadge'
import { AddRemoteModal } from '../AddRemoteModal'
@@ -21,15 +23,23 @@ import {
PulseBadge,
SyncBadge,
} from './StatusBarBadges'
import { DISABLED_STYLE, ICON_STYLE, SEP_STYLE } from './styles'
import { ICON_STYLE, SEP_STYLE } from './styles'
import type { VaultOption } from './types'
import { VaultMenu } from './VaultMenu'
import { formatShortcutDisplay } from '../../hooks/appCommandCatalog'
const UPDATE_TOOLTIP = { label: 'Check for updates' } as const
const ZOOM_RESET_TOOLTIP = { label: 'Reset the zoom level', shortcut: '⌘0' } as const
const FEEDBACK_TOOLTIP = { label: 'Share feedback' } as const
const NOTIFICATIONS_TOOLTIP = { label: 'Notifications are coming soon' } as const
const SETTINGS_TOOLTIP = { label: 'Open settings', shortcut: '⌘,' } as const
const ZOOM_RESET_TOOLTIP = {
label: 'Reset the zoom level',
shortcut: formatShortcutDisplay({ display: '⌘0' }),
} as const
const FEEDBACK_TOOLTIP = { label: 'Contribute to Tolaria' } as const
const LIGHT_MODE_TOOLTIP = { label: 'Switch to light mode' } as const
const DARK_MODE_TOOLTIP = { label: 'Switch to dark mode' } as const
const SETTINGS_TOOLTIP = {
label: 'Open settings',
shortcut: formatShortcutDisplay({ display: '⌘,' }),
} as const
interface StatusBarPrimarySectionProps {
modifiedCount: number
@@ -70,7 +80,9 @@ interface StatusBarPrimarySectionProps {
interface StatusBarSecondarySectionProps {
noteCount: number
zoomLevel: number
themeMode?: ThemeMode
onZoomReset?: () => void
onToggleThemeMode?: () => void
onOpenFeedback?: () => void
onOpenSettings?: () => void
}
@@ -197,11 +209,15 @@ export function StatusBarPrimarySection({
export function StatusBarSecondarySection({
noteCount,
zoomLevel,
themeMode = 'light',
onZoomReset,
onToggleThemeMode,
onOpenFeedback,
onOpenSettings,
}: StatusBarSecondarySectionProps) {
void noteCount
const ThemeIcon = themeMode === 'dark' ? Sun : Moon
const themeTooltip = themeMode === 'dark' ? LIGHT_MODE_TOOLTIP : DARK_MODE_TOOLTIP
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
@@ -227,19 +243,31 @@ export function StatusBarSecondarySection({
variant="ghost"
size="xs"
className="h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
onClick={onOpenFeedback}
onClick={(event) => {
rememberFeedbackDialogOpener(event.currentTarget)
onOpenFeedback()
}}
aria-label={FEEDBACK_TOOLTIP.label}
data-testid="status-feedback"
>
<Megaphone size={14} />
Feedback
Contribute
</Button>
</ActionTooltip>
)}
<ActionTooltip copy={NOTIFICATIONS_TOOLTIP} side="top">
<span style={DISABLED_STYLE} aria-label={NOTIFICATIONS_TOOLTIP.label}>
<Bell size={14} />
</span>
<ActionTooltip copy={themeTooltip} side="top">
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:bg-[var(--hover)] hover:text-foreground"
onClick={onToggleThemeMode}
disabled={!onToggleThemeMode}
aria-label={themeTooltip.label}
data-testid="status-theme-mode"
>
<ThemeIcon size={14} />
</Button>
</ActionTooltip>
<ActionTooltip copy={SETTINGS_TOOLTIP} side="top" align="end">
<Button

View File

@@ -229,7 +229,7 @@ export function VaultMenu({
borderRadius: 6,
padding: 4,
minWidth: 200,
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
boxShadow: '0 4px 12px var(--shadow-dialog)',
zIndex: 1000,
}}
>

View File

@@ -13,7 +13,7 @@ const badgeVariants = cva(
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",

View File

@@ -11,7 +11,7 @@ const buttonVariants = cva(
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:

View File

@@ -34,6 +34,14 @@ function appendWikilink(container: HTMLElement, target: string) {
return wikilink
}
function appendEditableWikilink(container: HTMLElement, target: string) {
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
const wikilink = appendWikilink(editable, target)
container.appendChild(editable)
return { editable, wikilink }
}
function appendUrl(container: HTMLElement, href: string) {
const link = document.createElement('a')
link.setAttribute('href', href)
@@ -61,6 +69,19 @@ describe('useEditorLinkActivation', () => {
expect(onNavigateWikilink).toHaveBeenCalledWith('Alpha Project')
})
it('blurs an active editor before navigating a Cmd-clicked wikilink', () => {
const { container, onNavigateWikilink } = renderHarness()
const { editable, wikilink } = appendEditableWikilink(container, 'Alpha Project')
editable.focus()
expect(document.activeElement).toBe(editable)
fireEvent.click(wikilink, { metaKey: true })
expect(onNavigateWikilink).toHaveBeenCalledWith('Alpha Project')
expect(document.activeElement).not.toBe(editable)
})
it('opens URLs only on Cmd+click', () => {
const { container } = renderHarness()
const link = appendUrl(container, 'https://example.com')

View File

@@ -21,6 +21,13 @@ function resolveUrlTarget(target: HTMLElement) {
return normalizeUrl(href)
}
function blurActiveEditable(container: HTMLElement) {
const active = document.activeElement
if (!(active instanceof HTMLElement) || !container.contains(active)) return
const editable = active.isContentEditable ? active : active.closest<HTMLElement>('[contenteditable="true"]')
editable?.blur()
}
function setFollowLinksActive(container: HTMLElement, active: boolean) {
if (active) container.setAttribute('data-follow-links', '')
else container.removeAttribute('data-follow-links')
@@ -49,6 +56,7 @@ export function useEditorLinkActivation(
if (wikilinkTarget) {
event.preventDefault()
event.stopPropagation()
blurActiveEditable(container)
onNavigateWikilink(wikilinkTarget)
return
}
@@ -58,7 +66,7 @@ export function useEditorLinkActivation(
event.preventDefault()
event.stopPropagation()
openExternalUrl(urlTarget).catch(() => {})
openExternalUrl(urlTarget).catch((err) => console.warn('[link] Failed to open URL:', err))
}
container.addEventListener('click', handleClick, true)

View File

@@ -1 +1,5 @@
export const TOLARIA_PRODUCT_BOARD_URL = 'https://tolaria.canny.io/'
export const TOLARIA_GITHUB_DISCUSSIONS_URL = 'https://github.com/refactoringhq/tolaria/discussions'
export const TOLARIA_GITHUB_CONTRIBUTING_URL = 'https://github.com/refactoringhq/tolaria/blob/main/CONTRIBUTING.md'
export const TOLARIA_GITHUB_ISSUES_URL = 'https://github.com/refactoringhq/tolaria/issues'
export const TOLARIA_GITHUB_PULL_REQUESTS_URL = 'https://github.com/refactoringhq/tolaria/pulls'

View File

@@ -73,12 +73,9 @@ export const frontmatterHighlightPlugin = ViewPlugin.fromClass(
)
export function frontmatterHighlightTheme() {
const keyColor = '#c9383e'
const valueColor = '#2a7e4f'
const delimiterColor = '#c9383e'
return EditorView.baseTheme({
'.cm-frontmatter-delimiter': { color: delimiterColor, fontWeight: '600' },
'.cm-frontmatter-key': { color: keyColor },
'.cm-frontmatter-value': { color: valueColor },
'.cm-frontmatter-delimiter': { color: 'var(--syntax-frontmatter-key)', fontWeight: '600' },
'.cm-frontmatter-key': { color: 'var(--syntax-frontmatter-key)' },
'.cm-frontmatter-value': { color: 'var(--syntax-frontmatter-value)' },
})
}

View File

@@ -4,23 +4,31 @@ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags } from '@lezer/highlight'
import type { Extension } from '@codemirror/state'
const SYNTAX_COLORS = {
heading: 'var(--syntax-heading)',
link: 'var(--syntax-link)',
monospace: 'var(--syntax-monospace)',
monospaceBackground: 'var(--syntax-monospace-bg)',
muted: 'var(--syntax-muted)',
}
const markdownHighlightStyle = HighlightStyle.define([
{ tag: tags.heading1, color: '#0969da', fontWeight: '700', fontSize: '1.4em' },
{ tag: tags.heading2, color: '#0969da', fontWeight: '700', fontSize: '1.25em' },
{ tag: tags.heading3, color: '#0969da', fontWeight: '600', fontSize: '1.1em' },
{ tag: tags.heading4, color: '#0969da', fontWeight: '600' },
{ tag: tags.heading5, color: '#0969da', fontWeight: '600' },
{ tag: tags.heading6, color: '#0969da', fontWeight: '600' },
{ tag: tags.heading1, color: SYNTAX_COLORS.heading, fontWeight: '700', fontSize: '1.4em' },
{ tag: tags.heading2, color: SYNTAX_COLORS.heading, fontWeight: '700', fontSize: '1.25em' },
{ tag: tags.heading3, color: SYNTAX_COLORS.heading, fontWeight: '600', fontSize: '1.1em' },
{ tag: tags.heading4, color: SYNTAX_COLORS.heading, fontWeight: '600' },
{ tag: tags.heading5, color: SYNTAX_COLORS.heading, fontWeight: '600' },
{ tag: tags.heading6, color: SYNTAX_COLORS.heading, fontWeight: '600' },
{ tag: tags.strong, fontWeight: '700' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
{ tag: tags.link, color: '#0969da', textDecoration: 'underline' },
{ tag: tags.url, color: '#0969da' },
{ tag: tags.monospace, color: '#c9383e', backgroundColor: 'rgba(175,184,193,0.15)', borderRadius: '3px' },
{ tag: tags.quote, color: '#636c76', fontStyle: 'italic' },
{ tag: tags.separator, color: '#636c76' },
{ tag: tags.processingInstruction, color: '#c9383e', fontWeight: '600' },
{ tag: tags.contentSeparator, color: '#c9383e', fontWeight: '600' },
{ tag: tags.link, color: SYNTAX_COLORS.link, textDecoration: 'underline' },
{ tag: tags.url, color: SYNTAX_COLORS.link },
{ tag: tags.monospace, color: SYNTAX_COLORS.monospace, backgroundColor: SYNTAX_COLORS.monospaceBackground, borderRadius: '3px' },
{ tag: tags.quote, color: SYNTAX_COLORS.muted, fontStyle: 'italic' },
{ tag: tags.separator, color: SYNTAX_COLORS.muted },
{ tag: tags.processingInstruction, color: SYNTAX_COLORS.monospace, fontWeight: '600' },
{ tag: tags.contentSeparator, color: SYNTAX_COLORS.monospace, fontWeight: '600' },
])
export function markdownLanguage(): Extension {

View File

@@ -1,4 +1,5 @@
import type { SidebarFilter } from '../types'
import { isMac } from '../utils/platform'
import type { ViewMode } from './useViewMode'
export const APP_COMMAND_IDS = {
@@ -460,3 +461,23 @@ export function findShortcutCommandIdForEvent(event: ShortcutEventLike): AppComm
}
return null
}
export function formatShortcutDisplay(
shortcut: Pick<AppCommandShortcutDefinition, 'display'>,
): string {
if (isMac()) return shortcut.display
return shortcut.display
.replaceAll('⌘⇧', 'Ctrl+Shift+')
.replaceAll('⌘', 'Ctrl+')
.replaceAll('⌫', 'Backspace')
.replaceAll('⌦', 'Delete')
.replaceAll('←', 'Left')
.replaceAll('→', 'Right')
.replaceAll('↵', 'Enter')
}
export function getAppCommandShortcutDisplay(id: AppCommandId): string | undefined {
const shortcut = APP_COMMAND_DEFINITIONS[id].shortcut
return shortcut ? formatShortcutDisplay(shortcut) : undefined
}

View File

@@ -1,3 +1,4 @@
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
import type { CommandAction } from './types'
import type { SidebarSelection } from '../../types'
@@ -50,13 +51,13 @@ function buildBaseCommands(config: NavigationCommandsConfig): CommandAction[] {
} = config
return [
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P / ⌘O', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.fileQuickOpen), keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
{ id: 'go-pulse', label: 'Go to History', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘←', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘→', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewGoBack), keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewGoForward), keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
]
}

View File

@@ -1,3 +1,4 @@
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
import type { CommandAction } from './types'
interface NoteCommandsConfig {
@@ -59,7 +60,7 @@ function buildCoreNoteCommands(config: NoteCommandsConfig): CommandAction[] {
createNoteCommand({
id: 'create-note',
label: 'New Note',
shortcut: '⌘N',
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.fileNewNote),
keywords: ['new', 'create', 'add'],
enabled: true,
execute: config.onCreateNote,
@@ -74,7 +75,7 @@ function buildCoreNoteCommands(config: NoteCommandsConfig): CommandAction[] {
createNoteCommand({
id: 'save-note',
label: 'Save Note',
shortcut: '⌘S',
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.fileSave),
keywords: ['write'],
enabled: config.hasActiveNote,
execute: config.onSave,
@@ -94,7 +95,7 @@ function buildDestructiveNoteCommands(config: NoteCommandsConfig): CommandAction
createNoteCommand({
id: 'delete-note',
label: 'Delete Note',
shortcut: '⌘⌫',
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteDelete),
keywords: ['delete', 'remove'],
enabled: config.hasActiveNote,
path: config.activeTabPath,
@@ -116,7 +117,7 @@ function buildPinnedNoteCommands(config: NoteCommandsConfig): CommandAction[] {
createNoteCommand({
id: 'toggle-favorite',
label: config.isFavorite ? 'Remove from Favorites' : 'Add to Favorites',
shortcut: '⌘D',
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteToggleFavorite),
keywords: ['favorite', 'star', 'bookmark', 'pin'],
enabled: config.hasActiveNote && !!config.onToggleFavorite,
path: config.activeTabPath,
@@ -125,7 +126,7 @@ function buildPinnedNoteCommands(config: NoteCommandsConfig): CommandAction[] {
createNoteCommand({
id: 'toggle-organized',
label: config.isOrganized ? 'Mark as Unorganized' : 'Mark as Organized',
shortcut: '⌘E',
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteToggleOrganized),
keywords: ['organized', 'inbox', 'triage', 'done'],
enabled: config.hasActiveNote && !!config.onToggleOrganized,
path: config.activeTabPath,
@@ -192,7 +193,7 @@ function buildPresentationCommands(config: NoteCommandsConfig): CommandAction[]
createNoteCommand({
id: 'open-in-new-window',
label: 'Open in New Window',
shortcut: '⌘⇧O',
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteOpenInNewWindow),
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
enabled: config.hasActiveNote,
execute: () => config.onOpenInNewWindow?.(),

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { formatShortcutDisplay } from '../appCommandCatalog'
import { buildSettingsCommands } from './settingsCommands'
describe('buildSettingsCommands', () => {
@@ -25,7 +26,7 @@ describe('buildSettingsCommands', () => {
expect(commands.find((item) => item.id === 'open-settings')).toMatchObject({
label: 'Open Settings',
shortcut: '⌘,',
shortcut: formatShortcutDisplay({ display: '⌘,' }),
enabled: true,
})
})

View File

@@ -1,4 +1,6 @@
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
import type { CommandAction } from './types'
import { rememberFeedbackDialogOpener } from '../../lib/feedbackDialogOpener'
interface SettingsCommandsConfig {
mcpStatus?: string
@@ -22,7 +24,7 @@ function buildPrimarySettingsCommands({
onCheckForUpdates,
}: Pick<SettingsCommandsConfig, 'onOpenSettings' | 'onOpenFeedback' | 'onCheckForUpdates'>): CommandAction[] {
return [
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.appSettings), keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
{
id: 'open-h1-auto-rename-setting',
label: 'Open H1 Auto-Rename Setting',
@@ -31,7 +33,17 @@ function buildPrimarySettingsCommands({
enabled: true,
execute: onOpenSettings,
},
{ id: 'give-feedback', label: 'Give Feedback', group: 'Settings', keywords: ['feedback', 'issue', 'bug', 'github', 'report'], enabled: !!onOpenFeedback, execute: () => onOpenFeedback?.() },
{
id: 'open-contribute',
label: 'Contribute',
group: 'Settings',
keywords: ['contribute', 'feedback', 'feature', 'canny', 'discussion', 'github', 'bug', 'report'],
enabled: !!onOpenFeedback,
execute: () => {
rememberFeedbackDialogOpener(document.activeElement instanceof HTMLElement ? document.activeElement : null)
onOpenFeedback?.()
},
},
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
]
}

View File

@@ -1,3 +1,4 @@
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
import type { CommandAction } from './types'
import type { ViewMode } from '../useViewMode'
import { requestNewAiChat } from '../../utils/aiPromptBridge'
@@ -28,18 +29,18 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
} = config
return [
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: '⌘⇧I', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewEditorOnly), keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewEditorList), keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewAll), keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleProperties), keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() },
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⇧⌘L', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat), keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
{ id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat },
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
{ id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomIn), keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomOut), keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomReset), keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
]
}

View File

@@ -125,7 +125,7 @@ export function useAutoGit({
void onCheckpoint(trigger).then((didRun) => {
if (didRun) markTriggerAsHandled(lastTriggeredRef.current, trigger, lastActivityAt)
}).catch(() => {})
}).catch((err) => console.warn('[git] Auto-commit failed:', err))
})
const updateAppActivity = useEffectEvent((active: boolean) => {

View File

@@ -127,7 +127,7 @@ function useCommitInfoRefresher(
return useCallback(() => {
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
.then(info => setLastCommitInfo(info))
.catch(() => {})
.catch((err) => console.warn('[sync] Failed to refresh last commit info:', err))
}, [vaultPath, setLastCommitInfo])
}

View File

@@ -8,6 +8,14 @@ import { resolveArrowLigatureInput } from '../utils/arrowLigatures'
import { zoomCursorFix } from '../extensions/zoomCursorFix'
const FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const RAW_EDITOR_COLORS = {
activeLineBackground: 'var(--state-hover-subtle)',
background: 'var(--surface-editor)',
foreground: 'var(--text-primary)',
gutterBackground: 'var(--surface-editor)',
gutterBorder: 'var(--border-subtle)',
gutterText: 'var(--text-muted)',
}
export interface CodeMirrorCallbacks {
onDocChange: (doc: string) => void
@@ -17,19 +25,12 @@ export interface CodeMirrorCallbacks {
}
function buildBaseTheme() {
const bg = '#ffffff'
const fg = '#1e1e1e'
const gutterBg = '#ffffff'
const gutterColor = '#aaa'
const activeLineBg = 'rgba(0,100,255,0.06)'
const gutterBorder = '#eee'
return EditorView.theme({
'&': {
fontSize: '13px',
fontFamily: FONT_FAMILY,
backgroundColor: bg,
color: fg,
backgroundColor: RAW_EDITOR_COLORS.background,
color: RAW_EDITOR_COLORS.foreground,
flex: '1',
minHeight: '0',
},
@@ -41,12 +42,12 @@ function buildBaseTheme() {
},
'.cm-content': {
padding: '0 32px 0 16px',
caretColor: fg,
caretColor: RAW_EDITOR_COLORS.foreground,
},
'.cm-gutters': {
backgroundColor: gutterBg,
color: gutterColor,
borderRight: `1px solid ${gutterBorder}`,
backgroundColor: RAW_EDITOR_COLORS.gutterBackground,
color: RAW_EDITOR_COLORS.gutterText,
borderRight: `1px solid ${RAW_EDITOR_COLORS.gutterBorder}`,
paddingLeft: '16px',
},
'.cm-lineNumbers .cm-gutterElement': {
@@ -55,10 +56,10 @@ function buildBaseTheme() {
textAlign: 'right',
},
'.cm-activeLine': {
backgroundColor: activeLineBg,
backgroundColor: RAW_EDITOR_COLORS.activeLineBackground,
},
'.cm-activeLineGutter': {
backgroundColor: activeLineBg,
backgroundColor: RAW_EDITOR_COLORS.activeLineBackground,
},
'&.cm-focused': { outline: 'none' },
'.cm-line': { padding: '0' },

View File

@@ -3,6 +3,7 @@ import { renderHook } from '@testing-library/react'
import { useCommandRegistry, buildTypeCommands, extractVaultTypes, pluralizeType, groupSortKey } from './useCommandRegistry'
import type { CommandAction } from './useCommandRegistry'
import { NEW_AI_CHAT_EVENT, OPEN_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
import { formatShortcutDisplay } from './appCommandCatalog'
function makeConfig(overrides: Record<string, unknown> = {}) {
return {
@@ -247,7 +248,9 @@ describe('useCommandRegistry', () => {
it('shows Cmd+E on toggle organized and removes it from archive note', () => {
const config = makeConfig()
const { result } = renderHook(() => useCommandRegistry(config))
expect(findCommand(result.current, 'toggle-organized')?.shortcut).toBe('⌘E')
expect(findCommand(result.current, 'toggle-organized')?.shortcut).toBe(
formatShortcutDisplay({ display: '⌘E' }),
)
expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined()
})
@@ -328,12 +331,13 @@ describe('useCommandRegistry', () => {
expect(findCommand(result.current, 'open-daily-note')).toBeUndefined()
})
it('includes Give Feedback in the Settings group when available', () => {
it('includes Contribute in the Settings group when available', () => {
const onOpenFeedback = vi.fn()
const config = makeConfig({ onOpenFeedback })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'give-feedback')
const cmd = findCommand(result.current, 'open-contribute')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe('Contribute')
expect(cmd!.group).toBe('Settings')
expect(cmd!.enabled).toBe(true)
@@ -355,7 +359,7 @@ describe('useCommandRegistry', () => {
expect(newNoteCommands).toHaveLength(1)
expect(newNoteCommands[0]).toMatchObject({
id: 'create-note',
shortcut: '⌘N',
shortcut: formatShortcutDisplay({ display: '⌘N' }),
})
})

View File

@@ -0,0 +1,29 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { useDocumentThemeMode } from './useDocumentThemeMode'
describe('useDocumentThemeMode', () => {
beforeEach(() => {
document.documentElement.removeAttribute('data-theme')
document.documentElement.classList.remove('dark')
})
it('defaults to light when no document theme is applied', () => {
const { result } = renderHook(() => useDocumentThemeMode())
expect(result.current).toBe('light')
})
it('updates when the document theme changes', async () => {
const { result } = renderHook(() => useDocumentThemeMode())
act(() => {
document.documentElement.setAttribute('data-theme', 'dark')
document.documentElement.classList.add('dark')
})
await waitFor(() => {
expect(result.current).toBe('dark')
})
})
})

View File

@@ -0,0 +1,33 @@
import { useSyncExternalStore } from 'react'
import {
DEFAULT_THEME_MODE,
normalizeThemeMode,
type ThemeMode,
} from '../lib/themeMode'
function readDocumentThemeMode(): ThemeMode {
if (typeof document === 'undefined') return DEFAULT_THEME_MODE
return normalizeThemeMode(document.documentElement.getAttribute('data-theme')) ?? DEFAULT_THEME_MODE
}
function subscribeDocumentThemeMode(onChange: () => void): () => void {
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
return () => {}
}
const observer = new MutationObserver(onChange)
observer.observe(document.documentElement, {
attributeFilter: ['class', 'data-theme'],
attributes: true,
})
return () => observer.disconnect()
}
export function useDocumentThemeMode(): ThemeMode {
return useSyncExternalStore(
subscribeDocumentThemeMode,
readDocumentThemeMode,
() => DEFAULT_THEME_MODE,
)
}

View File

@@ -61,7 +61,7 @@ export function useMainWindowSizeConstraints({
void (async () => {
if (cancelled) return
await applyMainWindowSizeConstraints(minWidth)
})().catch(() => {})
})().catch((err) => console.warn('[window] Size constraints failed:', err))
return () => {
cancelled = true

View File

@@ -54,7 +54,7 @@ function syncNativeMenuState(state: MenuStatePayload): void {
import('@tauri-apps/api/core')
.then(({ invoke }) => invoke('update_menu_state', { state }))
.catch(() => {})
.catch((err) => console.warn('[menu] Failed to sync native menu state:', err))
}
function useNativeMenuEventListener(handlersRef: { current: MenuEventHandlers }) {

View File

@@ -14,6 +14,7 @@ const defaultSettings: Settings = {
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
theme_mode: null,
default_ai_agent: null,
}
@@ -28,6 +29,7 @@ const savedSettings: Settings = {
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
theme_mode: null,
default_ai_agent: null,
}
@@ -111,6 +113,7 @@ describe('useSettings', () => {
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
theme_mode: null,
default_ai_agent: null,
}

View File

@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { normalizeStoredAiAgent } from '../lib/aiAgents'
import { normalizeReleaseChannel, serializeReleaseChannel } from '../lib/releaseChannel'
import { normalizeThemeMode } from '../lib/themeMode'
import type { Settings } from '../types'
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
@@ -20,6 +21,7 @@ const EMPTY_SETTINGS: Settings = {
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
theme_mode: null,
default_ai_agent: null,
}
@@ -29,6 +31,7 @@ function normalizeSettings(settings: Settings): Settings {
release_channel: serializeReleaseChannel(
normalizeReleaseChannel(settings.release_channel),
),
theme_mode: normalizeThemeMode(settings.theme_mode),
default_ai_agent: normalizeStoredAiAgent(settings.default_ai_agent),
}
}

View File

@@ -1,11 +1,20 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import { useTabManagement, prefetchNoteContent, cacheNoteContent, clearPrefetchCache } from './useTabManagement'
import {
useTabManagement,
prefetchNoteContent,
cacheNoteContent,
clearPrefetchCache,
NOTE_CONTENT_CACHE_MAX_BYTES,
NOTE_CONTENT_ENTRY_MAX_BYTES,
} from './useTabManagement'
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
isTauri: vi.fn(() => false),
mockInvoke: vi.fn().mockResolvedValue('# Mock content'),
}))
@@ -48,7 +57,6 @@ async function replaceActiveNote(result: HookState, overrides: Partial<VaultEntr
}
async function prefetchResolvedContent(path: string, content: string) {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockResolvedValue(content)
prefetchNoteContent(path)
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
@@ -69,10 +77,35 @@ function createDeferred<T>() {
return { promise, resolve }
}
function makeAsciiContent(byteCount: number): string {
return 'x'.repeat(byteCount)
}
function seedCacheBeyondByteLimit() {
const cachedContent = makeAsciiContent(Math.floor(NOTE_CONTENT_ENTRY_MAX_BYTES * 0.9))
const cachedPaths = Array.from(
{ length: Math.floor(NOTE_CONTENT_CACHE_MAX_BYTES / cachedContent.length) + 2 },
(_, index) => `/vault/note/cached-${index + 1}.md`,
)
for (const path of cachedPaths) {
cacheNoteContent(path, cachedContent)
}
return {
cachedContent,
oldestPath: cachedPaths[0],
newestPath: cachedPaths[cachedPaths.length - 1],
}
}
describe('useTabManagement (single-note model)', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetAllMocks()
clearPrefetchCache()
vi.mocked(isTauri).mockReturnValue(false)
vi.mocked(mockInvoke).mockResolvedValue('# Mock content')
window.history.replaceState({}, '', '/')
})
it('starts with no note and null active path', () => {
@@ -89,8 +122,6 @@ describe('useTabManagement (single-note model)', () => {
})
it('switches the active path immediately while the next note is still loading', async () => {
const { mockInvoke } = await import('../mock-tauri')
let resolveContent: (value: string) => void
vi.mocked(mockInvoke).mockImplementationOnce(
() => new Promise<string>((resolve) => { resolveContent = resolve }),
@@ -131,7 +162,6 @@ describe('useTabManagement (single-note model)', () => {
})
it('handles load content failure gracefully', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('fail'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
@@ -144,7 +174,6 @@ describe('useTabManagement (single-note model)', () => {
})
it('clears the active note when the file is missing on disk', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File does not exist: /vault/note/missing.md'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const onMissingNotePath = vi.fn()
@@ -162,7 +191,6 @@ describe('useTabManagement (single-note model)', () => {
})
it('returns to the empty state when note content is not valid UTF-8 text', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File is not valid UTF-8 text: /vault/note/bad.csv'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const onUnreadableNoteContent = vi.fn()
@@ -185,7 +213,6 @@ describe('useTabManagement (single-note model)', () => {
})
it('returns to the empty state when no active vault is selected', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('No active vault selected'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
@@ -196,6 +223,25 @@ describe('useTabManagement (single-note model)', () => {
expect(result.current.activeTabPath).toBeNull()
warnSpy.mockRestore()
})
it('uses the note-window vault path when Tauri reloads the selected note', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockResolvedValue('# Window content')
window.history.replaceState(
{},
'',
'/?window=note&path=%2Fvault%2Fnote%2Ftest.md&vault=%2Fvault&title=Test+Note',
)
const { result } = renderHook(() => useTabManagement())
await selectNote(result, { path: '/vault/note/test.md', title: 'Test Note' })
expect(vi.mocked(invoke)).toHaveBeenCalledWith('get_note_content', {
path: '/vault/note/test.md',
vaultPath: '/vault',
})
expect(result.current.tabs[0].content).toBe('# Window content')
})
})
describe('handleReplaceActiveTab', () => {
@@ -207,7 +253,6 @@ describe('useTabManagement (single-note model)', () => {
})
it('treats /tmp and /private/tmp aliases as the same active note', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke)
.mockResolvedValueOnce('# Stale before pull')
.mockResolvedValueOnce('# Fresh after pull')
@@ -229,7 +274,6 @@ describe('useTabManagement (single-note model)', () => {
})
it('reloads content when replacing with the same entry', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke)
.mockResolvedValueOnce('# Stale before pull')
.mockResolvedValueOnce('# Fresh after pull')
@@ -248,7 +292,6 @@ describe('useTabManagement (single-note model)', () => {
})
it('clears the active note when a forced reload hits a missing file path', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke)
.mockResolvedValueOnce('# Existing content')
.mockRejectedValueOnce(new Error('File does not exist: /vault/a.md'))
@@ -354,7 +397,6 @@ describe('useTabManagement (single-note model)', () => {
})
it('deduplicates concurrent prefetch requests for same path', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockResolvedValue('# Content')
prefetchNoteContent('/vault/note/dup.md')
@@ -365,7 +407,6 @@ describe('useTabManagement (single-note model)', () => {
})
it('swallows no-active-vault prefetch failures and lets a later open recover', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke)
.mockRejectedValueOnce(new Error('No active vault selected'))
.mockResolvedValueOnce('# Recovered content')
@@ -396,7 +437,6 @@ describe('useTabManagement (single-note model)', () => {
})
it('activates a warmed note immediately while reusing the cached content', async () => {
const { mockInvoke } = await import('../mock-tauri')
const deferred = createDeferred<string>()
vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise)
cacheNoteContent('/vault/note/warm.md', '# Warm content')
@@ -418,8 +458,74 @@ describe('useTabManagement (single-note model)', () => {
})
})
it('does not retain oversized notes in the prefetch cache', async () => {
const largeContent = makeAsciiContent(NOTE_CONTENT_ENTRY_MAX_BYTES + 1)
const mockInvoke = await prefetchResolvedContent('/vault/note/oversized.md', largeContent)
const deferred = createDeferred<string>()
vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise)
const { result } = renderHook(() => useTabManagement())
act(() => {
void result.current.handleSelectNote(makeEntry({ path: '/vault/note/oversized.md', title: 'Oversized' }))
})
expect(result.current.activeTabPath).toBe('/vault/note/oversized.md')
expect(result.current.tabs).toEqual([])
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
await act(async () => {
deferred.resolve(largeContent)
await Promise.resolve()
})
expect(result.current.tabs[0].content).toBe(largeContent)
})
it('evicts the oldest cached notes when retained bytes exceed the cache budget', async () => {
const { cachedContent, oldestPath } = seedCacheBeyondByteLimit()
const deferred = createDeferred<string>()
vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise)
const { result } = renderHook(() => useTabManagement())
act(() => {
void result.current.handleSelectNote(makeEntry({ path: oldestPath, title: 'Oldest cached note' }))
})
expect(result.current.activeTabPath).toBe(oldestPath)
expect(result.current.tabs).toEqual([])
await act(async () => {
deferred.resolve(cachedContent)
await Promise.resolve()
})
expect(result.current.tabs[0].content).toBe(cachedContent)
})
it('keeps the newest cached notes warm when trimming to the byte budget', async () => {
const { cachedContent, newestPath } = seedCacheBeyondByteLimit()
const deferred = createDeferred<string>()
vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise)
const { result } = renderHook(() => useTabManagement())
act(() => {
void result.current.handleSelectNote(makeEntry({ path: newestPath, title: 'Newest cached note' }))
})
expect(result.current.activeTabPath).toBe(newestPath)
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].content).toBe(cachedContent)
await act(async () => {
deferred.resolve(cachedContent)
await Promise.resolve()
})
})
it('reuses cached content when reopening a recently loaded note', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke)
.mockResolvedValueOnce('# A content')
.mockResolvedValueOnce('# B content')
@@ -436,7 +542,6 @@ describe('useTabManagement (single-note model)', () => {
})
it('falls back instead of reopening cached content when the note file disappeared', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke)
.mockResolvedValueOnce('# Other note')
.mockRejectedValueOnce(new Error('File does not exist: /vault/note/missing-cached.md'))
@@ -458,8 +563,6 @@ describe('useTabManagement (single-note model)', () => {
})
it('deduplicates a late prefetch after note opening already started', async () => {
const { mockInvoke } = await import('../mock-tauri')
let resolveContent!: (value: string) => void
vi.mocked(mockInvoke).mockImplementationOnce(
() => new Promise<string>((resolve) => { resolveContent = resolve }),
@@ -486,8 +589,6 @@ describe('useTabManagement (single-note model)', () => {
describe('rapid switching safety', () => {
it('only activates the last note when switching rapidly', async () => {
const { mockInvoke } = await import('../mock-tauri')
let resolveA: (v: string) => void
let resolveB: (v: string) => void
vi.mocked(mockInvoke)

Some files were not shown because too many files have changed in this diff Show More