Compare commits
15 Commits
alpha-v202
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f4e5b585f | ||
|
|
cd49c81bd3 | ||
|
|
b9f3dc9e1a | ||
|
|
122aba7878 | ||
|
|
05896bde19 | ||
|
|
87a933f39c | ||
|
|
54c0efa3e4 | ||
|
|
bdde0c8943 | ||
|
|
a474303eb9 | ||
|
|
4701485ee5 | ||
|
|
135a8a0adf | ||
|
|
0f5d7d5340 | ||
|
|
d6b3c0aef3 | ||
|
|
f896c01829 | ||
|
|
c3cff0c461 |
264
.github/workflows/release-stable.yml
vendored
264
.github/workflows/release-stable.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 2: Build each architecture in parallel
|
||||
# Phase 2: Build release bundles in parallel
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
@@ -249,12 +249,209 @@ jobs:
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
|
||||
retention-days: 1
|
||||
|
||||
build-linux:
|
||||
name: Build (linux-x86_64)
|
||||
needs: version
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Tauri Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
curl \
|
||||
wget \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached bundle artifacts
|
||||
run: |
|
||||
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Build Tauri app (Linux bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
|
||||
|
||||
- name: Upload Linux bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-windows:
|
||||
name: Build (windows-x86_64)
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\.cargo\registry
|
||||
~\.cargo\git
|
||||
src-tauri\target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.version.outputs.version }}"
|
||||
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
|
||||
$tauri.version = $version
|
||||
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
|
||||
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
|
||||
|
||||
- name: Validate Windows release env
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::$name is required to build signed Windows updater artifacts."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build Tauri app (Windows bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
|
||||
- name: Validate Windows bundles
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no installable NSIS or MSI bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Windows bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 3: Publish GitHub Release
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: GitHub Release (stable)
|
||||
needs: [version, build]
|
||||
needs: [version, build, build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -282,7 +479,7 @@ jobs:
|
||||
echo "---"
|
||||
echo "**Stable release — manually promoted from \`main\`**"
|
||||
echo ""
|
||||
echo "**Requires Apple Silicon (M1/M2/M3)**"
|
||||
echo "**Includes macOS (Apple Silicon), Windows x64, and Linux x64 bundles**"
|
||||
echo ""
|
||||
echo "*Built from \`$(git rev-parse --short ${{ needs.version.outputs.tag }})\` on $(date -u +%Y-%m-%d)*"
|
||||
} > release_notes.md
|
||||
@@ -295,9 +492,34 @@ jobs:
|
||||
REPO_NAME="${REPO#*/}"
|
||||
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename)
|
||||
find_required() {
|
||||
for pattern in "$@"; do
|
||||
set -- $pattern
|
||||
if [ -e "$1" ]; then
|
||||
printf '%s\n' "$1"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig")
|
||||
ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}"
|
||||
ARM_SIG=$(cat "$ARM_SIG_FILE")
|
||||
ARM_TARBALL=$(basename "$ARM_UPDATER_FILE")
|
||||
ARM_DMG=$(basename "$(find_required "dmg-aarch64/*.dmg")")
|
||||
|
||||
LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig")
|
||||
LINUX_UPDATER_FILE="${LINUX_SIG_FILE%.sig}"
|
||||
LINUX_SIG=$(cat "$LINUX_SIG_FILE")
|
||||
LINUX_UPDATER=$(basename "$LINUX_UPDATER_FILE")
|
||||
LINUX_DOWNLOAD=$(basename "$(find_required "linux-x86_64-bundles/*/*.AppImage" "linux-x86_64-bundles/*/*.deb" "linux-x86_64-bundles/*/*.AppImage.tar.gz" "linux-x86_64-bundles/*.AppImage" "linux-x86_64-bundles/*.deb" "linux-x86_64-bundles/*.AppImage.tar.gz")")
|
||||
|
||||
WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*/*-setup.exe.sig" "windows-x86_64-bundles/*/*.msi.sig" "windows-x86_64-bundles/*/*.nsis.zip.sig" "windows-x86_64-bundles/*/*.msi.zip.sig" "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig")
|
||||
WINDOWS_UPDATER_FILE="${WINDOWS_SIG_FILE%.sig}"
|
||||
WINDOWS_SIG=$(cat "$WINDOWS_SIG_FILE")
|
||||
WINDOWS_UPDATER=$(basename "$WINDOWS_UPDATER_FILE")
|
||||
WINDOWS_DOWNLOAD=$(basename "$(find_required "windows-x86_64-bundles/*/*-setup.exe" "windows-x86_64-bundles/*/*.msi" "windows-x86_64-bundles/*/*.nsis.zip" "windows-x86_64-bundles/*/*.msi.zip" "windows-x86_64-bundles/*-setup.exe" "windows-x86_64-bundles/*.msi" "windows-x86_64-bundles/*.nsis.zip" "windows-x86_64-bundles/*.msi.zip")")
|
||||
|
||||
cat > stable-latest.json << EOF
|
||||
{
|
||||
@@ -309,6 +531,16 @@ jobs:
|
||||
"signature": "${ARM_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}",
|
||||
"dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"signature": "${LINUX_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_DOWNLOAD}"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": "${WINDOWS_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_DOWNLOAD}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,6 +559,26 @@ jobs:
|
||||
dmg-aarch64/*.dmg
|
||||
updater-aarch64/*.app.tar.gz
|
||||
updater-aarch64/*.app.tar.gz.sig
|
||||
linux-x86_64-bundles/*.deb
|
||||
linux-x86_64-bundles/*.AppImage
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
||||
linux-x86_64-bundles/*/*.deb
|
||||
linux-x86_64-bundles/*/*.AppImage
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz.sig
|
||||
windows-x86_64-bundles/*.exe
|
||||
windows-x86_64-bundles/*.exe.sig
|
||||
windows-x86_64-bundles/*.msi
|
||||
windows-x86_64-bundles/*.msi.sig
|
||||
windows-x86_64-bundles/*.zip
|
||||
windows-x86_64-bundles/*.zip.sig
|
||||
windows-x86_64-bundles/*/*.exe
|
||||
windows-x86_64-bundles/*/*.exe.sig
|
||||
windows-x86_64-bundles/*/*.msi
|
||||
windows-x86_64-bundles/*/*.msi.sig
|
||||
windows-x86_64-bundles/*/*.zip
|
||||
windows-x86_64-bundles/*/*.zip.sig
|
||||
stable-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
147
.github/workflows/release.yml
vendored
147
.github/workflows/release.yml
vendored
@@ -303,28 +303,14 @@ jobs:
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
|
||||
retention-days: 1
|
||||
|
||||
build-linux:
|
||||
name: Build (linux-x86_64)
|
||||
build-windows:
|
||||
name: Build (windows-x86_64)
|
||||
needs: version
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Tauri Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
@@ -339,29 +325,45 @@ jobs:
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
~\.cargo\registry
|
||||
~\.cargo\git
|
||||
src-tauri\target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set version
|
||||
shell: pwsh
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
$version = "${{ needs.version.outputs.version }}"
|
||||
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
|
||||
$tauri.version = $version
|
||||
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
|
||||
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
|
||||
|
||||
- name: Build Tauri app (Linux bundles)
|
||||
- name: Validate Windows release env
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::$name is required to build signed Windows updater artifacts."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build Tauri app (Windows bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
@@ -370,26 +372,52 @@ jobs:
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
|
||||
- name: Upload Linux bundles
|
||||
- name: Validate Windows bundles
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no installable NSIS or MSI bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Windows bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-x86_64-bundles
|
||||
name: windows-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
retention-days: 7
|
||||
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
|
||||
retention-days: 1
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 3: Publish GitHub Release
|
||||
# No lipo/re-signing — use the per-arch artifacts directly
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: GitHub Release (alpha)
|
||||
needs: [version, build]
|
||||
needs: [version, build, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -437,7 +465,7 @@ jobs:
|
||||
echo "---"
|
||||
echo "**Alpha build — updated on every push to \`main\`**"
|
||||
echo ""
|
||||
echo "**Requires Apple Silicon (M1/M2/M3)**"
|
||||
echo "**Includes macOS (Apple Silicon) and Windows x64 bundles**"
|
||||
echo ""
|
||||
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
|
||||
} > release_notes.md
|
||||
@@ -450,8 +478,27 @@ jobs:
|
||||
REPO_NAME="${REPO#*/}"
|
||||
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
find_required() {
|
||||
for pattern in "$@"; do
|
||||
set -- $pattern
|
||||
if [ -e "$1" ]; then
|
||||
printf '%s\n' "$1"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig")
|
||||
ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}"
|
||||
ARM_SIG=$(cat "$ARM_SIG_FILE")
|
||||
ARM_UPDATER=$(basename "$ARM_UPDATER_FILE")
|
||||
|
||||
WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*/*-setup.exe.sig" "windows-x86_64-bundles/*/*.msi.sig" "windows-x86_64-bundles/*/*.nsis.zip.sig" "windows-x86_64-bundles/*/*.msi.zip.sig" "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig")
|
||||
WINDOWS_UPDATER_FILE="${WINDOWS_SIG_FILE%.sig}"
|
||||
WINDOWS_SIG=$(cat "$WINDOWS_SIG_FILE")
|
||||
WINDOWS_UPDATER=$(basename "$WINDOWS_UPDATER_FILE")
|
||||
WINDOWS_DOWNLOAD=$(basename "$(find_required "windows-x86_64-bundles/*/*-setup.exe" "windows-x86_64-bundles/*/*.msi" "windows-x86_64-bundles/*/*.nsis.zip" "windows-x86_64-bundles/*/*.msi.zip" "windows-x86_64-bundles/*-setup.exe" "windows-x86_64-bundles/*.msi" "windows-x86_64-bundles/*.nsis.zip" "windows-x86_64-bundles/*.msi.zip")")
|
||||
|
||||
cat > alpha-latest.json << EOF
|
||||
{
|
||||
@@ -461,7 +508,13 @@ jobs:
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
"signature": "${ARM_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}"
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": "${WINDOWS_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_DOWNLOAD}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -479,6 +532,18 @@ jobs:
|
||||
files: |
|
||||
updater-aarch64/*.app.tar.gz
|
||||
updater-aarch64/*.app.tar.gz.sig
|
||||
windows-x86_64-bundles/*.exe
|
||||
windows-x86_64-bundles/*.exe.sig
|
||||
windows-x86_64-bundles/*.msi
|
||||
windows-x86_64-bundles/*.msi.sig
|
||||
windows-x86_64-bundles/*.zip
|
||||
windows-x86_64-bundles/*.zip.sig
|
||||
windows-x86_64-bundles/*/*.exe
|
||||
windows-x86_64-bundles/*/*.exe.sig
|
||||
windows-x86_64-bundles/*/*.msi
|
||||
windows-x86_64-bundles/*/*.msi.sig
|
||||
windows-x86_64-bundles/*/*.zip
|
||||
windows-x86_64-bundles/*/*.zip.sig
|
||||
alpha-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
43
docs/adr/0081-internal-light-dark-theme-runtime.md
Normal file
43
docs/adr/0081-internal-light-dark-theme-runtime.md
Normal 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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
45
index.html
45
index.html
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::commands::expand_tilde;
|
||||
use crate::vault::filename_rules::validate_folder_name;
|
||||
use crate::vault::{self, FolderNode, VaultEntry};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -39,6 +40,31 @@ fn with_requested_root_path<T>(
|
||||
with_requested_root(raw_vault_path.as_ref(), action)
|
||||
}
|
||||
|
||||
fn sync_image_asset_scope(
|
||||
app_handle: &tauri::AppHandle,
|
||||
requested_root: &str,
|
||||
) -> Result<(), String> {
|
||||
#[cfg(desktop)]
|
||||
crate::sync_vault_asset_scope(app_handle, Path::new(requested_root))?;
|
||||
#[cfg(not(desktop))]
|
||||
let _ = requested_root;
|
||||
#[cfg(not(desktop))]
|
||||
let _ = app_handle;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn with_image_asset_scope(
|
||||
app_handle: &tauri::AppHandle,
|
||||
vault_path: &Path,
|
||||
action: impl FnOnce(&str) -> Result<String, String>,
|
||||
) -> Result<String, String> {
|
||||
with_requested_root_path(vault_path, |requested_root| {
|
||||
let saved_path = action(requested_root)?;
|
||||
sync_image_asset_scope(app_handle, requested_root)?;
|
||||
Ok(saved_path)
|
||||
})
|
||||
}
|
||||
|
||||
fn with_writable_note_path<T>(
|
||||
path: PathBuf,
|
||||
vault_path: Option<PathBuf>,
|
||||
@@ -114,6 +140,7 @@ pub fn create_vault_folder(vault_path: PathBuf, folder_name: PathBuf) -> Result<
|
||||
with_boundary(Some(raw_vault_path.as_ref()), |boundary| {
|
||||
let folder_name = folder_name.to_string_lossy();
|
||||
let folder_path = boundary.child_path(folder_name.as_ref())?;
|
||||
validate_folder_name(folder_name.as_ref())?;
|
||||
ensure_missing_folder(&folder_path, folder_name.as_ref())?;
|
||||
std::fs::create_dir_all(&folder_path)
|
||||
.map_err(|e| format!("Failed to create folder: {}", e))?;
|
||||
@@ -146,15 +173,24 @@ pub fn sync_note_title(path: PathBuf, vault_path: Option<PathBuf>) -> Result<boo
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_image(vault_path: PathBuf, filename: String, data: String) -> Result<String, String> {
|
||||
with_requested_root_path(vault_path.as_path(), |requested_root| {
|
||||
pub fn save_image(
|
||||
app_handle: tauri::AppHandle,
|
||||
vault_path: PathBuf,
|
||||
filename: String,
|
||||
data: String,
|
||||
) -> Result<String, String> {
|
||||
with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| {
|
||||
vault::save_image(requested_root, &filename, &data)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn copy_image_to_vault(vault_path: PathBuf, source_path: PathBuf) -> Result<String, String> {
|
||||
with_requested_root_path(vault_path.as_path(), |requested_root| {
|
||||
pub fn copy_image_to_vault(
|
||||
app_handle: tauri::AppHandle,
|
||||
vault_path: PathBuf,
|
||||
source_path: PathBuf,
|
||||
) -> Result<String, String> {
|
||||
with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| {
|
||||
vault::copy_image_to_vault(requested_root, source_path.to_string_lossy().as_ref())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
¬e_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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
®istered_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(¬e_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(), ®istered_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(¬e_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(), ®istered_roots),
|
||||
Some(nested_root),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
101
src-tauri/src/vault/filename_rules.rs
Normal file
101
src-tauri/src/vault/filename_rules.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::path::Path;
|
||||
use tempfile::NamedTempFile;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use super::filename_rules::validate_filename_stem;
|
||||
use super::rename_transaction::RenameWorkspace;
|
||||
use crate::frontmatter::{update_frontmatter_content, FrontmatterValue};
|
||||
|
||||
@@ -20,6 +21,34 @@ pub struct RenameResult {
|
||||
pub failed_updates: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RenameNoteRequest<'a> {
|
||||
pub vault_path: &'a str,
|
||||
pub old_path: &'a str,
|
||||
pub new_title: &'a str,
|
||||
pub old_title_hint: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RenameNoteFilenameRequest<'a> {
|
||||
pub vault_path: &'a str,
|
||||
pub old_path: &'a str,
|
||||
pub new_filename_stem: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct MoveNoteToFolderRequest<'a> {
|
||||
pub vault_path: &'a str,
|
||||
pub old_path: &'a str,
|
||||
pub destination_folder_path: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct AutoRenameUntitledRequest<'a> {
|
||||
pub vault_path: &'a str,
|
||||
pub note_path: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct WikilinkUpdateSummary {
|
||||
updated_files: usize,
|
||||
@@ -176,12 +205,13 @@ fn update_note_title_in_content(content: &str, new_title: &str) -> String {
|
||||
}
|
||||
|
||||
/// Strip vault prefix and .md suffix to get the relative path stem (e.g., "project/weekly-review").
|
||||
fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
|
||||
abs_path
|
||||
.strip_prefix(vault_prefix)
|
||||
.unwrap_or(abs_path)
|
||||
fn to_path_stem(path: &Path, vault_root: &Path) -> String {
|
||||
let relative = path.strip_prefix(vault_root).unwrap_or(path);
|
||||
let normalized = relative.to_string_lossy().replace('\\', "/");
|
||||
normalized
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(abs_path)
|
||||
.unwrap_or(&normalized)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
|
||||
@@ -197,8 +227,7 @@ fn persist_staged_note(staged: NamedTempFile, target_path: &Path) -> Result<(),
|
||||
|
||||
fn finalize_rename(vault: &Path, old_targets: &[&str], new_file: &Path) -> RenameResult {
|
||||
let new_path = new_file.to_string_lossy().to_string();
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
|
||||
let new_path_stem = to_path_stem(new_file, vault);
|
||||
let summary = update_wikilinks_in_vault(vault, old_targets, &new_path_stem, new_file);
|
||||
RenameResult {
|
||||
new_path,
|
||||
@@ -213,25 +242,19 @@ fn normalize_filename_stem(new_filename_stem: &str) -> Result<String, String> {
|
||||
if stem.is_empty() {
|
||||
return Err("New filename cannot be empty".to_string());
|
||||
}
|
||||
if is_invalid_filename_stem(stem) {
|
||||
return Err("Invalid filename".to_string());
|
||||
}
|
||||
validate_filename_stem(stem)?;
|
||||
Ok(stem.to_string())
|
||||
}
|
||||
|
||||
fn is_invalid_filename_stem(stem: &str) -> bool {
|
||||
stem == "." || stem == ".." || stem.contains('/') || stem.contains('\\')
|
||||
}
|
||||
|
||||
struct LoadedNote {
|
||||
content: String,
|
||||
filename: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
fn unchanged_result(path: &str) -> RenameResult {
|
||||
fn unchanged_result(path: &Path) -> RenameResult {
|
||||
RenameResult {
|
||||
new_path: path.to_string(),
|
||||
new_path: path.to_string_lossy().to_string(),
|
||||
updated_files: 0,
|
||||
failed_updates: 0,
|
||||
}
|
||||
@@ -245,20 +268,19 @@ fn validate_new_title(new_title: &str) -> Result<&str, String> {
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
fn ensure_existing_note(old_file: &Path, old_path: &str) -> Result<(), String> {
|
||||
fn ensure_existing_note(old_file: &Path) -> Result<(), String> {
|
||||
if old_file.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!("File does not exist: {}", old_path))
|
||||
Err(format!("File does not exist: {}", old_file.display()))
|
||||
}
|
||||
|
||||
fn load_note_for_title_rename(
|
||||
old_file: &Path,
|
||||
old_path: &str,
|
||||
old_title_hint: Option<&str>,
|
||||
) -> Result<LoadedNote, String> {
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let content = fs::read_to_string(old_file)
|
||||
.map_err(|e| format!("Failed to read {}: {}", old_file.display(), e))?;
|
||||
let filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
@@ -277,10 +299,9 @@ fn persist_title_only_update(
|
||||
workspace: &RenameWorkspace,
|
||||
old_file: &Path,
|
||||
updated_content: &str,
|
||||
old_path: &str,
|
||||
) -> Result<RenameResult, String> {
|
||||
persist_staged_note(workspace.stage_note_content(updated_content)?, old_file)?;
|
||||
Ok(unchanged_result(old_path))
|
||||
Ok(unchanged_result(old_file))
|
||||
}
|
||||
|
||||
/// Rename a note: update its frontmatter title, rename the file, and update wiki links across the vault.
|
||||
@@ -288,19 +309,14 @@ fn persist_title_only_update(
|
||||
/// When `old_title_hint` is provided it is used instead of extracting the title from
|
||||
/// the file's frontmatter/filename. This is needed when the caller has already saved
|
||||
/// updated content to disk before triggering the rename.
|
||||
pub fn rename_note(
|
||||
vault_path: &str,
|
||||
old_path: &str,
|
||||
new_title: &str,
|
||||
old_title_hint: Option<&str>,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
pub fn rename_note(request: RenameNoteRequest<'_>) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(request.vault_path);
|
||||
let old_file = Path::new(request.old_path);
|
||||
|
||||
recover_pending_rename_transactions(vault)?;
|
||||
ensure_existing_note(old_file, old_path)?;
|
||||
let new_title = validate_new_title(new_title)?;
|
||||
let loaded = load_note_for_title_rename(old_file, old_path, old_title_hint)?;
|
||||
ensure_existing_note(old_file)?;
|
||||
let new_title = validate_new_title(request.new_title)?;
|
||||
let loaded = load_note_for_title_rename(old_file, request.old_title_hint)?;
|
||||
|
||||
// Check both title and filename: even if the title in content matches,
|
||||
// the filename may still be stale (e.g. "untitled-note.md" after user changed H1).
|
||||
@@ -309,7 +325,7 @@ pub fn rename_note(
|
||||
let filename_matches = loaded.filename == expected_filename;
|
||||
|
||||
if title_unchanged && filename_matches {
|
||||
return Ok(unchanged_result(old_path));
|
||||
return Ok(unchanged_result(old_file));
|
||||
}
|
||||
|
||||
// Update content only if the title actually changed
|
||||
@@ -321,53 +337,50 @@ pub fn rename_note(
|
||||
let workspace = RenameWorkspace::new(vault)?;
|
||||
|
||||
if filename_matches {
|
||||
return persist_title_only_update(&workspace, old_file, &updated_content, old_path);
|
||||
return persist_title_only_update(&workspace, old_file, &updated_content);
|
||||
}
|
||||
|
||||
let parent_dir = old_file
|
||||
.parent()
|
||||
.ok_or("Cannot determine parent directory")?;
|
||||
let committed = workspace
|
||||
.operation(old_path, old_file)
|
||||
.operation(request.old_path, old_file)
|
||||
.rename_with_candidates(
|
||||
workspace.stage_note_content(&updated_content)?,
|
||||
&expected_filename,
|
||||
parent_dir,
|
||||
)?;
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let old_targets = collect_legacy_wikilink_targets(&loaded.title, old_path_stem);
|
||||
let old_path_stem = to_path_stem(old_file, vault);
|
||||
let old_targets = collect_legacy_wikilink_targets(&loaded.title, &old_path_stem);
|
||||
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
|
||||
}
|
||||
|
||||
/// Rename only the file path stem while preserving title/frontmatter content.
|
||||
pub fn rename_note_filename(
|
||||
vault_path: &str,
|
||||
old_path: &str,
|
||||
new_filename_stem: &str,
|
||||
request: RenameNoteFilenameRequest<'_>,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
let vault = Path::new(request.vault_path);
|
||||
let old_file = Path::new(request.old_path);
|
||||
|
||||
recover_pending_rename_transactions(vault)?;
|
||||
|
||||
if !old_file.exists() {
|
||||
return Err(format!("File does not exist: {}", old_path));
|
||||
return Err(format!("File does not exist: {}", old_file.display()));
|
||||
}
|
||||
|
||||
let normalized_stem = normalize_filename_stem(new_filename_stem)?;
|
||||
let normalized_stem = normalize_filename_stem(request.new_filename_stem)?;
|
||||
let old_filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let content = fs::read_to_string(old_file)
|
||||
.map_err(|e| format!("Failed to read {}: {}", request.old_path, e))?;
|
||||
let fm_title = extract_fm_title_value(&content);
|
||||
let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
|
||||
let new_filename = format!("{}.md", normalized_stem);
|
||||
|
||||
if old_filename == new_filename {
|
||||
return Ok(unchanged_result(old_path));
|
||||
return Ok(unchanged_result(old_file));
|
||||
}
|
||||
|
||||
let parent_dir = old_file
|
||||
@@ -376,38 +389,33 @@ pub fn rename_note_filename(
|
||||
let new_file = parent_dir.join(&new_filename);
|
||||
let workspace = RenameWorkspace::new(vault)?;
|
||||
let committed = workspace
|
||||
.operation(old_path, old_file)
|
||||
.operation(request.old_path, old_file)
|
||||
.rename_exact(workspace.stage_note_content(&content)?, &new_file)?;
|
||||
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
|
||||
let old_path_stem = to_path_stem(old_file, vault);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, &old_path_stem);
|
||||
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
|
||||
}
|
||||
|
||||
/// Move a note into a different folder while preserving its filename and content.
|
||||
pub fn move_note_to_folder(
|
||||
vault_path: &str,
|
||||
old_path: &str,
|
||||
destination_folder_path: &str,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
let destination_dir = Path::new(destination_folder_path);
|
||||
pub fn move_note_to_folder(request: MoveNoteToFolderRequest<'_>) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(request.vault_path);
|
||||
let old_file = Path::new(request.old_path);
|
||||
let destination_dir = Path::new(request.destination_folder_path);
|
||||
|
||||
recover_pending_rename_transactions(vault)?;
|
||||
ensure_existing_note(old_file, old_path)?;
|
||||
ensure_existing_note(old_file)?;
|
||||
|
||||
if !destination_dir.exists() {
|
||||
return Err(format!(
|
||||
"Folder does not exist: {}",
|
||||
destination_folder_path
|
||||
request.destination_folder_path
|
||||
));
|
||||
}
|
||||
if !destination_dir.is_dir() {
|
||||
return Err(format!(
|
||||
"Folder is not a directory: {}",
|
||||
destination_folder_path
|
||||
request.destination_folder_path
|
||||
));
|
||||
}
|
||||
|
||||
@@ -415,24 +423,23 @@ pub fn move_note_to_folder(
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let content = fs::read_to_string(old_file)
|
||||
.map_err(|e| format!("Failed to read {}: {}", request.old_path, e))?;
|
||||
let fm_title = extract_fm_title_value(&content);
|
||||
let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
|
||||
let new_file = destination_dir.join(&old_filename);
|
||||
|
||||
if new_file == old_file {
|
||||
return Ok(unchanged_result(old_path));
|
||||
return Ok(unchanged_result(old_file));
|
||||
}
|
||||
|
||||
let workspace = RenameWorkspace::new(vault)?;
|
||||
let committed = workspace
|
||||
.operation(old_path, old_file)
|
||||
.operation(request.old_path, old_file)
|
||||
.rename_exact(workspace.stage_note_content(&content)?, &new_file)?;
|
||||
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
|
||||
let old_path_stem = to_path_stem(old_file, vault);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, &old_path_stem);
|
||||
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
|
||||
}
|
||||
|
||||
@@ -451,10 +458,9 @@ fn is_untitled_filename(filename: &str) -> bool {
|
||||
/// Returns `Some(RenameResult)` if renamed, `None` if conditions not met.
|
||||
/// This is a ONE-SHOT rename: only fires for untitled-* files with an H1.
|
||||
pub fn auto_rename_untitled(
|
||||
vault_path: &str,
|
||||
note_path: &str,
|
||||
request: AutoRenameUntitledRequest<'_>,
|
||||
) -> Result<Option<RenameResult>, String> {
|
||||
let path = Path::new(note_path);
|
||||
let path = Path::new(request.note_path);
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
@@ -464,15 +470,20 @@ pub fn auto_rename_untitled(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", note_path, e))?;
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", request.note_path, e))?;
|
||||
|
||||
let h1_title = match super::parsing::extract_h1_title(&content) {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let result = rename_note(vault_path, note_path, &h1_title, None)?;
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: request.vault_path,
|
||||
old_path: request.note_path,
|
||||
new_title: &h1_title,
|
||||
old_title_hint: None,
|
||||
})?;
|
||||
Ok(Some(result))
|
||||
}
|
||||
|
||||
@@ -484,8 +495,7 @@ pub struct DetectedRename {
|
||||
}
|
||||
|
||||
/// Detect renamed files by comparing working tree against HEAD using git diff.
|
||||
pub fn detect_renames(vault_path: &str) -> Result<Vec<DetectedRename>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
pub fn detect_renames(vault: &Path) -> Result<Vec<DetectedRename>, String> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"])
|
||||
.current_dir(vault)
|
||||
@@ -521,28 +531,21 @@ pub fn detect_renames(vault_path: &str) -> Result<Vec<DetectedRename>, String> {
|
||||
/// Update wikilinks across the vault for a list of detected renames.
|
||||
/// Returns the total number of files updated.
|
||||
pub fn update_wikilinks_for_renames(
|
||||
vault_path: &str,
|
||||
vault: &Path,
|
||||
renames: &[DetectedRename],
|
||||
) -> Result<usize, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let mut total_updated = 0;
|
||||
|
||||
for rename in renames {
|
||||
let old_stem = rename
|
||||
.old_path
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(&rename.old_path);
|
||||
let new_stem = rename
|
||||
.new_path
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(&rename.new_path);
|
||||
let old_filename_stem = old_stem.split('/').next_back().unwrap_or(old_stem);
|
||||
let old_file = vault.join(&rename.old_path);
|
||||
let new_file = vault.join(&rename.new_path);
|
||||
let old_stem = to_path_stem(&old_file, vault);
|
||||
let new_stem = to_path_stem(&new_file, vault);
|
||||
let old_filename_stem = old_stem.split('/').next_back().unwrap_or(&old_stem);
|
||||
// Build title from filename stem (kebab-case → Title Case)
|
||||
let old_title = super::parsing::slug_to_title(old_filename_stem);
|
||||
// The new file is the exclude target (don't rewrite wikilinks inside the renamed file itself)
|
||||
let new_file = vault.join(&rename.new_path);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_stem);
|
||||
let summary = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, &old_stem);
|
||||
let summary = update_wikilinks_in_vault(vault, &old_targets, &new_stem, &new_file);
|
||||
total_updated += summary.updated_files;
|
||||
}
|
||||
|
||||
@@ -555,13 +558,13 @@ mod tests {
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_file(dir: &Path, name: &str, content: &str) {
|
||||
fn create_test_file(dir: &Path, name: impl AsRef<Path>, content: impl AsRef<[u8]>) {
|
||||
let file_path = dir.join(name);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
let mut file = fs::File::create(file_path).unwrap();
|
||||
file.write_all(content.as_bytes()).unwrap();
|
||||
file.write_all(content.as_ref()).unwrap();
|
||||
}
|
||||
|
||||
struct RenameTestRequest<'a> {
|
||||
@@ -577,16 +580,60 @@ mod tests {
|
||||
) -> (std::path::PathBuf, RenameResult) {
|
||||
create_test_file(vault, request.path, request.content);
|
||||
let old_path = vault.join(request.path);
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
request.new_title,
|
||||
request.old_title_hint,
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: request.new_title,
|
||||
old_title_hint: request.old_title_hint,
|
||||
})
|
||||
.unwrap();
|
||||
(old_path, result)
|
||||
}
|
||||
|
||||
fn create_current_note(vault: &Path, relative_path: impl AsRef<Path>) -> std::path::PathBuf {
|
||||
let relative_path = relative_path.as_ref();
|
||||
create_test_file(vault, relative_path, "# Current\n");
|
||||
vault.join(relative_path)
|
||||
}
|
||||
|
||||
fn assert_rename_note_filename_error<P>(
|
||||
new_filename_stem: impl AsRef<str>,
|
||||
existing_destination: Option<P>,
|
||||
expected_error: impl AsRef<str>,
|
||||
) where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let current_path = create_current_note(vault, "note/current.md");
|
||||
if let Some(existing_path) = existing_destination {
|
||||
create_test_file(vault, existing_path.as_ref(), "# Existing\n");
|
||||
}
|
||||
|
||||
let result = rename_note_filename(RenameNoteFilenameRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: current_path.to_str().unwrap(),
|
||||
new_filename_stem: new_filename_stem.as_ref(),
|
||||
});
|
||||
|
||||
assert_eq!(result.unwrap_err(), expected_error.as_ref());
|
||||
}
|
||||
|
||||
fn assert_move_note_to_folder_error(expected_error: impl AsRef<str>) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
|
||||
create_test_file(vault, "areas/weekly-review.md", "# Existing\n");
|
||||
|
||||
let result = move_note_to_folder(MoveNoteToFolderRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: vault.join("projects/weekly-review.md").to_str().unwrap(),
|
||||
destination_folder_path: vault.join("areas").to_str().unwrap(),
|
||||
});
|
||||
|
||||
assert_eq!(result.unwrap_err(), expected_error.as_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_to_slug() {
|
||||
assert_eq!(title_to_slug("Weekly Review"), "weekly-review");
|
||||
@@ -605,12 +652,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
None,
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "Sprint Retrospective",
|
||||
old_title_hint: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(result.new_path.ends_with("sprint-retrospective.md"));
|
||||
@@ -644,12 +691,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
None,
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "Sprint Retrospective",
|
||||
old_title_hint: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 2);
|
||||
@@ -669,12 +716,12 @@ mod tests {
|
||||
create_test_file(vault, "note/test.md", "# Test\n");
|
||||
|
||||
let old_path = vault.join("note/test.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
" ",
|
||||
None,
|
||||
);
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: " ",
|
||||
old_title_hint: None,
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -767,12 +814,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/old.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"New Name",
|
||||
None,
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "New Name",
|
||||
old_title_hint: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let content = fs::read_to_string(&result.new_path).unwrap();
|
||||
@@ -785,21 +832,25 @@ mod tests {
|
||||
|
||||
/// Helper: create a note, rename it, assert the rename succeeded and old file is gone.
|
||||
/// Returns the content of the renamed file for further assertions.
|
||||
fn rename_test_note(filename: &str, content: &str, new_title: &str) -> String {
|
||||
fn rename_test_note(
|
||||
filename: impl AsRef<Path>,
|
||||
content: impl AsRef<str>,
|
||||
new_title: impl AsRef<str>,
|
||||
) -> String {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, filename, content);
|
||||
create_test_file(vault, filename.as_ref(), content.as_ref());
|
||||
|
||||
let old_path = vault.join(filename);
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
new_title,
|
||||
None,
|
||||
)
|
||||
let old_path = vault.join(filename.as_ref());
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: new_title.as_ref(),
|
||||
old_title_hint: None,
|
||||
})
|
||||
.expect("rename_note should succeed");
|
||||
|
||||
let expected_slug = title_to_slug(new_title);
|
||||
let expected_slug = title_to_slug(new_title.as_ref());
|
||||
assert!(
|
||||
result.new_path.ends_with(&format!("{}.md", expected_slug)),
|
||||
"new path should end with slug: {}",
|
||||
@@ -869,12 +920,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/untitled-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My New Note",
|
||||
None,
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "My New Note",
|
||||
old_title_hint: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// File should be renamed to match the title slug
|
||||
@@ -910,12 +961,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/untitled-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
None,
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "My Note",
|
||||
old_title_hint: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Should get a suffixed name to avoid collision
|
||||
@@ -946,11 +997,11 @@ mod tests {
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/project-kickoff.md");
|
||||
let result = rename_note_filename(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"manual-name",
|
||||
)
|
||||
let result = rename_note_filename(RenameNoteFilenameRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_filename_stem: "manual-name",
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(result.new_path.ends_with("manual-name.md"));
|
||||
@@ -968,18 +1019,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_filename_rejects_existing_destination() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/current.md", "# Current\n");
|
||||
create_test_file(vault, "note/manual-name.md", "# Existing\n");
|
||||
|
||||
let result = rename_note_filename(
|
||||
vault.to_str().unwrap(),
|
||||
vault.join("note/current.md").to_str().unwrap(),
|
||||
assert_rename_note_filename_error(
|
||||
"manual-name",
|
||||
Some("note/manual-name.md"),
|
||||
"A note with that name already exists",
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(result.unwrap_err(), "A note with that name already exists");
|
||||
#[test]
|
||||
fn test_rename_note_filename_rejects_windows_invalid_names() {
|
||||
assert_rename_note_filename_error("quarterly:plan", None::<&str>, "Invalid filename");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -997,11 +1046,11 @@ mod tests {
|
||||
"Reference [[projects/weekly-review]]\n",
|
||||
);
|
||||
|
||||
let result = move_note_to_folder(
|
||||
vault.to_str().unwrap(),
|
||||
vault.join("projects/weekly-review.md").to_str().unwrap(),
|
||||
vault.join("areas").to_str().unwrap(),
|
||||
)
|
||||
let result = move_note_to_folder(MoveNoteToFolderRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: vault.join("projects/weekly-review.md").to_str().unwrap(),
|
||||
destination_folder_path: vault.join("areas").to_str().unwrap(),
|
||||
})
|
||||
.expect("move should succeed");
|
||||
|
||||
assert!(result.new_path.ends_with("areas/weekly-review.md"));
|
||||
@@ -1020,11 +1069,11 @@ mod tests {
|
||||
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
|
||||
|
||||
let source = vault.join("projects/weekly-review.md");
|
||||
let result = move_note_to_folder(
|
||||
vault.to_str().unwrap(),
|
||||
source.to_str().unwrap(),
|
||||
vault.join("projects").to_str().unwrap(),
|
||||
)
|
||||
let result = move_note_to_folder(MoveNoteToFolderRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: source.to_str().unwrap(),
|
||||
destination_folder_path: vault.join("projects").to_str().unwrap(),
|
||||
})
|
||||
.expect("move should noop");
|
||||
|
||||
assert_eq!(result.new_path, source.to_string_lossy());
|
||||
@@ -1034,18 +1083,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_move_note_to_folder_rejects_existing_destination() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
|
||||
create_test_file(vault, "areas/weekly-review.md", "# Existing\n");
|
||||
|
||||
let result = move_note_to_folder(
|
||||
vault.to_str().unwrap(),
|
||||
vault.join("projects/weekly-review.md").to_str().unwrap(),
|
||||
vault.join("areas").to_str().unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(result.unwrap_err(), "A note with that name already exists");
|
||||
assert_move_note_to_folder_error("A note with that name already exists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1073,12 +1111,12 @@ mod tests {
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
// Without old_title_hint, rename_note would see H1 = "Sprint Retrospective" == new_title → noop
|
||||
// With old_title_hint = "Weekly Review", it knows to search for [[Weekly Review]] and replace
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
Some("Weekly Review"),
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "Sprint Retrospective",
|
||||
old_title_hint: Some("Weekly Review"),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 2);
|
||||
@@ -1105,12 +1143,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/old.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Brand New Title",
|
||||
None,
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "Brand New Title",
|
||||
old_title_hint: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let content = fs::read_to_string(&result.new_path).unwrap();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, render, screen, fireEvent, waitFor, within } from '@testing-librar
|
||||
import type { ReactNode } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { DEFAULT_VAULTS } from './hooks/useVaultSwitcher'
|
||||
import { formatShortcutDisplay } from './hooks/appCommandCatalog'
|
||||
|
||||
// Provide a localStorage mock that supports all methods (jsdom's may be incomplete)
|
||||
const localStorageMock = (() => {
|
||||
@@ -305,6 +306,7 @@ vi.mock('@blocknote/react', () => ({
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
LinkToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
ComponentsContext: {
|
||||
Provider: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
},
|
||||
@@ -317,8 +319,30 @@ vi.mock('@blocknote/react', () => ({
|
||||
focus: () => {},
|
||||
onMount: (cb: () => void) => { cb(); return () => {} },
|
||||
}),
|
||||
LinkToolbarController: () => null,
|
||||
EditLinkButton: () => null,
|
||||
DeleteLinkButton: () => null,
|
||||
SideMenuController: () => null,
|
||||
SuggestionMenuController: () => null,
|
||||
useComponentsContext: () => ({
|
||||
LinkToolbar: {
|
||||
Button: ({
|
||||
children,
|
||||
label,
|
||||
onClick,
|
||||
}: { children?: ReactNode; label?: string; onClick?: () => void }) => (
|
||||
<button onClick={onClick} type="button">
|
||||
{label}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
}),
|
||||
useDictionary: () => ({
|
||||
link_toolbar: {
|
||||
open: { tooltip: 'Open in a new tab' },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/mantine', () => ({
|
||||
@@ -368,9 +392,14 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
it('shows keyboard shortcut hints', async () => {
|
||||
render(<App />)
|
||||
const quickOpenHint = formatShortcutDisplay({ display: '⌘P / ⌘O' })
|
||||
const newNoteHint = formatShortcutDisplay({ display: '⌘N' })
|
||||
const { container } = render(<App />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cmd\+P or Cmd\+O to search/)).toBeInTheDocument()
|
||||
const shortcutHint = Array.from(container.querySelectorAll('span.text-xs.text-muted-foreground'))
|
||||
.find((element) => element.textContent === `${quickOpenHint} to search · ${newNoteHint} to create`)
|
||||
|
||||
expect(shortcutHint).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
16
src/App.tsx
16
src/App.tsx
@@ -31,6 +31,8 @@ import { useAutoGit } from './hooks/useAutoGit'
|
||||
import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useDocumentThemeMode } from './hooks/useDocumentThemeMode'
|
||||
import { useThemeMode } from './hooks/useThemeMode'
|
||||
import { useNoteActions } from './hooks/useNoteActions'
|
||||
import { planNewTypeCreation } from './hooks/useNoteCreation'
|
||||
import { useCommitFlow } from './hooks/useCommitFlow'
|
||||
@@ -367,6 +369,12 @@ function App() {
|
||||
})
|
||||
}, [updateConfig, vaultConfig.inbox?.noteListProperties])
|
||||
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
||||
useThemeMode(settings.theme_mode, settingsLoaded)
|
||||
const documentThemeMode = useDocumentThemeMode()
|
||||
const handleToggleThemeMode = useCallback(() => {
|
||||
const theme_mode = documentThemeMode === 'dark' ? 'light' : 'dark'
|
||||
void saveSettings({ ...settings, theme_mode })
|
||||
}, [documentThemeMode, saveSettings, settings])
|
||||
const aiAgentPreferences = useAiAgentPreferences({
|
||||
settings,
|
||||
saveSettings,
|
||||
@@ -421,7 +429,7 @@ function App() {
|
||||
const handleFocus = () => {
|
||||
invoke<DetectedRename[]>('detect_renames', { vaultPath: resolvedPath })
|
||||
.then(renames => { if (renames.length > 0) setDetectedRenames(renames) })
|
||||
.catch(() => {}) // ignore errors (e.g., no git)
|
||||
.catch((err) => console.warn('[vault] Git rename detection failed:', err))
|
||||
}
|
||||
window.addEventListener('focus', handleFocus)
|
||||
return () => window.removeEventListener('focus', handleFocus)
|
||||
@@ -554,7 +562,7 @@ function App() {
|
||||
if (!isTauri()) { document.title = title; return }
|
||||
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
|
||||
getCurrentWindow().setTitle(title)
|
||||
}).catch(() => {})
|
||||
}).catch((err) => console.warn('[window] Failed to update note window title:', err))
|
||||
}, [noteWindowParams, notes.tabs, notes.activeTabPath])
|
||||
|
||||
// Keep note entry in sync with vault entries so banners (trash/archive)
|
||||
@@ -1032,7 +1040,7 @@ function App() {
|
||||
inspectorCollapsed: nextInspectorCollapsed,
|
||||
})
|
||||
|
||||
void applyMainWindowSizeConstraints(minWidth).catch(() => {})
|
||||
void applyMainWindowSizeConstraints(minWidth).catch((err) => console.warn('[window] Size constraints failed:', err))
|
||||
}, [layout.inspectorCollapsed, noteWindowParams])
|
||||
|
||||
const handleSetViewMode = useCallback((mode: ViewMode) => {
|
||||
@@ -1490,7 +1498,7 @@ function App() {
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 · Cmd+N to create</span>
|
||||
<span className="text-xs text-muted-foreground">{quickOpenShortcut} to search · {newNoteShortcut} to create</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FeedbackDialog } from './FeedbackDialog'
|
||||
import { TOLARIA_GITHUB_ISSUES_URL } from '../constants/feedback'
|
||||
import {
|
||||
TOLARIA_GITHUB_CONTRIBUTING_URL,
|
||||
TOLARIA_GITHUB_DISCUSSIONS_URL,
|
||||
TOLARIA_GITHUB_ISSUES_URL,
|
||||
TOLARIA_GITHUB_PULL_REQUESTS_URL,
|
||||
TOLARIA_PRODUCT_BOARD_URL,
|
||||
} from '../constants/feedback'
|
||||
import { APP_COMMAND_EVENT_NAME, APP_COMMAND_IDS } from '../hooks/appCommandDispatcher'
|
||||
import { rememberFeedbackDialogOpener } from '../lib/feedbackDialogOpener'
|
||||
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -12,42 +20,126 @@ const { openExternalUrl } = await import('../utils/url') as typeof import('../ut
|
||||
}
|
||||
|
||||
describe('FeedbackDialog', () => {
|
||||
it('renders the instructional copy when open', () => {
|
||||
render(<FeedbackDialog open={true} onClose={vi.fn()} />)
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(window.navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the contribution paths when open', () => {
|
||||
render(<FeedbackDialog open={true} onClose={vi.fn()} buildNumber="b281" releaseChannel="alpha" />)
|
||||
expect(screen.getByTestId('feedback-dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Share feedback')).toBeInTheDocument()
|
||||
expect(screen.getByText(/best way to share product feedback/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/check whether a similar one already exists/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Contribute to Tolaria')).toBeInTheDocument()
|
||||
expect(screen.getByText('Pick the path that fits what you want to do! Any type of help is appreciated')).toBeInTheDocument()
|
||||
expect(screen.getByText('Feature requests')).toBeInTheDocument()
|
||||
expect(screen.getByText('Discussions')).toBeInTheDocument()
|
||||
expect(screen.getByText('Contribute code')).toBeInTheDocument()
|
||||
expect(screen.getByText('Report a bug')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Search on the product board first/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Use GitHub Discussions for/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Attach the sanitized diagnostic bundle please!/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Sanitized and optional/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('focuses the primary CTA when opened', async () => {
|
||||
render(<FeedbackDialog open={true} onClose={vi.fn()} />)
|
||||
const cta = screen.getByRole('button', { name: 'Go to Issues' })
|
||||
render(<FeedbackDialog open={true} onClose={vi.fn()} buildNumber="b281" releaseChannel={null} />)
|
||||
const cta = screen.getByRole('button', { name: 'Open Product Board' })
|
||||
await waitFor(() => expect(cta).toHaveFocus())
|
||||
})
|
||||
|
||||
it('opens GitHub Issues without closing the modal', async () => {
|
||||
it('opens the expected contribution links without closing the modal', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<FeedbackDialog open={true} onClose={onClose} />)
|
||||
render(<FeedbackDialog open={true} onClose={onClose} buildNumber="b281" releaseChannel={null} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Go to Issues' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Product Board' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Discussions' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Pull Requests' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Contributing Guide' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open GitHub Issues' }))
|
||||
|
||||
await waitFor(() => expect(openExternalUrl).toHaveBeenCalledWith(TOLARIA_GITHUB_ISSUES_URL))
|
||||
await waitFor(() => expect(openExternalUrl).toHaveBeenNthCalledWith(1, TOLARIA_PRODUCT_BOARD_URL))
|
||||
expect(openExternalUrl).toHaveBeenNthCalledWith(2, TOLARIA_GITHUB_DISCUSSIONS_URL)
|
||||
expect(openExternalUrl).toHaveBeenNthCalledWith(3, TOLARIA_GITHUB_PULL_REQUESTS_URL)
|
||||
expect(openExternalUrl).toHaveBeenNthCalledWith(4, TOLARIA_GITHUB_CONTRIBUTING_URL)
|
||||
expect(openExternalUrl).toHaveBeenNthCalledWith(5, TOLARIA_GITHUB_ISSUES_URL)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('feedback-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies a sanitized diagnostic bundle', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(window.navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
|
||||
render(<FeedbackDialog open={true} onClose={vi.fn()} buildNumber="b281" releaseChannel="alpha" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy sanitized diagnostics' }))
|
||||
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1))
|
||||
expect(writeText.mock.calls[0]?.[0]).toContain('Tolaria sanitized diagnostics')
|
||||
expect(writeText.mock.calls[0]?.[0]).toContain('Build: b281')
|
||||
expect(writeText.mock.calls[0]?.[0]).toContain('Release channel: alpha')
|
||||
expect(screen.getByText('Diagnostics copied.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a fallback message when a contribution link cannot be opened', async () => {
|
||||
openExternalUrl.mockRejectedValueOnce(new Error('blocked'))
|
||||
|
||||
render(<FeedbackDialog open={true} onClose={vi.fn()} buildNumber="b281" releaseChannel={null} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Product Board' }))
|
||||
|
||||
expect(await screen.findByText(/couldn’t 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,58 +1,434 @@
|
||||
import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Megaphone } from '@phosphor-icons/react'
|
||||
import { ArrowUpRight, Bug, Check, Copy, GitPullRequest, Lightbulb, MessagesSquare } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { TOLARIA_GITHUB_ISSUES_URL } from '../constants/feedback'
|
||||
import {
|
||||
TOLARIA_GITHUB_CONTRIBUTING_URL,
|
||||
TOLARIA_GITHUB_DISCUSSIONS_URL,
|
||||
TOLARIA_GITHUB_ISSUES_URL,
|
||||
TOLARIA_GITHUB_PULL_REQUESTS_URL,
|
||||
TOLARIA_PRODUCT_BOARD_URL,
|
||||
} from '../constants/feedback'
|
||||
import {
|
||||
buildSanitizedDiagnosticBundle,
|
||||
startFeedbackDiagnosticsCapture,
|
||||
} from '../lib/feedbackDiagnostics'
|
||||
import { cn } from '../lib/utils'
|
||||
import { takeFeedbackDialogOpener } from '../lib/feedbackDialogOpener'
|
||||
import { useBuildNumber } from '../hooks/useBuildNumber'
|
||||
import { APP_COMMAND_EVENT_NAME, APP_COMMAND_IDS } from '../hooks/appCommandDispatcher'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
|
||||
interface FeedbackDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
buildNumber?: string
|
||||
releaseChannel?: string | null
|
||||
}
|
||||
|
||||
export function FeedbackDialog({ open, onClose }: FeedbackDialogProps) {
|
||||
const handleOpenIssues = () => {
|
||||
void openExternalUrl(TOLARIA_GITHUB_ISSUES_URL)
|
||||
interface ContributionCardProps {
|
||||
title: string
|
||||
description: string
|
||||
ctaLabel: string
|
||||
icon: typeof Lightbulb
|
||||
tone: ContributionTone
|
||||
onAction: () => void
|
||||
autoFocus?: boolean
|
||||
secondaryAction?: ReactNode
|
||||
}
|
||||
|
||||
interface LinkFallback {
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface ContributionPath {
|
||||
title: string
|
||||
description: string
|
||||
ctaLabel: string
|
||||
label: string
|
||||
url: string
|
||||
icon: typeof Lightbulb
|
||||
tone: ContributionTone
|
||||
secondaryLink?: ContributionLink
|
||||
}
|
||||
|
||||
interface ContributionLink {
|
||||
ctaLabel: string
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const EMPTY_DIALOG_OPENER: ReturnType<typeof takeFeedbackDialogOpener> = {
|
||||
element: null,
|
||||
reopenCommandPalette: false,
|
||||
}
|
||||
|
||||
type ContributionTone = 'green' | 'yellow' | 'purple' | 'red'
|
||||
|
||||
const CONTRIBUTION_TONE_CLASSES: Record<ContributionTone, string> = {
|
||||
green: 'bg-[var(--accent-green-light)] text-[var(--accent-green)]',
|
||||
yellow: 'bg-[var(--accent-yellow-light)] text-[var(--accent-yellow)]',
|
||||
purple: 'bg-[var(--accent-purple-light)] text-[var(--accent-purple)]',
|
||||
red: 'bg-[var(--accent-red-light)] text-[var(--accent-red)]',
|
||||
}
|
||||
|
||||
const CONTRIBUTION_BUTTON_CLASSES: Record<ContributionTone, string> = {
|
||||
green: 'border-[var(--accent-green)] hover:bg-[var(--accent-green-light)] [&_svg]:text-[var(--accent-green)]',
|
||||
yellow: 'border-[var(--accent-yellow)] hover:bg-[var(--accent-yellow-light)] [&_svg]:text-[var(--accent-yellow)]',
|
||||
purple: 'border-[var(--accent-purple)] hover:bg-[var(--accent-purple-light)] [&_svg]:text-[var(--accent-purple)]',
|
||||
red: 'border-[var(--accent-red)] hover:bg-[var(--accent-red-light)] [&_svg]:text-[var(--accent-red)]',
|
||||
}
|
||||
|
||||
const CONTRIBUTION_PATHS: ContributionPath[] = [
|
||||
{
|
||||
title: 'Feature requests',
|
||||
description: 'Search on the product board first, upvote an existing idea if already there, and only create a new post when genuinely new!',
|
||||
ctaLabel: 'Open Product Board',
|
||||
label: 'Product Board',
|
||||
url: TOLARIA_PRODUCT_BOARD_URL,
|
||||
icon: Lightbulb,
|
||||
tone: 'green',
|
||||
},
|
||||
{
|
||||
title: 'Discussions',
|
||||
description: 'Use GitHub Discussions for questions, broader conversations, idea sharing, and community context that is not a concrete bug report.',
|
||||
ctaLabel: 'Open Discussions',
|
||||
label: 'GitHub Discussions',
|
||||
url: TOLARIA_GITHUB_DISCUSSIONS_URL,
|
||||
icon: MessagesSquare,
|
||||
tone: 'purple',
|
||||
},
|
||||
{
|
||||
title: 'Contribute code',
|
||||
description: 'Small, focused pull requests are welcome. Check planned or in-progress work first so you are building in the right place.',
|
||||
ctaLabel: 'Open Pull Requests',
|
||||
label: 'GitHub Pull Requests',
|
||||
url: TOLARIA_GITHUB_PULL_REQUESTS_URL,
|
||||
icon: GitPullRequest,
|
||||
tone: 'yellow',
|
||||
secondaryLink: {
|
||||
ctaLabel: 'Open Contributing Guide',
|
||||
label: 'the contributing guide',
|
||||
url: TOLARIA_GITHUB_CONTRIBUTING_URL,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function ContributionLinkButton({
|
||||
label,
|
||||
tone,
|
||||
onAction,
|
||||
autoFocus = false,
|
||||
accented = true,
|
||||
}: {
|
||||
label: string
|
||||
tone: ContributionTone
|
||||
onAction: () => void
|
||||
autoFocus?: boolean
|
||||
accented?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
accented && 'bg-background text-foreground hover:text-foreground',
|
||||
accented && CONTRIBUTION_BUTTON_CLASSES[tone],
|
||||
)}
|
||||
autoFocus={autoFocus}
|
||||
onClick={onAction}
|
||||
>
|
||||
{label}
|
||||
<ArrowUpRight size={14} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function ContributionCard({
|
||||
title,
|
||||
description,
|
||||
ctaLabel,
|
||||
icon: Icon,
|
||||
tone,
|
||||
onAction,
|
||||
autoFocus = false,
|
||||
secondaryAction,
|
||||
}: ContributionCardProps) {
|
||||
return (
|
||||
<Card className="gap-4 border-border/70 py-4 shadow-none">
|
||||
<CardHeader className="gap-3 px-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<span className={cn('rounded-md p-2', CONTRIBUTION_TONE_CLASSES[tone])}>
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-sm leading-6 text-muted-foreground">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4">
|
||||
<ContributionLinkButton label={ctaLabel} tone={tone} autoFocus={autoFocus} onAction={onAction} />
|
||||
</CardContent>
|
||||
{secondaryAction ? <CardFooter className="px-4 pt-0">{secondaryAction}</CardFooter> : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkFallbackBanner({ linkFallback }: { linkFallback: LinkFallback | null }) {
|
||||
if (!linkFallback) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border px-4 py-3 text-sm"
|
||||
style={{
|
||||
background: 'var(--feedback-warning-bg)',
|
||||
borderColor: 'var(--feedback-warning-border)',
|
||||
color: 'var(--feedback-warning-text)',
|
||||
}}
|
||||
>
|
||||
<p className="font-medium">Couldn’t open {linkFallback.label} automatically.</p>
|
||||
<p className="mt-1">Open this URL manually instead:</p>
|
||||
<p className="mt-2 break-all rounded-md bg-popover px-3 py-2 font-mono text-xs text-foreground">
|
||||
{linkFallback.url}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getCopyDiagnosticsLabel(copyState: 'idle' | 'copied' | 'failed') {
|
||||
return copyState === 'copied' ? 'Diagnostics copied' : 'Copy sanitized diagnostics'
|
||||
}
|
||||
|
||||
function BugReportActions({
|
||||
copyState,
|
||||
canCopyDiagnostics,
|
||||
onCopyDiagnostics,
|
||||
}: {
|
||||
copyState: 'idle' | 'copied' | 'failed'
|
||||
canCopyDiagnostics: boolean
|
||||
onCopyDiagnostics: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
onClick={onCopyDiagnostics}
|
||||
disabled={!canCopyDiagnostics}
|
||||
>
|
||||
{getCopyDiagnosticsLabel(copyState)}
|
||||
{copyState === 'copied' ? <Check size={14} /> : <Copy size={14} />}
|
||||
</Button>
|
||||
{copyState === 'copied' ? (
|
||||
<p className="text-xs font-medium text-foreground">Diagnostics copied.</p>
|
||||
) : null}
|
||||
{copyState === 'failed' ? (
|
||||
<p className="text-xs font-medium text-[var(--feedback-warning-text)]">
|
||||
Clipboard access is unavailable right now. You can still open GitHub Issues directly.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useDialogReturnFocus(open: boolean, onClose: () => void) {
|
||||
const openerRef = useRef(EMPTY_DIALOG_OPENER)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open) {
|
||||
openerRef.current = takeFeedbackDialogOpener()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return () => {
|
||||
const { element: opener, reopenCommandPalette } = openerRef.current
|
||||
openerRef.current = takeFeedbackDialogOpener()
|
||||
|
||||
onClose()
|
||||
window.setTimeout(() => {
|
||||
if (reopenCommandPalette) {
|
||||
window.dispatchEvent(new CustomEvent(APP_COMMAND_EVENT_NAME, {
|
||||
detail: APP_COMMAND_IDS.viewCommandPalette,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (opener?.isConnected) {
|
||||
opener.focus()
|
||||
}
|
||||
}, 80)
|
||||
}
|
||||
}
|
||||
|
||||
function useFeedbackDialogActions(diagnosticsBundle: string) {
|
||||
const [linkFallback, setLinkFallback] = useState<LinkFallback | null>(null)
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
|
||||
const canCopyDiagnostics = typeof navigator !== 'undefined' && typeof navigator.clipboard?.writeText === 'function'
|
||||
|
||||
const handleOpenLink = (label: string, url: string) => {
|
||||
void openExternalUrl(url)
|
||||
.then(() => {
|
||||
setLinkFallback(null)
|
||||
})
|
||||
.catch(() => {
|
||||
setLinkFallback({ label, url })
|
||||
})
|
||||
}
|
||||
|
||||
const handleCopyDiagnostics = () => {
|
||||
if (!canCopyDiagnostics) {
|
||||
setCopyState('failed')
|
||||
return
|
||||
}
|
||||
|
||||
void navigator.clipboard.writeText(diagnosticsBundle)
|
||||
.then(() => {
|
||||
setCopyState('copied')
|
||||
})
|
||||
.catch(() => {
|
||||
setCopyState('failed')
|
||||
})
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setLinkFallback(null)
|
||||
setCopyState('idle')
|
||||
}
|
||||
|
||||
return {
|
||||
linkFallback,
|
||||
copyState,
|
||||
canCopyDiagnostics,
|
||||
handleOpenLink,
|
||||
handleCopyDiagnostics,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
function ContributionGrid({
|
||||
onOpenLink,
|
||||
copyState,
|
||||
canCopyDiagnostics,
|
||||
onCopyDiagnostics,
|
||||
}: {
|
||||
onOpenLink: (label: string, url: string) => void
|
||||
copyState: 'idle' | 'copied' | 'failed'
|
||||
canCopyDiagnostics: boolean
|
||||
onCopyDiagnostics: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{CONTRIBUTION_PATHS.map((path, index) => {
|
||||
const secondaryLink = path.secondaryLink
|
||||
|
||||
return (
|
||||
<ContributionCard
|
||||
key={path.title}
|
||||
title={path.title}
|
||||
description={path.description}
|
||||
ctaLabel={path.ctaLabel}
|
||||
icon={path.icon}
|
||||
tone={path.tone}
|
||||
autoFocus={index === 0}
|
||||
onAction={() => onOpenLink(path.label, path.url)}
|
||||
secondaryAction={secondaryLink ? (
|
||||
<ContributionLinkButton
|
||||
label={secondaryLink.ctaLabel}
|
||||
tone={path.tone}
|
||||
accented={false}
|
||||
onAction={() => onOpenLink(secondaryLink.label, secondaryLink.url)}
|
||||
/>
|
||||
) : undefined}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<ContributionCard
|
||||
title="Report a bug"
|
||||
description="Explain how to reproduce, what you expected, and what actually happened. Check for duplicate bugs first. Attach the sanitized diagnostic bundle please!"
|
||||
ctaLabel="Open GitHub Issues"
|
||||
icon={Bug}
|
||||
tone="red"
|
||||
onAction={() => onOpenLink('GitHub Issues', TOLARIA_GITHUB_ISSUES_URL)}
|
||||
secondaryAction={(
|
||||
<BugReportActions
|
||||
copyState={copyState}
|
||||
canCopyDiagnostics={canCopyDiagnostics}
|
||||
onCopyDiagnostics={onCopyDiagnostics}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FeedbackDialog({
|
||||
open,
|
||||
onClose,
|
||||
buildNumber,
|
||||
releaseChannel,
|
||||
}: FeedbackDialogProps) {
|
||||
const detectedBuildNumber = useBuildNumber()
|
||||
const resolvedBuildNumber = buildNumber ?? detectedBuildNumber
|
||||
const diagnosticsBundle = useMemo(
|
||||
() => buildSanitizedDiagnosticBundle({ buildNumber: resolvedBuildNumber, releaseChannel }),
|
||||
[releaseChannel, resolvedBuildNumber],
|
||||
)
|
||||
const handleRequestClose = useDialogReturnFocus(open, onClose)
|
||||
const {
|
||||
linkFallback,
|
||||
copyState,
|
||||
canCopyDiagnostics,
|
||||
handleOpenLink,
|
||||
handleCopyDiagnostics,
|
||||
reset,
|
||||
} = useFeedbackDialogActions(diagnosticsBundle)
|
||||
|
||||
useEffect(() => startFeedbackDiagnosticsCapture(), [])
|
||||
|
||||
const handleClose = () => {
|
||||
reset()
|
||||
handleRequestClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[460px]" data-testid="feedback-dialog">
|
||||
<DialogHeader>
|
||||
<Dialog open={open} onOpenChange={(next) => { if (!next) handleClose() }}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-[760px]" data-testid="feedback-dialog">
|
||||
<DialogHeader className="space-y-2">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Megaphone size={18} weight="duotone" />
|
||||
Share feedback
|
||||
Contribute to Tolaria
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
The best way to share product feedback is through a GitHub Issue.
|
||||
Pick the path that fits what you want to do! Any type of help is appreciated
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 text-sm leading-6 text-muted-foreground">
|
||||
<p>
|
||||
Before opening a new issue, please check whether a similar one already exists.
|
||||
If it does, add an upvote or comment there instead of opening a duplicate.
|
||||
</p>
|
||||
<p>
|
||||
When you do open a new issue, include the steps to reproduce, what you expected,
|
||||
and what actually happened so it is easier to triage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button type="button" autoFocus onClick={handleOpenIssues}>
|
||||
Go to Issues
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<LinkFallbackBanner linkFallback={linkFallback} />
|
||||
<ContributionGrid
|
||||
onOpenLink={handleOpenLink}
|
||||
copyState={copyState}
|
||||
canCopyDiagnostics={canCopyDiagnostics}
|
||||
onCopyDiagnostics={handleCopyDiagnostics}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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}`,
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
28
src/components/propertyDropdownUtils.test.ts
Normal file
28
src/components/propertyDropdownUtils.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
57
src/components/propertyDropdownUtils.ts
Normal file
57
src/components/propertyDropdownUtils.ts
Normal 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])
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export const TOLARIA_PRODUCT_BOARD_URL = 'https://tolaria.canny.io/'
|
||||
export const TOLARIA_GITHUB_DISCUSSIONS_URL = 'https://github.com/refactoringhq/tolaria/discussions'
|
||||
export const TOLARIA_GITHUB_CONTRIBUTING_URL = 'https://github.com/refactoringhq/tolaria/blob/main/CONTRIBUTING.md'
|
||||
export const TOLARIA_GITHUB_ISSUES_URL = 'https://github.com/refactoringhq/tolaria/issues'
|
||||
export const TOLARIA_GITHUB_PULL_REQUESTS_URL = 'https://github.com/refactoringhq/tolaria/pulls'
|
||||
|
||||
@@ -73,12 +73,9 @@ export const frontmatterHighlightPlugin = ViewPlugin.fromClass(
|
||||
)
|
||||
|
||||
export function frontmatterHighlightTheme() {
|
||||
const keyColor = '#c9383e'
|
||||
const valueColor = '#2a7e4f'
|
||||
const delimiterColor = '#c9383e'
|
||||
return EditorView.baseTheme({
|
||||
'.cm-frontmatter-delimiter': { color: delimiterColor, fontWeight: '600' },
|
||||
'.cm-frontmatter-key': { color: keyColor },
|
||||
'.cm-frontmatter-value': { color: valueColor },
|
||||
'.cm-frontmatter-delimiter': { color: 'var(--syntax-frontmatter-key)', fontWeight: '600' },
|
||||
'.cm-frontmatter-key': { color: 'var(--syntax-frontmatter-key)' },
|
||||
'.cm-frontmatter-value': { color: 'var(--syntax-frontmatter-value)' },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,23 +4,31 @@ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { tags } from '@lezer/highlight'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
|
||||
const SYNTAX_COLORS = {
|
||||
heading: 'var(--syntax-heading)',
|
||||
link: 'var(--syntax-link)',
|
||||
monospace: 'var(--syntax-monospace)',
|
||||
monospaceBackground: 'var(--syntax-monospace-bg)',
|
||||
muted: 'var(--syntax-muted)',
|
||||
}
|
||||
|
||||
const markdownHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.heading1, color: '#0969da', fontWeight: '700', fontSize: '1.4em' },
|
||||
{ tag: tags.heading2, color: '#0969da', fontWeight: '700', fontSize: '1.25em' },
|
||||
{ tag: tags.heading3, color: '#0969da', fontWeight: '600', fontSize: '1.1em' },
|
||||
{ tag: tags.heading4, color: '#0969da', fontWeight: '600' },
|
||||
{ tag: tags.heading5, color: '#0969da', fontWeight: '600' },
|
||||
{ tag: tags.heading6, color: '#0969da', fontWeight: '600' },
|
||||
{ tag: tags.heading1, color: SYNTAX_COLORS.heading, fontWeight: '700', fontSize: '1.4em' },
|
||||
{ tag: tags.heading2, color: SYNTAX_COLORS.heading, fontWeight: '700', fontSize: '1.25em' },
|
||||
{ tag: tags.heading3, color: SYNTAX_COLORS.heading, fontWeight: '600', fontSize: '1.1em' },
|
||||
{ tag: tags.heading4, color: SYNTAX_COLORS.heading, fontWeight: '600' },
|
||||
{ tag: tags.heading5, color: SYNTAX_COLORS.heading, fontWeight: '600' },
|
||||
{ tag: tags.heading6, color: SYNTAX_COLORS.heading, fontWeight: '600' },
|
||||
{ tag: tags.strong, fontWeight: '700' },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strikethrough, textDecoration: 'line-through' },
|
||||
{ tag: tags.link, color: '#0969da', textDecoration: 'underline' },
|
||||
{ tag: tags.url, color: '#0969da' },
|
||||
{ tag: tags.monospace, color: '#c9383e', backgroundColor: 'rgba(175,184,193,0.15)', borderRadius: '3px' },
|
||||
{ tag: tags.quote, color: '#636c76', fontStyle: 'italic' },
|
||||
{ tag: tags.separator, color: '#636c76' },
|
||||
{ tag: tags.processingInstruction, color: '#c9383e', fontWeight: '600' },
|
||||
{ tag: tags.contentSeparator, color: '#c9383e', fontWeight: '600' },
|
||||
{ tag: tags.link, color: SYNTAX_COLORS.link, textDecoration: 'underline' },
|
||||
{ tag: tags.url, color: SYNTAX_COLORS.link },
|
||||
{ tag: tags.monospace, color: SYNTAX_COLORS.monospace, backgroundColor: SYNTAX_COLORS.monospaceBackground, borderRadius: '3px' },
|
||||
{ tag: tags.quote, color: SYNTAX_COLORS.muted, fontStyle: 'italic' },
|
||||
{ tag: tags.separator, color: SYNTAX_COLORS.muted },
|
||||
{ tag: tags.processingInstruction, color: SYNTAX_COLORS.monospace, fontWeight: '600' },
|
||||
{ tag: tags.contentSeparator, color: SYNTAX_COLORS.monospace, fontWeight: '600' },
|
||||
])
|
||||
|
||||
export function markdownLanguage(): Extension {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SidebarFilter } from '../types'
|
||||
import { isMac } from '../utils/platform'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
|
||||
export const APP_COMMAND_IDS = {
|
||||
@@ -460,3 +461,23 @@ export function findShortcutCommandIdForEvent(event: ShortcutEventLike): AppComm
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatShortcutDisplay(
|
||||
shortcut: Pick<AppCommandShortcutDefinition, 'display'>,
|
||||
): string {
|
||||
if (isMac()) return shortcut.display
|
||||
|
||||
return shortcut.display
|
||||
.replaceAll('⌘⇧', 'Ctrl+Shift+')
|
||||
.replaceAll('⌘', 'Ctrl+')
|
||||
.replaceAll('⌫', 'Backspace')
|
||||
.replaceAll('⌦', 'Delete')
|
||||
.replaceAll('←', 'Left')
|
||||
.replaceAll('→', 'Right')
|
||||
.replaceAll('↵', 'Enter')
|
||||
}
|
||||
|
||||
export function getAppCommandShortcutDisplay(id: AppCommandId): string | undefined {
|
||||
const shortcut = APP_COMMAND_DEFINITIONS[id].shortcut
|
||||
return shortcut ? formatShortcutDisplay(shortcut) : undefined
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
|
||||
import type { CommandAction } from './types'
|
||||
import type { SidebarSelection } from '../../types'
|
||||
|
||||
@@ -50,13 +51,13 @@ function buildBaseCommands(config: NavigationCommandsConfig): CommandAction[] {
|
||||
} = config
|
||||
|
||||
return [
|
||||
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P / ⌘O', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
|
||||
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.fileQuickOpen), keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
|
||||
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
|
||||
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
|
||||
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
{ id: 'go-pulse', label: 'Go to History', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
|
||||
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘←', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
|
||||
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘→', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
|
||||
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewGoBack), keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
|
||||
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewGoForward), keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
|
||||
import type { CommandAction } from './types'
|
||||
|
||||
interface NoteCommandsConfig {
|
||||
@@ -59,7 +60,7 @@ function buildCoreNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
createNoteCommand({
|
||||
id: 'create-note',
|
||||
label: 'New Note',
|
||||
shortcut: '⌘N',
|
||||
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.fileNewNote),
|
||||
keywords: ['new', 'create', 'add'],
|
||||
enabled: true,
|
||||
execute: config.onCreateNote,
|
||||
@@ -74,7 +75,7 @@ function buildCoreNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
createNoteCommand({
|
||||
id: 'save-note',
|
||||
label: 'Save Note',
|
||||
shortcut: '⌘S',
|
||||
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.fileSave),
|
||||
keywords: ['write'],
|
||||
enabled: config.hasActiveNote,
|
||||
execute: config.onSave,
|
||||
@@ -94,7 +95,7 @@ function buildDestructiveNoteCommands(config: NoteCommandsConfig): CommandAction
|
||||
createNoteCommand({
|
||||
id: 'delete-note',
|
||||
label: 'Delete Note',
|
||||
shortcut: '⌘⌫',
|
||||
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteDelete),
|
||||
keywords: ['delete', 'remove'],
|
||||
enabled: config.hasActiveNote,
|
||||
path: config.activeTabPath,
|
||||
@@ -116,7 +117,7 @@ function buildPinnedNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
createNoteCommand({
|
||||
id: 'toggle-favorite',
|
||||
label: config.isFavorite ? 'Remove from Favorites' : 'Add to Favorites',
|
||||
shortcut: '⌘D',
|
||||
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteToggleFavorite),
|
||||
keywords: ['favorite', 'star', 'bookmark', 'pin'],
|
||||
enabled: config.hasActiveNote && !!config.onToggleFavorite,
|
||||
path: config.activeTabPath,
|
||||
@@ -125,7 +126,7 @@ function buildPinnedNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
createNoteCommand({
|
||||
id: 'toggle-organized',
|
||||
label: config.isOrganized ? 'Mark as Unorganized' : 'Mark as Organized',
|
||||
shortcut: '⌘E',
|
||||
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteToggleOrganized),
|
||||
keywords: ['organized', 'inbox', 'triage', 'done'],
|
||||
enabled: config.hasActiveNote && !!config.onToggleOrganized,
|
||||
path: config.activeTabPath,
|
||||
@@ -192,7 +193,7 @@ function buildPresentationCommands(config: NoteCommandsConfig): CommandAction[]
|
||||
createNoteCommand({
|
||||
id: 'open-in-new-window',
|
||||
label: 'Open in New Window',
|
||||
shortcut: '⌘⇧O',
|
||||
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteOpenInNewWindow),
|
||||
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
|
||||
enabled: config.hasActiveNote,
|
||||
execute: () => config.onOpenInNewWindow?.(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { formatShortcutDisplay } from '../appCommandCatalog'
|
||||
import { buildSettingsCommands } from './settingsCommands'
|
||||
|
||||
describe('buildSettingsCommands', () => {
|
||||
@@ -25,7 +26,7 @@ describe('buildSettingsCommands', () => {
|
||||
|
||||
expect(commands.find((item) => item.id === 'open-settings')).toMatchObject({
|
||||
label: 'Open Settings',
|
||||
shortcut: '⌘,',
|
||||
shortcut: formatShortcutDisplay({ display: '⌘,' }),
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
|
||||
import type { CommandAction } from './types'
|
||||
import { rememberFeedbackDialogOpener } from '../../lib/feedbackDialogOpener'
|
||||
|
||||
interface SettingsCommandsConfig {
|
||||
mcpStatus?: string
|
||||
@@ -22,7 +24,7 @@ function buildPrimarySettingsCommands({
|
||||
onCheckForUpdates,
|
||||
}: Pick<SettingsCommandsConfig, 'onOpenSettings' | 'onOpenFeedback' | 'onCheckForUpdates'>): CommandAction[] {
|
||||
return [
|
||||
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
|
||||
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.appSettings), keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
|
||||
{
|
||||
id: 'open-h1-auto-rename-setting',
|
||||
label: 'Open H1 Auto-Rename Setting',
|
||||
@@ -31,7 +33,17 @@ function buildPrimarySettingsCommands({
|
||||
enabled: true,
|
||||
execute: onOpenSettings,
|
||||
},
|
||||
{ id: 'give-feedback', label: 'Give Feedback', group: 'Settings', keywords: ['feedback', 'issue', 'bug', 'github', 'report'], enabled: !!onOpenFeedback, execute: () => onOpenFeedback?.() },
|
||||
{
|
||||
id: 'open-contribute',
|
||||
label: 'Contribute',
|
||||
group: 'Settings',
|
||||
keywords: ['contribute', 'feedback', 'feature', 'canny', 'discussion', 'github', 'bug', 'report'],
|
||||
enabled: !!onOpenFeedback,
|
||||
execute: () => {
|
||||
rememberFeedbackDialogOpener(document.activeElement instanceof HTMLElement ? document.activeElement : null)
|
||||
onOpenFeedback?.()
|
||||
},
|
||||
},
|
||||
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
|
||||
import type { CommandAction } from './types'
|
||||
import type { ViewMode } from '../useViewMode'
|
||||
import { requestNewAiChat } from '../../utils/aiPromptBridge'
|
||||
@@ -28,18 +29,18 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
|
||||
} = config
|
||||
|
||||
return [
|
||||
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: '⌘⇧I', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewEditorOnly), keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewEditorList), keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewAll), keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleProperties), keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⇧⌘L', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat), keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat },
|
||||
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
|
||||
{ id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomIn), keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomOut), keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomReset), keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export function useAutoGit({
|
||||
|
||||
void onCheckpoint(trigger).then((didRun) => {
|
||||
if (didRun) markTriggerAsHandled(lastTriggeredRef.current, trigger, lastActivityAt)
|
||||
}).catch(() => {})
|
||||
}).catch((err) => console.warn('[git] Auto-commit failed:', err))
|
||||
})
|
||||
|
||||
const updateAppActivity = useEffectEvent((active: boolean) => {
|
||||
|
||||
@@ -127,7 +127,7 @@ function useCommitInfoRefresher(
|
||||
return useCallback(() => {
|
||||
tauriCall<LastCommitInfo | null>('get_last_commit_info', { vaultPath })
|
||||
.then(info => setLastCommitInfo(info))
|
||||
.catch(() => {})
|
||||
.catch((err) => console.warn('[sync] Failed to refresh last commit info:', err))
|
||||
}, [vaultPath, setLastCommitInfo])
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,14 @@ import { resolveArrowLigatureInput } from '../utils/arrowLigatures'
|
||||
import { zoomCursorFix } from '../extensions/zoomCursorFix'
|
||||
|
||||
const FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
const RAW_EDITOR_COLORS = {
|
||||
activeLineBackground: 'var(--state-hover-subtle)',
|
||||
background: 'var(--surface-editor)',
|
||||
foreground: 'var(--text-primary)',
|
||||
gutterBackground: 'var(--surface-editor)',
|
||||
gutterBorder: 'var(--border-subtle)',
|
||||
gutterText: 'var(--text-muted)',
|
||||
}
|
||||
|
||||
export interface CodeMirrorCallbacks {
|
||||
onDocChange: (doc: string) => void
|
||||
@@ -17,19 +25,12 @@ export interface CodeMirrorCallbacks {
|
||||
}
|
||||
|
||||
function buildBaseTheme() {
|
||||
const bg = '#ffffff'
|
||||
const fg = '#1e1e1e'
|
||||
const gutterBg = '#ffffff'
|
||||
const gutterColor = '#aaa'
|
||||
const activeLineBg = 'rgba(0,100,255,0.06)'
|
||||
const gutterBorder = '#eee'
|
||||
|
||||
return EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '13px',
|
||||
fontFamily: FONT_FAMILY,
|
||||
backgroundColor: bg,
|
||||
color: fg,
|
||||
backgroundColor: RAW_EDITOR_COLORS.background,
|
||||
color: RAW_EDITOR_COLORS.foreground,
|
||||
flex: '1',
|
||||
minHeight: '0',
|
||||
},
|
||||
@@ -41,12 +42,12 @@ function buildBaseTheme() {
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '0 32px 0 16px',
|
||||
caretColor: fg,
|
||||
caretColor: RAW_EDITOR_COLORS.foreground,
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: gutterBg,
|
||||
color: gutterColor,
|
||||
borderRight: `1px solid ${gutterBorder}`,
|
||||
backgroundColor: RAW_EDITOR_COLORS.gutterBackground,
|
||||
color: RAW_EDITOR_COLORS.gutterText,
|
||||
borderRight: `1px solid ${RAW_EDITOR_COLORS.gutterBorder}`,
|
||||
paddingLeft: '16px',
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
@@ -55,10 +56,10 @@ function buildBaseTheme() {
|
||||
textAlign: 'right',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: activeLineBg,
|
||||
backgroundColor: RAW_EDITOR_COLORS.activeLineBackground,
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: activeLineBg,
|
||||
backgroundColor: RAW_EDITOR_COLORS.activeLineBackground,
|
||||
},
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-line': { padding: '0' },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { renderHook } from '@testing-library/react'
|
||||
import { useCommandRegistry, buildTypeCommands, extractVaultTypes, pluralizeType, groupSortKey } from './useCommandRegistry'
|
||||
import type { CommandAction } from './useCommandRegistry'
|
||||
import { NEW_AI_CHAT_EVENT, OPEN_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
|
||||
import { formatShortcutDisplay } from './appCommandCatalog'
|
||||
|
||||
function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -247,7 +248,9 @@ describe('useCommandRegistry', () => {
|
||||
it('shows Cmd+E on toggle organized and removes it from archive note', () => {
|
||||
const config = makeConfig()
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
expect(findCommand(result.current, 'toggle-organized')?.shortcut).toBe('⌘E')
|
||||
expect(findCommand(result.current, 'toggle-organized')?.shortcut).toBe(
|
||||
formatShortcutDisplay({ display: '⌘E' }),
|
||||
)
|
||||
expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -328,12 +331,13 @@ describe('useCommandRegistry', () => {
|
||||
expect(findCommand(result.current, 'open-daily-note')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes Give Feedback in the Settings group when available', () => {
|
||||
it('includes Contribute in the Settings group when available', () => {
|
||||
const onOpenFeedback = vi.fn()
|
||||
const config = makeConfig({ onOpenFeedback })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'give-feedback')
|
||||
const cmd = findCommand(result.current, 'open-contribute')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.label).toBe('Contribute')
|
||||
expect(cmd!.group).toBe('Settings')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
|
||||
@@ -355,7 +359,7 @@ describe('useCommandRegistry', () => {
|
||||
expect(newNoteCommands).toHaveLength(1)
|
||||
expect(newNoteCommands[0]).toMatchObject({
|
||||
id: 'create-note',
|
||||
shortcut: '⌘N',
|
||||
shortcut: formatShortcutDisplay({ display: '⌘N' }),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
29
src/hooks/useDocumentThemeMode.test.ts
Normal file
29
src/hooks/useDocumentThemeMode.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useDocumentThemeMode } from './useDocumentThemeMode'
|
||||
|
||||
describe('useDocumentThemeMode', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
document.documentElement.classList.remove('dark')
|
||||
})
|
||||
|
||||
it('defaults to light when no document theme is applied', () => {
|
||||
const { result } = renderHook(() => useDocumentThemeMode())
|
||||
|
||||
expect(result.current).toBe('light')
|
||||
})
|
||||
|
||||
it('updates when the document theme changes', async () => {
|
||||
const { result } = renderHook(() => useDocumentThemeMode())
|
||||
|
||||
act(() => {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
document.documentElement.classList.add('dark')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe('dark')
|
||||
})
|
||||
})
|
||||
})
|
||||
33
src/hooks/useDocumentThemeMode.ts
Normal file
33
src/hooks/useDocumentThemeMode.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import {
|
||||
DEFAULT_THEME_MODE,
|
||||
normalizeThemeMode,
|
||||
type ThemeMode,
|
||||
} from '../lib/themeMode'
|
||||
|
||||
function readDocumentThemeMode(): ThemeMode {
|
||||
if (typeof document === 'undefined') return DEFAULT_THEME_MODE
|
||||
return normalizeThemeMode(document.documentElement.getAttribute('data-theme')) ?? DEFAULT_THEME_MODE
|
||||
}
|
||||
|
||||
function subscribeDocumentThemeMode(onChange: () => void): () => void {
|
||||
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(onChange)
|
||||
observer.observe(document.documentElement, {
|
||||
attributeFilter: ['class', 'data-theme'],
|
||||
attributes: true,
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
|
||||
export function useDocumentThemeMode(): ThemeMode {
|
||||
return useSyncExternalStore(
|
||||
subscribeDocumentThemeMode,
|
||||
readDocumentThemeMode,
|
||||
() => DEFAULT_THEME_MODE,
|
||||
)
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function useMainWindowSizeConstraints({
|
||||
void (async () => {
|
||||
if (cancelled) return
|
||||
await applyMainWindowSizeConstraints(minWidth)
|
||||
})().catch(() => {})
|
||||
})().catch((err) => console.warn('[window] Size constraints failed:', err))
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
||||
@@ -54,7 +54,7 @@ function syncNativeMenuState(state: MenuStatePayload): void {
|
||||
|
||||
import('@tauri-apps/api/core')
|
||||
.then(({ invoke }) => invoke('update_menu_state', { state }))
|
||||
.catch(() => {})
|
||||
.catch((err) => console.warn('[menu] Failed to sync native menu state:', err))
|
||||
}
|
||||
|
||||
function useNativeMenuEventListener(handlersRef: { current: MenuEventHandlers }) {
|
||||
|
||||
@@ -14,6 +14,7 @@ const defaultSettings: Settings = {
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
theme_mode: null,
|
||||
default_ai_agent: null,
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ const savedSettings: Settings = {
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
theme_mode: null,
|
||||
default_ai_agent: null,
|
||||
}
|
||||
|
||||
@@ -111,6 +113,7 @@ describe('useSettings', () => {
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
theme_mode: null,
|
||||
default_ai_agent: null,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { normalizeStoredAiAgent } from '../lib/aiAgents'
|
||||
import { normalizeReleaseChannel, serializeReleaseChannel } from '../lib/releaseChannel'
|
||||
import { normalizeThemeMode } from '../lib/themeMode'
|
||||
import type { Settings } from '../types'
|
||||
|
||||
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
|
||||
@@ -20,6 +21,7 @@ const EMPTY_SETTINGS: Settings = {
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
theme_mode: null,
|
||||
default_ai_agent: null,
|
||||
}
|
||||
|
||||
@@ -29,6 +31,7 @@ function normalizeSettings(settings: Settings): Settings {
|
||||
release_channel: serializeReleaseChannel(
|
||||
normalizeReleaseChannel(settings.release_channel),
|
||||
),
|
||||
theme_mode: normalizeThemeMode(settings.theme_mode),
|
||||
default_ai_agent: normalizeStoredAiAgent(settings.default_ai_agent),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useTabManagement, prefetchNoteContent, cacheNoteContent, clearPrefetchCache } from './useTabManagement'
|
||||
import {
|
||||
useTabManagement,
|
||||
prefetchNoteContent,
|
||||
cacheNoteContent,
|
||||
clearPrefetchCache,
|
||||
NOTE_CONTENT_CACHE_MAX_BYTES,
|
||||
NOTE_CONTENT_ENTRY_MAX_BYTES,
|
||||
} from './useTabManagement'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
isTauri: vi.fn(() => false),
|
||||
mockInvoke: vi.fn().mockResolvedValue('# Mock content'),
|
||||
}))
|
||||
|
||||
@@ -48,7 +57,6 @@ async function replaceActiveNote(result: HookState, overrides: Partial<VaultEntr
|
||||
}
|
||||
|
||||
async function prefetchResolvedContent(path: string, content: string) {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockResolvedValue(content)
|
||||
prefetchNoteContent(path)
|
||||
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
|
||||
@@ -69,10 +77,35 @@ function createDeferred<T>() {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function makeAsciiContent(byteCount: number): string {
|
||||
return 'x'.repeat(byteCount)
|
||||
}
|
||||
|
||||
function seedCacheBeyondByteLimit() {
|
||||
const cachedContent = makeAsciiContent(Math.floor(NOTE_CONTENT_ENTRY_MAX_BYTES * 0.9))
|
||||
const cachedPaths = Array.from(
|
||||
{ length: Math.floor(NOTE_CONTENT_CACHE_MAX_BYTES / cachedContent.length) + 2 },
|
||||
(_, index) => `/vault/note/cached-${index + 1}.md`,
|
||||
)
|
||||
|
||||
for (const path of cachedPaths) {
|
||||
cacheNoteContent(path, cachedContent)
|
||||
}
|
||||
|
||||
return {
|
||||
cachedContent,
|
||||
oldestPath: cachedPaths[0],
|
||||
newestPath: cachedPaths[cachedPaths.length - 1],
|
||||
}
|
||||
}
|
||||
|
||||
describe('useTabManagement (single-note model)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetAllMocks()
|
||||
clearPrefetchCache()
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Mock content')
|
||||
window.history.replaceState({}, '', '/')
|
||||
})
|
||||
|
||||
it('starts with no note and null active path', () => {
|
||||
@@ -89,8 +122,6 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('switches the active path immediately while the next note is still loading', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
|
||||
let resolveContent: (value: string) => void
|
||||
vi.mocked(mockInvoke).mockImplementationOnce(
|
||||
() => new Promise<string>((resolve) => { resolveContent = resolve }),
|
||||
@@ -131,7 +162,6 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('handles load content failure gracefully', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('fail'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
@@ -144,7 +174,6 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('clears the active note when the file is missing on disk', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File does not exist: /vault/note/missing.md'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const onMissingNotePath = vi.fn()
|
||||
@@ -162,7 +191,6 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('returns to the empty state when note content is not valid UTF-8 text', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File is not valid UTF-8 text: /vault/note/bad.csv'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const onUnreadableNoteContent = vi.fn()
|
||||
@@ -185,7 +213,6 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('returns to the empty state when no active vault is selected', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('No active vault selected'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
@@ -196,6 +223,25 @@ describe('useTabManagement (single-note model)', () => {
|
||||
expect(result.current.activeTabPath).toBeNull()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('uses the note-window vault path when Tauri reloads the selected note', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(invoke).mockResolvedValue('# Window content')
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
'/?window=note&path=%2Fvault%2Fnote%2Ftest.md&vault=%2Fvault&title=Test+Note',
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
await selectNote(result, { path: '/vault/note/test.md', title: 'Test Note' })
|
||||
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('get_note_content', {
|
||||
path: '/vault/note/test.md',
|
||||
vaultPath: '/vault',
|
||||
})
|
||||
expect(result.current.tabs[0].content).toBe('# Window content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleReplaceActiveTab', () => {
|
||||
@@ -207,7 +253,6 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('treats /tmp and /private/tmp aliases as the same active note', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke)
|
||||
.mockResolvedValueOnce('# Stale before pull')
|
||||
.mockResolvedValueOnce('# Fresh after pull')
|
||||
@@ -229,7 +274,6 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('reloads content when replacing with the same entry', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke)
|
||||
.mockResolvedValueOnce('# Stale before pull')
|
||||
.mockResolvedValueOnce('# Fresh after pull')
|
||||
@@ -248,7 +292,6 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('clears the active note when a forced reload hits a missing file path', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke)
|
||||
.mockResolvedValueOnce('# Existing content')
|
||||
.mockRejectedValueOnce(new Error('File does not exist: /vault/a.md'))
|
||||
@@ -354,7 +397,6 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('deduplicates concurrent prefetch requests for same path', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke).mockResolvedValue('# Content')
|
||||
|
||||
prefetchNoteContent('/vault/note/dup.md')
|
||||
@@ -365,7 +407,6 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('swallows no-active-vault prefetch failures and lets a later open recover', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke)
|
||||
.mockRejectedValueOnce(new Error('No active vault selected'))
|
||||
.mockResolvedValueOnce('# Recovered content')
|
||||
@@ -396,7 +437,6 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('activates a warmed note immediately while reusing the cached content', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
const deferred = createDeferred<string>()
|
||||
vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise)
|
||||
cacheNoteContent('/vault/note/warm.md', '# Warm content')
|
||||
@@ -418,8 +458,74 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not retain oversized notes in the prefetch cache', async () => {
|
||||
const largeContent = makeAsciiContent(NOTE_CONTENT_ENTRY_MAX_BYTES + 1)
|
||||
const mockInvoke = await prefetchResolvedContent('/vault/note/oversized.md', largeContent)
|
||||
const deferred = createDeferred<string>()
|
||||
vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise)
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
act(() => {
|
||||
void result.current.handleSelectNote(makeEntry({ path: '/vault/note/oversized.md', title: 'Oversized' }))
|
||||
})
|
||||
|
||||
expect(result.current.activeTabPath).toBe('/vault/note/oversized.md')
|
||||
expect(result.current.tabs).toEqual([])
|
||||
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(largeContent)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(result.current.tabs[0].content).toBe(largeContent)
|
||||
})
|
||||
|
||||
it('evicts the oldest cached notes when retained bytes exceed the cache budget', async () => {
|
||||
const { cachedContent, oldestPath } = seedCacheBeyondByteLimit()
|
||||
const deferred = createDeferred<string>()
|
||||
vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise)
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
act(() => {
|
||||
void result.current.handleSelectNote(makeEntry({ path: oldestPath, title: 'Oldest cached note' }))
|
||||
})
|
||||
|
||||
expect(result.current.activeTabPath).toBe(oldestPath)
|
||||
expect(result.current.tabs).toEqual([])
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(cachedContent)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(result.current.tabs[0].content).toBe(cachedContent)
|
||||
})
|
||||
|
||||
it('keeps the newest cached notes warm when trimming to the byte budget', async () => {
|
||||
const { cachedContent, newestPath } = seedCacheBeyondByteLimit()
|
||||
const deferred = createDeferred<string>()
|
||||
vi.mocked(mockInvoke).mockImplementationOnce(() => deferred.promise)
|
||||
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
|
||||
act(() => {
|
||||
void result.current.handleSelectNote(makeEntry({ path: newestPath, title: 'Newest cached note' }))
|
||||
})
|
||||
|
||||
expect(result.current.activeTabPath).toBe(newestPath)
|
||||
expect(result.current.tabs).toHaveLength(1)
|
||||
expect(result.current.tabs[0].content).toBe(cachedContent)
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(cachedContent)
|
||||
await Promise.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses cached content when reopening a recently loaded note', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke)
|
||||
.mockResolvedValueOnce('# A content')
|
||||
.mockResolvedValueOnce('# B content')
|
||||
@@ -436,7 +542,6 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('falls back instead of reopening cached content when the note file disappeared', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
vi.mocked(mockInvoke)
|
||||
.mockResolvedValueOnce('# Other note')
|
||||
.mockRejectedValueOnce(new Error('File does not exist: /vault/note/missing-cached.md'))
|
||||
@@ -458,8 +563,6 @@ describe('useTabManagement (single-note model)', () => {
|
||||
})
|
||||
|
||||
it('deduplicates a late prefetch after note opening already started', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
|
||||
let resolveContent!: (value: string) => void
|
||||
vi.mocked(mockInvoke).mockImplementationOnce(
|
||||
() => new Promise<string>((resolve) => { resolveContent = resolve }),
|
||||
@@ -486,8 +589,6 @@ describe('useTabManagement (single-note model)', () => {
|
||||
|
||||
describe('rapid switching safety', () => {
|
||||
it('only activates the last note when switching rapidly', async () => {
|
||||
const { mockInvoke } = await import('../mock-tauri')
|
||||
|
||||
let resolveA: (v: string) => void
|
||||
let resolveB: (v: string) => void
|
||||
vi.mocked(mockInvoke)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user