Compare commits

...

30 Commits

Author SHA1 Message Date
lucaronin
8207b87c33 fix: include linux signatures in stable release 2026-04-25 14:39:19 +02:00
lucaronin
602a099ac3 fix: polish contribute dialog copy 2026-04-25 13:54:39 +02:00
lucaronin
2315122c4a fix: clear stale editor selection on note switch 2026-04-25 13:27:33 +02:00
lucaronin
ab4fcdbaae ci: include linux alpha updater signatures 2026-04-25 12:45:31 +02:00
lucaronin
a56cb8c287 fix: allow raw editor styles under CSP 2026-04-25 12:03:54 +02:00
lucaronin
b93e6d491a ci: test linux alpha release artifacts 2026-04-25 11:59:21 +02:00
lucaronin
3152ff2c6e fix: allow optional pending diff loaders 2026-04-25 11:46:59 +02:00
lucaronin
dd018cf98b fix: queue diff opening until tab activation 2026-04-25 11:46:59 +02:00
lucaronin
d0d9d90096 test: stabilize H1 wikilink smoke 2026-04-25 11:20:14 +02:00
lucaronin
944efada94 fix: load note windows without vault scan 2026-04-25 10:53:09 +02:00
lucaronin
ef4ad256e3 feat: add Refactoring support card 2026-04-25 10:31:26 +02:00
lucaronin
c427cd2492 fix: guard native file drops 2026-04-25 10:07:43 +02:00
lucaronin
151e993ab7 chore: ratchet codescene thresholds 2026-04-25 02:29:08 +02:00
lucaronin
60554d0b96 fix: handle IME composition in AI composer 2026-04-25 02:29:08 +02:00
lucaronin
4929f11b6d fix: harden windows release and git subprocesses 2026-04-25 00:58:49 +02:00
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
162 changed files with 6876 additions and 1699 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.94
AVERAGE_THRESHOLD=9.83
HOTSPOT_THRESHOLD=9.97
AVERAGE_THRESHOLD=9.85

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,243 @@ 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: Validate Linux bundles
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
)
signatures=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage or deb bundle."
exit 1
fi
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Linux build produced no updater signature (.sig) artifact."
exit 1
fi
- 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/deb/*.deb.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
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: Clear cached Windows bundle artifacts
shell: pwsh
run: |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
- 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
for installer in "${installers[@]}"; do
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
exit 1
fi
done
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 +513,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 +526,36 @@ 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() {
local patterns=("$@")
for pattern in "${patterns[@]}"; do
set -- $pattern
if [ -e "$1" ]; then
printf '%s\n' "$1"
return 0
fi
done
echo "::error::Missing required artifact matching one of: ${patterns[*]}" >&2
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/*/*.deb.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.deb.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 +567,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 +595,30 @@ jobs:
dmg-aarch64/*.dmg
updater-aarch64/*.app.tar.gz
updater-aarch64/*.app.tar.gz.sig
linux-x86_64-bundles/*.deb
linux-x86_64-bundles/*.deb.sig
linux-x86_64-bundles/*.AppImage
linux-x86_64-bundles/*.AppImage.sig
linux-x86_64-bundles/*.AppImage.tar.gz
linux-x86_64-bundles/*.AppImage.tar.gz.sig
linux-x86_64-bundles/*/*.deb
linux-x86_64-bundles/*/*.deb.sig
linux-x86_64-bundles/*/*.AppImage
linux-x86_64-bundles/*/*.AppImage.sig
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

@@ -321,6 +321,8 @@ jobs:
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
curl \
wget \
patchelf \
build-essential \
file
@@ -355,6 +357,10 @@ jobs:
- 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 }}"
@@ -372,24 +378,168 @@ jobs:
run: |
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
- name: Validate Linux bundles
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
)
signatures=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage or deb bundle."
exit 1
fi
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Linux build produced no updater signature (.sig) artifact."
exit 1
fi
- 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/deb/*.deb.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
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
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: Clear cached Windows bundle artifacts
shell: pwsh
run: |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
- 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
for installer in "${installers[@]}"; do
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
exit 1
fi
done
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
# No lipo/re-signing — use the per-arch artifacts directly
# ─────────────────────────────────────────────────────────────
release:
name: GitHub Release (alpha)
needs: [version, build]
needs: [version, build, build-linux, build-windows]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -437,7 +587,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), Linux x64, and Windows x64 bundles**"
echo ""
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
} > release_notes.md
@@ -450,8 +600,33 @@ 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")
LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*/*.deb.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.deb.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 > alpha-latest.json << EOF
{
@@ -461,7 +636,18 @@ 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}"
},
"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}"
}
}
}
@@ -479,6 +665,30 @@ jobs:
files: |
updater-aarch64/*.app.tar.gz
updater-aarch64/*.app.tar.gz.sig
linux-x86_64-bundles/*.deb
linux-x86_64-bundles/*.deb.sig
linux-x86_64-bundles/*.AppImage
linux-x86_64-bundles/*.AppImage.sig
linux-x86_64-bundles/*.AppImage.tar.gz
linux-x86_64-bundles/*.AppImage.tar.gz.sig
linux-x86_64-bundles/*/*.deb
linux-x86_64-bundles/*/*.deb.sig
linux-x86_64-bundles/*/*.AppImage
linux-x86_64-bundles/*/*.AppImage.sig
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
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,41 @@ 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)
})
}
#[tauri::command]
pub fn sync_vault_asset_scope_for_window(
app_handle: tauri::AppHandle,
vault_path: PathBuf,
) -> Result<(), String> {
with_requested_root_path(vault_path.as_path(), |requested_root| {
sync_image_asset_scope(&app_handle, requested_root)
})
}
fn with_writable_note_path<T>(
path: PathBuf,
vault_path: Option<PathBuf>,
@@ -114,6 +150,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 +183,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

@@ -1,6 +1,8 @@
use std::path::Path;
use std::process::{Command, Output, Stdio};
use super::git_command;
struct CloneRequest<'a> {
url: &'a str,
dest: &'a Path,
@@ -92,7 +94,7 @@ fn run_clone(request: &CloneRequest<'_>) -> Result<(), String> {
}
fn build_clone_command(request: &CloneRequest<'_>, destination: &str) -> Command {
let mut command = Command::new("git");
let mut command = git_command();
command
.args(["clone", "--quiet", request.url, destination])
.env("GIT_TERMINAL_PROMPT", "0")

View File

@@ -1,5 +1,5 @@
use super::git_command;
use std::path::Path;
use std::process::Command;
struct CommitFailure {
stdout: String,
@@ -11,7 +11,7 @@ pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
// Stage all changes
let add = Command::new("git")
let add = git_command()
.args(["add", "-A"])
.current_dir(vault)
.output()
@@ -37,7 +37,7 @@ pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
}
fn run_commit(vault: &Path, message: &str, disable_signing: bool) -> Result<String, CommitFailure> {
let mut command = Command::new("git");
let mut command = git_command();
if disable_signing {
command.args(["-c", "commit.gpgsign=false"]);
}
@@ -85,10 +85,10 @@ fn is_commit_signing_failure(detail: &str) -> bool {
#[cfg(test)]
mod tests {
use super::git_command;
use super::*;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
#[test]
fn test_git_commit() {
@@ -101,7 +101,7 @@ mod tests {
assert!(result.is_ok());
// Verify the commit exists
let log = Command::new("git")
let log = git_command()
.args(["log", "--oneline", "-1"])
.current_dir(vault)
.output()
@@ -135,12 +135,12 @@ mod tests {
let vault = dir.path();
let vp = vault.to_str().unwrap();
Command::new("git")
git_command()
.args(["config", "commit.gpgsign", "true"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["config", "gpg.program", "/missing/tolaria-test-gpg"])
.current_dir(vault)
.output()
@@ -153,14 +153,14 @@ mod tests {
"commit should retry unsigned when signing helper is missing: {result:?}"
);
let log = Command::new("git")
let log = git_command()
.args(["log", "--oneline", "-1"])
.current_dir(vault)
.output()
.unwrap();
assert!(String::from_utf8_lossy(&log.stdout).contains("Commit with broken signing config"));
let config = Command::new("git")
let config = git_command()
.args(["config", "commit.gpgsign"])
.current_dir(vault)
.output()

View File

@@ -1,7 +1,6 @@
use std::path::Path;
use std::process::Command;
use super::run_git;
use super::{git_command, run_git};
/// List files with merge conflicts (unmerged paths).
///
@@ -10,7 +9,7 @@ use super::run_git;
/// stale (e.g. after a reboot or when MERGE_HEAD is missing).
pub fn get_conflict_files(vault_path: &str) -> Result<Vec<String>, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
let output = git_command()
.args(["ls-files", "--unmerged"])
.current_dir(vault)
.output()
@@ -93,13 +92,13 @@ pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String
let mode = get_conflict_mode(vault_path);
let output = match mode.as_str() {
"rebase" => Command::new("git")
"rebase" => git_command()
.args(["rebase", "--continue"])
.env("GIT_EDITOR", "true")
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git rebase --continue: {}", e))?,
_ => Command::new("git")
_ => git_command()
.args(["commit", "-m", "Resolve merge conflicts"])
.current_dir(vault)
.output()
@@ -131,7 +130,6 @@ mod tests {
use crate::git::tests::{setup_git_repo, setup_remote_pair};
use crate::git::{git_commit, git_pull, git_push};
use std::fs;
use std::process::Command;
use tempfile::TempDir;
#[test]
@@ -203,35 +201,30 @@ mod tests {
(bare_dir, clone_a_dir, clone_b_dir)
}
#[test]
fn test_resolve_conflict_ours() {
fn assert_resolve_conflict_strategy(strategy: &str, expected_content: &str) {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let conflicts = get_conflict_files(vp_b).unwrap();
assert!(conflicts.contains(&"conflict.md".to_string()));
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
git_resolve_conflict(vp_b, "conflict.md", strategy).unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
assert_eq!(content, "# Version B\n");
assert_eq!(content, expected_content);
}
#[test]
fn test_resolve_conflict_ours() {
assert_resolve_conflict_strategy("ours", "# Version B\n");
}
#[test]
fn test_resolve_conflict_theirs() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
assert_eq!(content, "# Version A\n");
assert_resolve_conflict_strategy("theirs", "# Version A\n");
}
#[test]
@@ -244,7 +237,7 @@ mod tests {
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok());
let log = Command::new("git")
let log = git_command()
.args(["log", "--oneline", "-1"])
.current_dir(clone_b.path())
.output()
@@ -310,7 +303,7 @@ mod tests {
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
git_commit(vp_b, "B's change").unwrap();
let output = Command::new("git")
let output = git_command()
.args(["pull", "--rebase"])
.current_dir(clone_b_dir.path())
.output()

View File

@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::{Command, Output};
use std::process::Output;
use super::git_command;
const DEFAULT_REMOTE_NAME: &str = "origin";
@@ -192,14 +194,14 @@ fn list_remotes(vault: &Path) -> Result<Vec<String>, String> {
}
fn unset_upstream(vault: &Path) {
let _ = Command::new("git")
let _ = git_command()
.args(["branch", "--unset-upstream"])
.current_dir(vault)
.output();
}
fn run_git(vault: &Path, args: &[&str]) -> Result<(), String> {
let output = Command::new("git")
let output = git_command()
.args(args)
.current_dir(vault)
.output()
@@ -237,7 +239,7 @@ fn list_remote_branches(vault: &Path) -> Result<Vec<String>, String> {
}
fn histories_share_base(vault: &Path, connection: &RemoteConnection) -> bool {
Command::new("git")
git_command()
.args(["merge-base", "HEAD", connection.remote_branch.as_str()])
.current_dir(vault)
.output()
@@ -315,7 +317,7 @@ fn classify_connect_error(stderr: &str) -> GitAddRemoteResult {
}
fn git_output(vault: &Path, args: &[&str]) -> Result<Output, String> {
Command::new("git")
git_command()
.args(args)
.current_dir(vault)
.output()

View File

@@ -1,7 +1,7 @@
use super::git_command;
use chrono::DateTime;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
/// Git-derived creation and modification timestamps for a file.
#[derive(Debug, Clone)]
@@ -19,7 +19,7 @@ pub struct GitDates {
/// Files not yet committed (untracked / only staged) will not appear in the map;
/// callers should fall back to filesystem metadata for those.
pub fn get_all_file_dates(vault_path: &Path) -> HashMap<String, GitDates> {
let output = match Command::new("git")
let output = match git_command()
.args(["log", "--format=COMMIT %aI", "--name-only"])
.current_dir(vault_path)
.output()

View File

@@ -1,5 +1,5 @@
use super::git_command;
use std::path::Path;
use std::process::Command;
use super::GitCommit;
@@ -16,7 +16,7 @@ pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitComm
.to_str()
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
let output = Command::new("git")
let output = git_command()
.args([
"log",
"--format=%H|%h|%an|%aI|%s",
@@ -80,7 +80,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
// First try tracked file diff
let output = Command::new("git")
let output = git_command()
.args(["diff", "--", relative_str])
.current_dir(vault)
.output()
@@ -90,7 +90,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
// If no diff (maybe staged or untracked), try diff --cached
if stdout.is_empty() {
let cached = Command::new("git")
let cached = git_command()
.args(["diff", "--cached", "--", relative_str])
.current_dir(vault)
.output()
@@ -102,7 +102,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
}
// Try showing untracked file as all-new
let status = Command::new("git")
let status = git_command()
.args(["status", "--porcelain", "--", relative_str])
.current_dir(vault)
.output()
@@ -144,7 +144,7 @@ pub fn get_file_diff_at_commit(
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
// Show diff between commit^ and commit for this file
let output = Command::new("git")
let output = git_command()
.args([
"diff",
&format!("{}^", commit_hash),
@@ -161,7 +161,7 @@ pub fn get_file_diff_at_commit(
// If diff is empty, it might be the initial commit (no parent).
// Fall back to showing the full file content as added.
if stdout.is_empty() {
let show = Command::new("git")
let show = git_command()
.args(["show", &format!("{}:{}", commit_hash, relative_str)])
.current_dir(vault)
.output()
@@ -184,10 +184,10 @@ pub fn get_file_diff_at_commit(
#[cfg(test)]
mod tests {
use super::git_command;
use super::*;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
#[test]
fn test_get_file_history_with_commits() {
@@ -196,24 +196,24 @@ mod tests {
let file = vault.join("test.md");
fs::write(&file, "# Initial\n").unwrap();
Command::new("git")
git_command()
.args(["add", "test.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "Initial commit"])
.current_dir(vault)
.output()
.unwrap();
fs::write(&file, "# Updated\n\nNew content.").unwrap();
Command::new("git")
git_command()
.args(["add", "test.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "Update test"])
.current_dir(vault)
.output()
@@ -248,12 +248,12 @@ mod tests {
let file = vault.join("diff-test.md");
fs::write(&file, "# Test\n\nOriginal content.").unwrap();
Command::new("git")
git_command()
.args(["add", "diff-test.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "Add diff-test"])
.current_dir(vault)
.output()
@@ -275,31 +275,31 @@ mod tests {
let file = vault.join("diff-at-commit.md");
fs::write(&file, "# First\n\nOriginal content.").unwrap();
Command::new("git")
git_command()
.args(["add", "diff-at-commit.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "First commit"])
.current_dir(vault)
.output()
.unwrap();
fs::write(&file, "# First\n\nModified content.").unwrap();
Command::new("git")
git_command()
.args(["add", "diff-at-commit.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "Second commit"])
.current_dir(vault)
.output()
.unwrap();
// Get hash of second commit
let log = Command::new("git")
let log = git_command()
.args(["log", "--format=%H", "-1"])
.current_dir(vault)
.output()
@@ -321,18 +321,18 @@ mod tests {
let file = vault.join("initial.md");
fs::write(&file, "# Initial\n\nHello world.").unwrap();
Command::new("git")
git_command()
.args(["add", "initial.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "Initial commit"])
.current_dir(vault)
.output()
.unwrap();
let log = Command::new("git")
let log = git_command()
.args(["log", "--format=%H", "-1"])
.current_dir(vault)
.output()

View File

@@ -56,6 +56,10 @@ const DEFAULT_GITIGNORE: &str = "# Tolaria app files (machine-specific, never co
*.swp\n\
*.swo\n";
fn git_command() -> Command {
crate::hidden_command("git")
}
/// Ensure a `.gitignore` with sensible defaults exists in the vault directory.
/// Creates the file if missing; leaves existing `.gitignore` files untouched.
pub fn ensure_gitignore(path: &str) -> Result<(), String> {
@@ -99,7 +103,7 @@ fn commit_initial_vault_setup(dir: &Path) -> Result<(), String> {
/// Run a git command in the given directory, returning an error on failure.
fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
let output = Command::new("git")
let output = git_command()
.args(args)
.current_dir(dir)
.output()
@@ -130,7 +134,7 @@ fn ensure_author_config(dir: &Path) -> Result<(), String> {
("user.name", "Tolaria"),
("user.email", "vault@tolaria.app"),
] {
let check = Command::new("git")
let check = git_command()
.args(["config", key])
.current_dir(dir)
.output()
@@ -174,7 +178,6 @@ fn parse_github_repo_path(url: &str) -> Option<String> {
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn assert_repo_path(url: &str, expected: Option<&str>) {
@@ -188,19 +191,19 @@ mod tests {
let dir = TempDir::new().unwrap();
let path = dir.path();
Command::new("git")
git_command()
.args(["init", "--initial-branch=main"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["config", "user.name", "Test User"])
.current_dir(path)
.output()
@@ -214,14 +217,14 @@ mod tests {
let bare_dir = TempDir::new().unwrap();
let bare = bare_dir.path();
Command::new("git")
git_command()
.args(["init", "--bare"])
.current_dir(bare)
.output()
.unwrap();
let clone_a_dir = TempDir::new().unwrap();
Command::new("git")
git_command()
.args(["clone", bare.to_str().unwrap(), "."])
.current_dir(clone_a_dir.path())
.output()
@@ -230,7 +233,7 @@ mod tests {
&["config", "user.email", "a@test.com"][..],
&["config", "user.name", "User A"][..],
] {
Command::new("git")
git_command()
.args(*cmd)
.current_dir(clone_a_dir.path())
.output()
@@ -238,7 +241,7 @@ mod tests {
}
let clone_b_dir = TempDir::new().unwrap();
Command::new("git")
git_command()
.args(["clone", bare.to_str().unwrap(), "."])
.current_dir(clone_b_dir.path())
.output()
@@ -247,7 +250,7 @@ mod tests {
&["config", "user.email", "b@test.com"][..],
&["config", "user.name", "User B"][..],
] {
Command::new("git")
git_command()
.args(*cmd)
.current_dir(clone_b_dir.path())
.output()
@@ -301,7 +304,7 @@ mod tests {
init_repo(vault.to_str().unwrap()).unwrap();
let log = Command::new("git")
let log = git_command()
.args(["log", "--oneline"])
.current_dir(&vault)
.output()
@@ -317,17 +320,17 @@ mod tests {
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
Command::new("git")
git_command()
.args(["init"])
.current_dir(&vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["config", "commit.gpgsign", "true"])
.current_dir(&vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["config", "gpg.program", "/missing/tolaria-test-gpg"])
.current_dir(&vault)
.output()
@@ -335,7 +338,7 @@ mod tests {
init_repo(vault.to_str().unwrap()).unwrap();
let log = Command::new("git")
let log = git_command()
.args(["log", "--oneline"])
.current_dir(&vault)
.output()
@@ -353,7 +356,7 @@ mod tests {
init_repo(vault.to_str().unwrap()).unwrap();
let status = Command::new("git")
let status = git_command()
.args(["status", "--porcelain"])
.current_dir(&vault)
.output()

View File

@@ -1,8 +1,7 @@
use serde::Serialize;
use std::path::Path;
use std::process::Command;
use super::parse_github_repo_path;
use super::{git_command, parse_github_repo_path};
#[derive(Debug, Serialize, Clone)]
pub struct PulseFile {
@@ -67,7 +66,7 @@ pub fn get_vault_pulse(
let limit_str = limit.to_string();
let skip_str = skip.to_string();
let output = Command::new("git")
let output = git_command()
.args([
"log",
"--name-status",
@@ -99,7 +98,7 @@ pub fn get_vault_pulse(
fn get_github_base_url(vault_path: &str) -> Option<String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
let output = git_command()
.args(["remote", "get-url", "origin"])
.current_dir(vault)
.output()
@@ -123,69 +122,90 @@ fn parse_pulse_output(stdout: &str, github_base: &Option<String>) -> Vec<PulseCo
continue;
}
if line.contains('|')
&& !line.starts_with(|c: char| {
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
})
{
// Commit header line: hash|short_hash|message|date
if let Some(commit) = current.take() {
commits.push(commit);
}
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() == 4 {
let hash = parts[0];
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
.map(|dt| dt.timestamp())
.unwrap_or(0);
let github_url = github_base
.as_ref()
.map(|base| format!("{}/commit/{}", base, hash));
if is_commit_header(line) {
push_current_commit(&mut commits, &mut current);
current = parse_commit_header(line, github_base);
continue;
}
current = Some(PulseCommit {
hash: hash.to_string(),
short_hash: parts[1].to_string(),
message: parts[2].to_string(),
date,
github_url,
files: Vec::new(),
added: 0,
modified: 0,
deleted: 0,
});
}
} else if let Some(ref mut commit) = current {
// File status line: A\tpath or M\tpath
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
if file_parts.len() == 2 {
let status = parse_file_status(file_parts[0].trim());
let path = file_parts[1].trim();
match status {
"added" => commit.added += 1,
"deleted" => commit.deleted += 1,
_ => commit.modified += 1,
}
commit.files.push(PulseFile {
path: path.to_string(),
status: status.to_string(),
title: title_from_path(path),
});
}
if let Some(ref mut commit) = current {
add_file_change(commit, line);
}
}
if let Some(commit) = current {
commits.push(commit);
}
push_current_commit(&mut commits, &mut current);
commits
}
fn is_git_status_line(line: &str) -> bool {
line.starts_with(|c: char| {
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
})
}
fn is_commit_header(line: &str) -> bool {
line.contains('|') && !is_git_status_line(line)
}
fn push_current_commit(commits: &mut Vec<PulseCommit>, current: &mut Option<PulseCommit>) {
if let Some(commit) = current.take() {
commits.push(commit);
}
}
fn parse_commit_header(line: &str, github_base: &Option<String>) -> Option<PulseCommit> {
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() != 4 {
return None;
}
let hash = parts[0];
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
.map(|dt| dt.timestamp())
.unwrap_or(0);
let github_url = github_base
.as_ref()
.map(|base| format!("{}/commit/{}", base, hash));
Some(PulseCommit {
hash: hash.to_string(),
short_hash: parts[1].to_string(),
message: parts[2].to_string(),
date,
github_url,
files: Vec::new(),
added: 0,
modified: 0,
deleted: 0,
})
}
fn add_file_change(commit: &mut PulseCommit, line: &str) {
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
if file_parts.len() != 2 {
return;
}
let status = parse_file_status(file_parts[0].trim());
let path = file_parts[1].trim();
match status {
"added" => commit.added += 1,
"deleted" => commit.deleted += 1,
_ => commit.modified += 1,
}
commit.files.push(PulseFile {
path: path.to_string(),
status: status.to_string(),
title: title_from_path(path),
});
}
/// Get the last commit's short hash and a GitHub URL (if remote is GitHub).
pub fn get_last_commit_info(vault_path: &str) -> Result<Option<LastCommitInfo>, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
let output = git_command()
.args(["log", "-1", "--format=%H|%h"])
.current_dir(vault)
.output()
@@ -223,23 +243,7 @@ pub fn get_last_commit_info(vault_path: &str) -> Result<Option<LastCommitInfo>,
/// Try to build a GitHub commit URL from the origin remote URL.
fn get_github_commit_url(vault_path: &str, full_hash: &str) -> Option<String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(vault)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let repo_path = parse_github_repo_path(&url)?;
Some(format!(
"https://github.com/{}/commit/{}",
repo_path, full_hash
))
get_github_base_url(vault_path).map(|base| format!("{}/commit/{}", base, full_hash))
}
#[cfg(test)]

View File

@@ -1,6 +1,6 @@
use super::git_command;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
use super::conflict::get_conflict_files;
@@ -17,7 +17,7 @@ pub struct GitPullResult {
/// Check whether the vault repo has at least one remote configured.
pub fn has_remote(vault_path: &str) -> Result<bool, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
let output = git_command()
.args(["remote"])
.current_dir(vault)
.output()
@@ -40,7 +40,7 @@ pub fn git_pull(vault_path: &str) -> Result<GitPullResult, String> {
});
}
let output = Command::new("git")
let output = git_command()
.args(["pull", "--no-rebase"])
.current_dir(vault)
.output()
@@ -134,14 +134,14 @@ pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
}
// Fetch latest remote refs (silent, best-effort)
let _ = Command::new("git")
let _ = git_command()
.args(["fetch", "--quiet"])
.current_dir(vault)
.output();
let branch = current_branch(vault)?;
let output = Command::new("git")
let output = git_command()
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
.current_dir(vault)
.output()
@@ -171,7 +171,7 @@ pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
}
fn current_branch(vault: &Path) -> Result<String, String> {
let output = Command::new("git")
let output = git_command()
.args(["branch", "--show-current"])
.current_dir(vault)
.output()
@@ -189,59 +189,93 @@ pub struct GitPushResult {
pub fn classify_push_error(stderr: &str) -> GitPushResult {
let lower = stderr.to_lowercase();
if lower.contains("non-fast-forward")
|| lower.contains("[rejected]")
|| lower.contains("fetch first")
|| lower.contains("failed to push some refs")
&& (lower.contains("updates were rejected") || lower.contains("non-fast-forward"))
{
return GitPushResult {
status: "rejected".to_string(),
message: "Push rejected: remote has new commits. Pull first, then push.".to_string(),
};
if is_rejected_push_error(&lower) {
return push_error(
"rejected",
"Push rejected: remote has new commits. Pull first, then push.",
);
}
if lower.contains("authentication failed")
|| lower.contains("could not read username")
|| lower.contains("permission denied")
|| lower.contains("403")
|| lower.contains("invalid credentials")
{
return GitPushResult {
status: "auth_error".to_string(),
message: "Push failed: authentication error. Check your credentials.".to_string(),
};
if is_auth_push_error(&lower) {
return push_error(
"auth_error",
"Push failed: authentication error. Check your credentials.",
);
}
if lower.contains("could not resolve host")
|| lower.contains("unable to access")
|| lower.contains("connection refused")
|| lower.contains("network is unreachable")
|| lower.contains("timed out")
{
return GitPushResult {
status: "network_error".to_string(),
message: "Push failed: network error. Check your connection and try again.".to_string(),
};
if is_network_push_error(&lower) {
return push_error(
"network_error",
"Push failed: network error. Check your connection and try again.",
);
}
// Fallback: extract the hint line if present, otherwise use the full stderr
push_error(
"error",
format!("Push failed: {}", push_error_detail(stderr)),
)
}
fn push_error(status: &str, message: impl Into<String>) -> GitPushResult {
GitPushResult {
status: status.to_string(),
message: message.into(),
}
}
fn contains_any(haystack: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| haystack.contains(needle))
}
fn is_rejected_push_error(lower: &str) -> bool {
contains_any(lower, &["non-fast-forward", "[rejected]", "fetch first"])
|| (lower.contains("failed to push some refs")
&& contains_any(lower, &["updates were rejected", "non-fast-forward"]))
}
fn is_auth_push_error(lower: &str) -> bool {
contains_any(
lower,
&[
"authentication failed",
"could not read username",
"permission denied",
"403",
"invalid credentials",
],
)
}
fn is_network_push_error(lower: &str) -> bool {
contains_any(
lower,
&[
"could not resolve host",
"unable to access",
"connection refused",
"network is unreachable",
"timed out",
],
)
}
fn push_error_detail(stderr: &str) -> String {
let hint_line = stderr
.lines()
.find(|l| l.trim_start().starts_with("hint:"))
.map(|l| l.trim_start().strip_prefix("hint:").unwrap_or(l).trim())
.find(|line| line.trim_start().starts_with("hint:"))
.map(|line| {
line.trim_start()
.strip_prefix("hint:")
.unwrap_or(line)
.trim()
})
.unwrap_or("")
.to_string();
let detail = if hint_line.is_empty() {
if hint_line.is_empty() {
stderr.trim().to_string()
} else {
hint_line
};
GitPushResult {
status: "error".to_string(),
message: format!("Push failed: {detail}"),
}
}
@@ -249,7 +283,7 @@ pub fn classify_push_error(stderr: &str) -> GitPushResult {
pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
let output = git_command()
.args(["push"])
.current_dir(vault)
.output()
@@ -272,7 +306,6 @@ mod tests {
use crate::git::git_commit;
use crate::git::tests::{setup_git_repo, setup_remote_pair};
use std::fs;
use std::process::Command;
#[test]
fn test_has_remote_returns_false_for_local_repo() {
@@ -289,7 +322,7 @@ mod tests {
let vault = dir.path();
let vp = vault.to_str().unwrap();
Command::new("git")
git_command()
.args(["remote", "add", "origin", "https://example.com/repo.git"])
.current_dir(vault)
.output()

View File

@@ -1,7 +1,7 @@
use super::git_command;
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Serialize, Clone)]
pub struct ModifiedFile {
@@ -23,14 +23,34 @@ struct DiffStats {
binary: bool,
}
fn status_label(status_code: &str) -> &'static str {
match status_code.trim() {
"M" | "MM" | "AM" => "modified",
"A" => "added",
"D" => "deleted",
"??" => "untracked",
"R" | "RM" => "renamed",
_ => "modified",
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileChangeStatus {
Modified,
Added,
Deleted,
Untracked,
Renamed,
}
impl FileChangeStatus {
fn from_code(status_code: &str) -> Self {
match status_code.trim() {
"A" => Self::Added,
"D" => Self::Deleted,
"??" => Self::Untracked,
"R" | "RM" => Self::Renamed,
_ => Self::Modified,
}
}
fn label(self) -> &'static str {
match self {
Self::Added => "added",
Self::Deleted => "deleted",
Self::Untracked => "untracked",
Self::Renamed => "renamed",
Self::Modified => "modified",
}
}
}
@@ -68,7 +88,7 @@ fn parse_numstat_line(line: &str) -> Option<(String, DiffStats)> {
}
fn repo_has_head(vault: &Path) -> Result<bool, String> {
let output = Command::new("git")
let output = git_command()
.args(["rev-parse", "--verify", "HEAD"])
.current_dir(vault)
.output()
@@ -82,7 +102,7 @@ fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
return Ok(HashMap::new());
}
let output = Command::new("git")
let output = git_command()
.args(["diff", "--numstat", "--find-renames", "HEAD", "--"])
.current_dir(vault)
.output()
@@ -100,7 +120,7 @@ fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
.collect::<HashMap<_, _>>())
}
fn count_worktree_lines(vault: &Path, relative_path: &str) -> DiffStats {
fn count_worktree_lines(vault: &Path, relative_path: &Path) -> DiffStats {
let full_path = vault.join(relative_path);
let added_lines = std::fs::read_to_string(full_path)
.ok()
@@ -115,19 +135,20 @@ fn count_worktree_lines(vault: &Path, relative_path: &str) -> DiffStats {
fn resolve_diff_stats(
vault: &Path,
relative_path: &str,
status: &str,
relative_path: &Path,
status: FileChangeStatus,
diff_stats: &HashMap<String, DiffStats>,
) -> DiffStats {
if status == "untracked" {
if status == FileChangeStatus::Untracked {
return count_worktree_lines(vault, relative_path);
}
diff_stats.get(relative_path).copied().unwrap_or_default()
let key = relative_path.to_string_lossy();
diff_stats.get(key.as_ref()).copied().unwrap_or_default()
}
fn ensure_path_within_vault(vault: &Path, relative_path: &str, abs: &Path) -> Result<(), String> {
for component in Path::new(relative_path).components() {
fn ensure_path_within_vault(vault: &Path, relative_path: &Path, abs: &Path) -> Result<(), String> {
for component in relative_path.components() {
if matches!(component, std::path::Component::ParentDir) {
return Err("File path is outside the vault".into());
}
@@ -151,9 +172,10 @@ fn ensure_path_within_vault(vault: &Path, relative_path: &str, abs: &Path) -> Re
}
}
fn load_file_status(vault: &Path, relative_path: &str) -> Result<String, String> {
let output = Command::new("git")
.args(["status", "--porcelain", "--", relative_path])
fn load_file_status(vault: &Path, relative_path: &Path) -> Result<String, String> {
let output = git_command()
.args(["status", "--porcelain", "--"])
.arg(relative_path)
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git status: {e}"))?;
@@ -166,14 +188,16 @@ fn load_file_status(vault: &Path, relative_path: &str) -> Result<String, String>
.unwrap_or_default())
}
fn restore_tracked_file(vault: &Path, relative_path: &str) -> Result<(), String> {
let _ = Command::new("git")
.args(["reset", "HEAD", "--", relative_path])
fn restore_tracked_file(vault: &Path, relative_path: &Path) -> Result<(), String> {
let _ = git_command()
.args(["reset", "HEAD", "--"])
.arg(relative_path)
.current_dir(vault)
.output();
let checkout = Command::new("git")
.args(["checkout", "--", relative_path])
let checkout = git_command()
.args(["checkout", "--"])
.arg(relative_path)
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git checkout: {e}"))?;
@@ -189,7 +213,7 @@ fn restore_tracked_file(vault: &Path, relative_path: &str) -> Result<(), String>
/// Get list of modified/added/deleted files in the vault (uncommitted changes).
pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
let output = git_command()
.args(["status", "--porcelain", "--untracked-files=all"])
.current_dir(vault)
.output()
@@ -217,14 +241,14 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
return None;
}
let status = status_label(status_code);
let status = FileChangeStatus::from_code(status_code);
let full_path = vault.join(&relative_path).to_string_lossy().to_string();
let stats = resolve_diff_stats(vault, &relative_path, status, &diff_stats);
let stats = resolve_diff_stats(vault, Path::new(&relative_path), status, &diff_stats);
Some(ModifiedFile {
path: full_path,
relative_path,
status: status.to_string(),
status: status.label().to_string(),
added_lines: stats.added_lines,
deleted_lines: stats.deleted_lines,
binary: stats.binary,
@@ -244,10 +268,11 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
/// returned by [`get_modified_files`]).
pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
let abs = vault.join(relative_path);
let relative = Path::new(relative_path);
let abs = vault.join(relative);
ensure_path_within_vault(vault, relative_path, &abs)?;
let status_code = load_file_status(vault, relative_path)?;
ensure_path_within_vault(vault, relative, &abs)?;
let status_code = load_file_status(vault, relative)?;
match status_code.as_str() {
"??" => {
@@ -255,7 +280,7 @@ pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(),
.map_err(|e| format!("Failed to delete untracked file: {e}"))?;
}
_ => {
restore_tracked_file(vault, relative_path)?;
restore_tracked_file(vault, relative)?;
}
}
@@ -264,11 +289,11 @@ pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(),
#[cfg(test)]
mod tests {
use super::git_command;
use super::*;
use crate::git::git_commit;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
fn write_and_commit_markdown(vault: &Path, vp: &str, relative_path: &str, content: &str) {
fs::write(vault.join(relative_path), content).unwrap();
@@ -282,12 +307,12 @@ mod tests {
// Create and commit a file
fs::write(vault.join("note.md"), "# Note\n").unwrap();
Command::new("git")
git_command()
.args(["add", "note.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "Add note"])
.current_dir(vault)
.output()
@@ -327,12 +352,12 @@ mod tests {
// Create initial commit so git is initialized
fs::write(vault.join("init.md"), "# Init\n").unwrap();
Command::new("git")
git_command()
.args(["add", "init.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "Initial"])
.current_dir(vault)
.output()

View File

@@ -13,6 +13,9 @@ pub mod telemetry;
pub mod vault;
pub mod vault_list;
use std::ffi::OsStr;
use std::process::Command;
#[cfg(desktop)]
use std::path::{Path, PathBuf};
#[cfg(desktop)]
@@ -20,6 +23,24 @@ use std::process::Child;
#[cfg(desktop)]
use std::sync::Mutex;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
pub(crate) fn hidden_command(program: impl AsRef<OsStr>) -> Command {
let mut command = Command::new(program);
suppress_windows_console(&mut command);
command
}
#[cfg(windows)]
fn suppress_windows_console(command: &mut Command) {
use std::os::windows::process::CommandExt;
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn suppress_windows_console(_command: &mut Command) {}
#[cfg(desktop)]
struct WsBridgeChild(Mutex<Option<Child>>);
@@ -257,6 +278,7 @@ macro_rules! app_invoke_handler {
commands::stream_ai_agent,
commands::reload_vault,
commands::reload_vault_entry,
commands::sync_vault_asset_scope_for_window,
commands::sync_note_title,
commands::save_image,
commands::copy_image_to_vault,

View File

@@ -17,10 +17,9 @@ pub enum McpStatus {
/// Find the `node` binary path at runtime.
pub(crate) fn find_node() -> Result<PathBuf, String> {
let output = Command::new("which")
.arg("node")
let output = node_lookup_command()
.output()
.map_err(|e| format!("Failed to run `which node`: {e}"))?;
.map_err(|e| format!("Failed to locate node on PATH: {e}"))?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
@@ -35,6 +34,16 @@ pub(crate) fn find_node() -> Result<PathBuf, String> {
Err("node not found in PATH or common install locations".into())
}
fn node_lookup_command() -> Command {
#[cfg(windows)]
let mut command = crate::hidden_command("where.exe");
#[cfg(not(windows))]
let mut command = crate::hidden_command("which");
command.arg("node");
command
}
fn fallback_node_path() -> Option<PathBuf> {
let mut candidates = vec![
PathBuf::from("/opt/homebrew/bin/node"),
@@ -99,7 +108,7 @@ pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
let server_dir = mcp_server_dir()?;
let script = server_dir.join("ws-bridge.js");
let child = Command::new(node)
let child = crate::hidden_command(node)
.arg(&script)
.env("VAULT_PATH", vault_path)
.env("WS_PORT", "9710")

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

@@ -125,7 +125,7 @@ fn git_head_hash(vault: &Path) -> Option<String> {
/// Run a git command in the given directory and return stdout if successful.
fn run_git(vault: &Path, args: &[&str]) -> Option<String> {
let output = std::process::Command::new("git")
let output = crate::hidden_command("git")
.args(args)
.current_dir(vault)
.output()
@@ -510,7 +510,7 @@ fn migrate_legacy_cache(vault: &Path) {
}
// Remove legacy file from git tracking if present
let _ = std::process::Command::new("git")
let _ = crate::hidden_command("git")
.args([
"rm",
"--cached",
@@ -727,17 +727,17 @@ mod tests {
}
fn init_git_repo(vault: &Path) {
std::process::Command::new("git")
crate::hidden_command("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
crate::hidden_command("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
crate::hidden_command("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
@@ -756,12 +756,12 @@ mod tests {
}
fn git_add_commit(vault: &Path, msg: &str) {
std::process::Command::new("git")
crate::hidden_command("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
crate::hidden_command("git")
.args(["commit", "-m", msg])
.current_dir(vault)
.output()
@@ -1121,7 +1121,7 @@ mod tests {
// Delete file via filesystem (simulates Finder delete)
fs::remove_file(vault.join("remove.md")).unwrap();
// Also stage the deletion so git status is clean for this file
std::process::Command::new("git")
crate::hidden_command("git")
.args(["add", "remove.md"])
.current_dir(vault)
.output()

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

@@ -1,6 +1,5 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Public starter vault cloned when the user chooses Getting Started.
pub const GETTING_STARTED_REPO_URL: &str =
@@ -578,7 +577,7 @@ fn refresh_cloned_vault_config_files(vault_path: &Path) -> Result<(), String> {
}
fn vault_has_pending_changes(vault_path: &Path) -> Result<bool, String> {
let output = Command::new("git")
let output = crate::hidden_command("git")
.args(["status", "--porcelain"])
.current_dir(vault_path)
.output()
@@ -599,7 +598,7 @@ fn ensure_commit_identity(vault_path: &Path) -> Result<(), String> {
("user.name", "Tolaria"),
("user.email", "vault@tolaria.app"),
] {
let output = Command::new("git")
let output = crate::hidden_command("git")
.args(["config", key])
.current_dir(vault_path)
.output()
@@ -609,7 +608,7 @@ fn ensure_commit_identity(vault_path: &Path) -> Result<(), String> {
continue;
}
let set_output = Command::new("git")
let set_output = crate::hidden_command("git")
.args(["config", key, fallback])
.current_dir(vault_path)
.output()

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,9 +495,8 @@ 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);
let output = std::process::Command::new("git")
pub fn detect_renames(vault: &Path) -> Result<Vec<DetectedRename>, String> {
let output = crate::hidden_command("git")
.args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"])
.current_dir(vault)
.output()
@@ -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

@@ -32,7 +32,7 @@
"script-src": "'self' https://us.i.posthog.com https://eu.i.posthog.com https://us-assets.i.posthog.com https://eu-assets.i.posthog.com",
"connect-src": "'self' ipc: http://ipc.localhost ws://localhost:9710 ws://127.0.0.1:9710 ws://localhost:9711 ws://127.0.0.1:9711 https:",
"img-src": "'self' asset: http://asset.localhost data: blob: https:",
"style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com",
"style-src": "'self' 'unsafe-inline' 'nonce-tolaria-codemirror-style' https://fonts.googleapis.com",
"font-src": "'self' data: https://fonts.gstatic.com",
"media-src": "'self' data: blob: https:",
"object-src": "'none'"
@@ -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 = (() => {
@@ -58,13 +59,30 @@ const mockEntries = [
belongsTo: [],
relatedTo: [],
status: 'Active',
archived: false,
owner: 'Luca',
cadence: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 1024,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
view: null,
visible: true,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
},
{
path: '/vault/topic/dev.md',
@@ -75,13 +93,30 @@ const mockEntries = [
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
owner: null,
cadence: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
view: null,
visible: true,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
},
]
@@ -107,6 +142,8 @@ const mockCommandResults: Record<string, unknown> = {
get_all_content: mockAllContent,
get_modified_files: [],
get_note_content: mockAllContent['/vault/project/test.md'] || '',
reload_vault_entry: ({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null,
sync_vault_asset_scope_for_window: null,
get_file_history: [],
get_settings: { auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null },
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
@@ -238,6 +275,8 @@ function resetMockCommandResults() {
get_all_content: mockAllContent,
get_modified_files: [],
get_note_content: mockAllContent['/vault/project/test.md'] || '',
reload_vault_entry: ({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null,
sync_vault_asset_scope_for_window: null,
get_file_history: [],
get_settings: {
auto_pull_interval_minutes: null,
@@ -305,6 +344,7 @@ vi.mock('@blocknote/react', () => ({
{children}
</div>
),
LinkToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
ComponentsContext: {
Provider: ({ children }: { children?: ReactNode }) => <>{children}</>,
},
@@ -317,8 +357,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', () => ({
@@ -343,6 +405,7 @@ describe('App', () => {
vi.clearAllMocks()
resetMockCommandResults()
localStorage.clear()
window.history.replaceState({}, '', '/')
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
})
@@ -367,10 +430,40 @@ describe('App', () => {
})
})
it('shows keyboard shortcut hints', async () => {
it('opens a note window by loading only the requested entry', async () => {
const listVault = vi.fn(() => mockEntries)
const reloadVaultEntry = vi.fn(({ path }: { path: string }) =>
mockEntries.find((entry) => entry.path === path) ?? null,
)
const getNoteContent = vi.fn(({ path }: { path: string }) => mockAllContent[path] ?? '')
mockCommandResults.list_vault = listVault
mockCommandResults.reload_vault_entry = reloadVaultEntry
mockCommandResults.get_note_content = getNoteContent
window.history.replaceState(
{},
'',
'/?window=note&path=%2Fvault%2Fproject%2Ftest.md&vault=%2Fvault&title=Test+Project',
)
render(<App />)
await waitFor(() => expect(reloadVaultEntry).toHaveBeenCalled())
expect(reloadVaultEntry).toHaveBeenCalledWith({ path: '/vault/project/test.md', vaultPath: '/vault' })
await waitFor(() => expect(getNoteContent).toHaveBeenCalled())
expect(getNoteContent).toHaveBeenCalledWith({ path: '/vault/project/test.md', vaultPath: '/vault' })
await waitFor(() => expect(window.__laputaTest?.activeTabPath).toBe('/vault/project/test.md'))
expect(listVault).not.toHaveBeenCalled()
})
it('shows keyboard shortcut hints', async () => {
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()
})
})
@@ -656,7 +749,7 @@ describe('App', () => {
render(<App />)
const noteListContainer = await screen.findByTestId('note-list-container')
const noteListContainer = await screen.findByTestId('note-list-container', {}, { timeout: 5000 })
const getHeader = () => getHeaderForNoteList(noteListContainer)
await waitFor(() => {

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'
@@ -80,7 +82,7 @@ import { initializeNoteProperties } from './utils/initializeNoteProperties'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, findNoteWindowEntry, type NoteWindowParams } from './utils/windowMode'
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, type NoteWindowParams } from './utils/windowMode'
import { GitRequiredModal } from './components/GitRequiredModal'
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents'
@@ -136,26 +138,26 @@ function shouldPreferOnboardingVaultPath(
&& !vaults.some((vault) => vault.path === onboardingState.vaultPath)
}
async function resolveNoteWindowEntry(
noteWindowParams: NoteWindowParams,
entries: VaultEntry[],
): Promise<VaultEntry | undefined> {
const fallbackEntry = () =>
findNoteWindowEntry(entries, noteWindowParams)
if (!isTauri()) {
return fallbackEntry()
}
async function resolveNoteWindowEntry(noteWindowParams: NoteWindowParams): Promise<VaultEntry | undefined> {
for (const path of getNoteWindowPathCandidates(noteWindowParams)) {
try {
return await invoke<VaultEntry>('reload_vault_entry', { path })
const request = { path, vaultPath: noteWindowParams.vaultPath }
const entry = isTauri()
? await invoke<VaultEntry | null>('reload_vault_entry', request)
: await mockInvoke<VaultEntry | null>('reload_vault_entry', request)
if (entry) return entry
} catch {
// Try the next normalized candidate before falling back to the scanned entries.
// Try the next normalized candidate before reporting the note as unavailable.
}
}
}
return fallbackEntry()
async function loadNoteWindowContent(path: string, vaultPath: string): Promise<string> {
const request = { path, vaultPath }
if (!isTauri()) return mockInvoke<string>('get_note_content', request)
await invoke('sync_vault_asset_scope_for_window', { vaultPath })
return invoke<string>('get_note_content', request)
}
function createPulseDeletedNoteEntry(fullPath: string, relativePath: string): DeletedNoteEntry {
@@ -251,7 +253,11 @@ function App() {
// called on user interaction, never during render (refs inside the hook
// guarantee the latest closure is always used).
const vaultSwitcher = useVaultSwitcher({
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
onSwitch: () => {
if (noteWindowParams) return
handleSetSelection(DEFAULT_SELECTION)
notes.closeAllTabs()
},
onToast: (msg) => setToastMessage(msg),
})
const {
@@ -322,7 +328,7 @@ function App() {
setGitRepoState('ready')
}, [resolvedPath])
const vault = useVaultLoader(resolvedPath)
const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath)
const {
status: vaultAiGuidanceStatus,
refresh: refreshVaultAiGuidance,
@@ -367,6 +373,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 +433,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)
@@ -486,6 +498,10 @@ function App() {
closeAllTabs,
openTabWithContent,
} = notes
const noteWindowActionsRef = useRef({ handleSelectNote, openTabWithContent })
useEffect(() => {
noteWindowActionsRef.current = { handleSelectNote, openTabWithContent }
}, [handleSelectNote, openTabWithContent])
const handlePulledVaultUpdate = useCallback(async (updatedFiles: string[]) => {
await refreshPulledVaultState({
activeTabPath: notes.activeTabPath,
@@ -518,33 +534,37 @@ function App() {
},
onToast: (msg) => setToastMessage(msg),
})
const pulseCommitDiffRequestIdRef = useRef(0)
const [pulseCommitDiffRequest, setPulseCommitDiffRequest] = useState<CommitDiffRequest | null>(null)
const pendingDiffRequestIdRef = useRef(0)
const [pendingDiffRequest, setPendingDiffRequest] = useState<CommitDiffRequest | null>(null)
// Note window: auto-open the note from URL params once vault entries load
// Note window: auto-open the note from URL params without scanning the whole vault.
const noteWindowOpenedRef = useRef(false)
const noteWindowMissingPathRef = useRef<string | null>(null)
useEffect(() => {
if (!noteWindowParams || noteWindowOpenedRef.current) return
let cancelled = false
void resolveNoteWindowEntry(noteWindowParams, vault.entries).then((entry) => {
if (cancelled || noteWindowOpenedRef.current) return
void resolveNoteWindowEntry(noteWindowParams).then(async (entry) => {
if (noteWindowOpenedRef.current) return
if (entry) {
noteWindowOpenedRef.current = true
noteWindowMissingPathRef.current = null
void handleSelectNote(entry)
try {
const content = await loadNoteWindowContent(entry.path, noteWindowParams.vaultPath)
if (noteWindowOpenedRef.current) return
noteWindowOpenedRef.current = true
noteWindowMissingPathRef.current = null
noteWindowActionsRef.current.openTabWithContent(entry, content)
} catch {
if (noteWindowOpenedRef.current) return
noteWindowOpenedRef.current = true
noteWindowMissingPathRef.current = null
void noteWindowActionsRef.current.handleSelectNote(entry)
}
return
}
if (noteWindowMissingPathRef.current === noteWindowParams.notePath) return
noteWindowMissingPathRef.current = noteWindowParams.notePath
setToastMessage(`Could not open "${noteWindowParams.noteTitle}" in this window`)
})
return () => {
cancelled = true
}
}, [handleSelectNote, noteWindowParams, setToastMessage, vault.entries])
}, [noteWindowParams, setToastMessage])
// Note window: update window title when active note changes
useEffect(() => {
@@ -554,7 +574,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)
@@ -580,17 +600,17 @@ function App() {
onSelectNote: notes.handleSelectNote,
})
const queuePulseCommitDiff = useCallback((path: string, commitHash: string) => {
pulseCommitDiffRequestIdRef.current += 1
setPulseCommitDiffRequest({
requestId: pulseCommitDiffRequestIdRef.current,
const queuePendingDiff = useCallback((path: string, commitHash?: string) => {
pendingDiffRequestIdRef.current += 1
setPendingDiffRequest({
requestId: pendingDiffRequestIdRef.current,
path,
commitHash,
})
}, [])
const handlePulseCommitDiffHandled = useCallback((requestId: number) => {
setPulseCommitDiffRequest((current) =>
const handlePendingDiffHandled = useCallback((requestId: number) => {
setPendingDiffRequest((current) =>
current?.requestId === requestId ? null : current,
)
}, [])
@@ -601,7 +621,7 @@ function App() {
if (commitHash) {
const targetPath = entry?.path ?? fullPath
queuePulseCommitDiff(targetPath, commitHash)
queuePendingDiff(targetPath, commitHash)
if (entry) {
void handleSelectNote(entry)
} else {
@@ -613,7 +633,7 @@ function App() {
if (entry) {
void handleSelectNote(entry)
}
}, [entriesByPath, resolvedPath, queuePulseCommitDiff, handleSelectNote, openTabWithContent])
}, [entriesByPath, resolvedPath, queuePendingDiff, handleSelectNote, openTabWithContent])
const handleOpenFavorite = useCallback(async (entry: VaultEntry) => {
await handleReplaceActiveTab(entry)
@@ -792,11 +812,18 @@ function App() {
}
notes.openTabWithContent(entry, previewContent)
if (hasDiff) {
setTimeout(() => diffToggleRef.current(), 50)
queuePendingDiff(entry.path)
} else {
setToastMessage('Content not available (untracked)')
}
}, [vault, notes, setToastMessage])
}, [vault, notes, queuePendingDiff, setToastMessage])
const handleReplaceActiveTabWithQueuedDiff = useCallback((entry: VaultEntry) => {
notes.handleReplaceActiveTab(entry)
if (effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'changes') {
queuePendingDiff(entry.path)
}
}, [effectiveSelection, notes, queuePendingDiff])
const commitFlow = useCommitFlow({
savePending: appSave.savePending,
@@ -1032,7 +1059,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) => {
@@ -1424,7 +1451,7 @@ function App() {
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -1438,8 +1465,8 @@ function App() {
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}
onLoadDiffAtCommit={vault.loadDiffAtCommit}
pendingCommitDiffRequest={pulseCommitDiffRequest}
onPendingCommitDiffHandled={handlePulseCommitDiffHandled}
pendingCommitDiffRequest={pendingDiffRequest}
onPendingCommitDiffHandled={handlePendingDiffHandled}
getNoteStatus={vault.getNoteStatus}
onCreateNote={notes.handleCreateNoteImmediate}
inspectorCollapsed={layout.inspectorCollapsed}
@@ -1490,7 +1517,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,16 @@
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 {
REFACTORING_HOME_URL,
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 +21,133 @@ 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('Sponsor / Support')).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(/Luca here .* newsletter for 170K\+ engineers/i)).toBeInTheDocument()
expect(screen.getByText(/private community of 2000\+ engineers/i)).toBeInTheDocument()
expect(screen.getByText(/Tolaria is FOSS and always will be/i)).toBeInTheDocument()
expect(screen.getByText('Search on the board first, upvote existing ideas, and create new posts when genuinely new!')).toBeInTheDocument()
expect(screen.getByText('Use Discussions for questions, conversations, show & tell, and community context.')).toBeInTheDocument()
expect(screen.getByText('Small, focused PRs are welcome. Check the board first so you build the right things!')).toBeInTheDocument()
expect(screen.getByText('Explain how to reproduce, what you expected, vs what happened. Attach the diagnostics please!')).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: 'Check out Refactoring' })
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: 'Check out Refactoring' }))
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, REFACTORING_HOME_URL))
expect(openExternalUrl).toHaveBeenNthCalledWith(2, TOLARIA_PRODUCT_BOARD_URL)
expect(openExternalUrl).toHaveBeenNthCalledWith(3, TOLARIA_GITHUB_DISCUSSIONS_URL)
expect(openExternalUrl).toHaveBeenNthCalledWith(4, TOLARIA_GITHUB_PULL_REQUESTS_URL)
expect(openExternalUrl).toHaveBeenNthCalledWith(5, TOLARIA_GITHUB_CONTRIBUTING_URL)
expect(openExternalUrl).toHaveBeenNthCalledWith(6, 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,457 @@
import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Megaphone } from '@phosphor-icons/react'
import { ArrowUpRight, Bug, Check, Copy, GitPullRequest, Lightbulb, MessagesSquare, Newspaper } 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 {
REFACTORING_HOME_URL,
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 = 'blue' | 'green' | 'yellow' | 'purple' | 'red'
const CONTRIBUTION_TONE_CLASSES: Record<ContributionTone, string> = {
blue: 'bg-[var(--accent-blue-light)] text-[var(--accent-blue)]',
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> = {
blue: 'border-[var(--accent-blue)] hover:bg-[var(--accent-blue-light)] [&_svg]:text-[var(--accent-blue)]',
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 SPONSOR_SUPPORT_PATH: ContributionPath = {
title: 'Sponsor / Support',
description: 'Luca here 👋 my full-time job is running Refactoring, a newsletter for 170K+ engineers about how to run good teams and ship software with AI. I write about workflows, interview tech leaders (e.g. DHH, Martin Fowler, and more) and run a private community of 2000+ engineers with monthly live coaching, AI club, and more.\n\nTolaria is FOSS and always will be. If you like it, the best way to support it is to subscribe to the newsletter.',
ctaLabel: 'Check out Refactoring',
label: 'Refactoring',
url: REFACTORING_HOME_URL,
icon: Newspaper,
tone: 'blue',
}
const CONTRIBUTION_PATHS: ContributionPath[] = [
{
title: 'Feature requests',
description: 'Search on the board first, upvote existing ideas, and create new posts when genuinely new!',
ctaLabel: 'Open Product Board',
label: 'Product Board',
url: TOLARIA_PRODUCT_BOARD_URL,
icon: Lightbulb,
tone: 'green',
},
{
title: 'Discussions',
description: 'Use Discussions for questions, conversations, show & tell, and community context.',
ctaLabel: 'Open Discussions',
label: 'GitHub Discussions',
url: TOLARIA_GITHUB_DISCUSSIONS_URL,
icon: MessagesSquare,
tone: 'purple',
},
{
title: 'Contribute code',
description: 'Small, focused PRs are welcome. Check the board first so you build the right things!',
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="whitespace-pre-line 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">
<div className="sm:col-span-2">
<ContributionCard
title={SPONSOR_SUPPORT_PATH.title}
description={SPONSOR_SUPPORT_PATH.description}
ctaLabel={SPONSOR_SUPPORT_PATH.ctaLabel}
icon={SPONSOR_SUPPORT_PATH.icon}
tone={SPONSOR_SUPPORT_PATH.tone}
autoFocus={true}
onAction={() => onOpenLink(SPONSOR_SUPPORT_PATH.label, SPONSOR_SUPPORT_PATH.url)}
/>
</div>
{CONTRIBUTION_PATHS.map((path) => {
const secondaryLink = path.secondaryLink
return (
<ContributionCard
key={path.title}
title={path.title}
description={path.description}
ctaLabel={path.ctaLabel}
icon={path.icon}
tone={path.tone}
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, vs what happened. Attach the diagnostics 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-[92dvh] overflow-y-auto sm:max-w-[820px]" 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

@@ -160,6 +160,8 @@ export function InlineWikilinkInput({
inputRef,
})
const pendingPasteRef = useRef<PendingPasteState | null>(null)
const isComposingRef = useRef(false)
const pendingCompositionInputRef = useRef(false)
const activeQuery = useMemo(
() => selectionRange.start === selectionRange.end
? findActiveWikilinkQuery(value, selectionIndex)
@@ -190,6 +192,7 @@ export function InlineWikilinkInput({
}
const notifyUnsupportedPaste = () => onUnsupportedPaste?.(UNSUPPORTED_INLINE_PASTE_MESSAGE)
const recoverUnsupportedMutation = () => {
pendingCompositionInputRef.current = false
pendingPasteRef.current = null
notifyUnsupportedPaste()
forceRender((current) => current + 1)
@@ -232,7 +235,7 @@ export function InlineWikilinkInput({
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const handleInput = () => {
const syncValueFromEditor = () => {
const editor = editorRef.current
if (editor && containsUnsupportedInlineContent(editor)) {
recoverUnsupportedMutation()
@@ -254,12 +257,34 @@ export function InlineWikilinkInput({
commitValueFromEditor()
}
const flushPendingCompositionInput = () => {
if (isComposingRef.current || !pendingCompositionInputRef.current) return
pendingCompositionInputRef.current = false
syncValueFromEditor()
}
const handleCompositionStart = () => {
isComposingRef.current = true
}
const handleCompositionEnd = () => {
isComposingRef.current = false
queueMicrotask(flushPendingCompositionInput)
}
const handleInput = () => {
if (isComposingRef.current) {
pendingCompositionInputRef.current = true
return
}
pendingCompositionInputRef.current = false
syncValueFromEditor()
}
const submitValue = () =>
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) =>
handleInlineWikilinkKeyDown({
event,
disabled,
isComposing: isComposingRef.current,
suggestionsOpen: suggestions.length > 0,
onCycleSuggestions: cycleSuggestions,
onSelectSuggestion: () => selectSuggestion(selectedSuggestionIndex),
@@ -278,6 +303,8 @@ export function InlineWikilinkInput({
dataTestId={dataTestId}
editorClassName={editorClassName}
onBeforeInput={handleBeforeInput}
onCompositionEnd={handleCompositionEnd}
onCompositionStart={handleCompositionStart}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}

View File

@@ -159,6 +159,8 @@ export function InlineWikilinkEditorField({
dataTestId,
editorClassName,
onBeforeInput,
onCompositionEnd,
onCompositionStart,
onInput,
onKeyDown,
onPaste,
@@ -173,6 +175,8 @@ export function InlineWikilinkEditorField({
dataTestId: string
editorClassName?: string
onBeforeInput: (event: React.FormEvent<HTMLDivElement>) => void
onCompositionEnd: () => void
onCompositionStart: () => void
onInput: () => void
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
@@ -207,6 +211,8 @@ export function InlineWikilinkEditorField({
editorClassName,
)}
onBeforeInput={onBeforeInput}
onCompositionEnd={onCompositionEnd}
onCompositionStart={onCompositionStart}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}

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,6 +1,12 @@
import { useState } from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import {
createEvent,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'
import { WikilinkChatInput } from './WikilinkChatInput'
import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput'
import type { VaultEntry } from '../types'
@@ -41,19 +47,25 @@ function Controlled({
onUnsupportedPaste,
disabled = false,
placeholder,
onDraftChange,
}: {
onSend?: (text: string, refs: Array<{ title: string; path: string; type: string | null }>) => void
onUnsupportedPaste?: (message: string) => void
disabled?: boolean
placeholder?: string
onDraftChange?: (value: string) => void
}) {
const [value, setValue] = useState('')
const handleChange = (nextValue: string) => {
onDraftChange?.(nextValue)
setValue(nextValue)
}
return (
<WikilinkChatInput
entries={entries}
value={value}
onChange={setValue}
onChange={handleChange}
onSend={onSend ?? vi.fn()}
onUnsupportedPaste={onUnsupportedPaste}
disabled={disabled}
@@ -92,6 +104,21 @@ function clickFirstSuggestion() {
fireEvent.click(rows[0])
}
function fireComposingKeyDown(editor: HTMLElement, key: string) {
const event = createEvent.keyDown(editor, {
key,
keyCode: 229,
which: 229,
})
Object.defineProperty(event, 'isComposing', {
configurable: true,
value: true,
})
fireEvent(editor, event)
}
describe('WikilinkChatInput', () => {
it('renders the placeholder overlay for an empty draft', () => {
render(<Controlled placeholder="Ask something..." />)
@@ -145,6 +172,45 @@ describe('WikilinkChatInput', () => {
expect(onSend).not.toHaveBeenCalled()
})
it('does not hijack Enter while IME composition is active', async () => {
const onDraftChange = vi.fn()
const onSend = vi.fn()
render(<Controlled onDraftChange={onDraftChange} onSend={onSend} />)
const editor = screen.getByTestId('agent-input')
fireEvent.focus(editor)
fireEvent.compositionStart(editor)
editor.textContent = 'ni'
setSelection(editor, 2)
fireEvent.input(editor)
expect(onDraftChange).not.toHaveBeenCalled()
fireComposingKeyDown(editor, 'Enter')
expect(onSend).not.toHaveBeenCalled()
editor.textContent = '你'
setSelection(editor, 1)
fireEvent.compositionEnd(editor)
await waitFor(() => {
expect(onDraftChange).toHaveBeenCalledWith('你')
})
expect(editor.textContent).toContain('你')
})
it('does not select wikilink suggestions while IME composition is active', () => {
render(<Controlled />)
updateEditorText('[[a')
const editor = screen.getByTestId('agent-input')
fireEvent.compositionStart(editor)
fireComposingKeyDown(editor, 'Enter')
expect(screen.queryByTestId('inline-wikilink-chip')).toBeNull()
expect(screen.getByTestId('wikilink-menu').textContent).toContain('Alpha')
})
it('deletes an inline chip with a single Backspace', () => {
render(<Controlled />)
updateEditorText('edit my [[alp')

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

@@ -1,7 +1,18 @@
import type React from 'react'
function isInlineWikilinkCompositionEvent(
event: React.KeyboardEvent<HTMLDivElement>,
isComposing: boolean,
) {
return isComposing
|| event.nativeEvent.isComposing
|| event.key === 'Process'
|| event.keyCode === 229
}
interface HandleSuggestionKeysArgs {
event: React.KeyboardEvent<HTMLDivElement>
isComposing: boolean
suggestionsOpen: boolean
onCycleSuggestions: (direction: 1 | -1) => void
onSelectSuggestion: () => void
@@ -9,11 +20,13 @@ interface HandleSuggestionKeysArgs {
function handleSuggestionKeys({
event,
isComposing,
suggestionsOpen,
onCycleSuggestions,
onSelectSuggestion,
}: HandleSuggestionKeysArgs): boolean {
if (!suggestionsOpen) return false
if (isInlineWikilinkCompositionEvent(event, isComposing)) return false
if (event.key === 'ArrowDown') {
event.preventDefault()
@@ -38,13 +51,17 @@ function handleSuggestionKeys({
interface HandleDeleteKeysArgs {
event: React.KeyboardEvent<HTMLDivElement>
isComposing: boolean
onDeleteContent: (direction: 'backward' | 'forward') => void
}
function handleDeleteKeys({
event,
isComposing,
onDeleteContent,
}: HandleDeleteKeysArgs): boolean {
if (isInlineWikilinkCompositionEvent(event, isComposing)) return false
if (event.key === 'Backspace') {
event.preventDefault()
onDeleteContent('backward')
@@ -62,15 +79,17 @@ function handleDeleteKeys({
interface HandleInsertTextArgs {
event: React.KeyboardEvent<HTMLDivElement>
isComposing: boolean
onInsertText: (text: string) => void
}
function handleInsertText({
event,
isComposing,
onInsertText,
}: HandleInsertTextArgs): boolean {
if (event.metaKey || event.ctrlKey || event.altKey) return false
if (event.nativeEvent.isComposing) return false
if (isInlineWikilinkCompositionEvent(event, isComposing)) return false
if (event.key.length !== 1) return false
event.preventDefault()
@@ -80,16 +99,19 @@ function handleInsertText({
interface HandleSubmitKeyArgs {
event: React.KeyboardEvent<HTMLDivElement>
isComposing: boolean
canSubmit: boolean
onSubmit: () => void
}
function handleSubmitKey({
event,
isComposing,
canSubmit,
onSubmit,
}: HandleSubmitKeyArgs): boolean {
if (!canSubmit) return false
if (isInlineWikilinkCompositionEvent(event, isComposing)) return false
if (event.key !== 'Enter' || event.shiftKey) return false
event.preventDefault()
@@ -100,6 +122,7 @@ function handleSubmitKey({
interface HandleInlineWikilinkKeyDownArgs {
event: React.KeyboardEvent<HTMLDivElement>
disabled: boolean
isComposing: boolean
suggestionsOpen: boolean
onCycleSuggestions: (direction: 1 | -1) => void
onSelectSuggestion: () => void
@@ -112,6 +135,7 @@ interface HandleInlineWikilinkKeyDownArgs {
export function handleInlineWikilinkKeyDown({
event,
disabled,
isComposing,
suggestionsOpen,
onCycleSuggestions,
onSelectSuggestion,
@@ -124,6 +148,7 @@ export function handleInlineWikilinkKeyDown({
if (handleSuggestionKeys({
event,
isComposing,
suggestionsOpen,
onCycleSuggestions,
onSelectSuggestion,
@@ -131,13 +156,13 @@ export function handleInlineWikilinkKeyDown({
return
}
if (handleDeleteKeys({ event, onDeleteContent })) {
if (handleDeleteKeys({ event, isComposing, onDeleteContent })) {
return
}
if (handleInsertText({ event, onInsertText })) {
if (handleInsertText({ event, isComposing, onInsertText })) {
return
}
handleSubmitKey({ event, canSubmit, onSubmit })
handleSubmitKey({ event, isComposing, canSubmit, onSubmit })
}

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,6 @@
export const REFACTORING_HOME_URL = 'https://refactoring.fm/'
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'

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