Compare commits
119 Commits
stable-v20
...
stable-v20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da13cb38ac | ||
|
|
dbf8ba5f40 | ||
|
|
3e8a6e5d7c | ||
|
|
8141644729 | ||
|
|
0d88c75718 | ||
|
|
f8721f2a1b | ||
|
|
fb39c6679a | ||
|
|
f1bed131bf | ||
|
|
257cf6ed02 | ||
|
|
908daafaa1 | ||
|
|
d15437face | ||
|
|
30281f879d | ||
|
|
4ef6edfb10 | ||
|
|
918419e371 | ||
|
|
2704002973 | ||
|
|
9265b8f117 | ||
|
|
943fa854e1 | ||
|
|
a2f8ba72a2 | ||
|
|
1d546dde3d | ||
|
|
63877e61ec | ||
|
|
8896f1d451 | ||
|
|
f523099854 | ||
|
|
15f38f096f | ||
|
|
cdd2ddec6c | ||
|
|
292d3739df | ||
|
|
1136c1948e | ||
|
|
8406042db1 | ||
|
|
f07cbbe0eb | ||
|
|
768da9f955 | ||
|
|
f58e9d8930 | ||
|
|
133242eff5 | ||
|
|
aebd2bea4a | ||
|
|
60238fbe78 | ||
|
|
5336236e79 | ||
|
|
f9719f11b5 | ||
|
|
e4083ded1a | ||
|
|
65a89f102a | ||
|
|
5b34ec2980 | ||
|
|
e579979829 | ||
|
|
c7edc71a97 | ||
|
|
5fd8f8fb40 | ||
|
|
694419d442 | ||
|
|
e82fb2a5c7 | ||
|
|
39234dcf52 | ||
|
|
36e8a62284 | ||
|
|
d622b91b81 | ||
|
|
5a546b9a8b | ||
|
|
b8c1a094b6 | ||
|
|
c2c56cfca5 | ||
|
|
449a62e31f | ||
|
|
7d6fd5cc51 | ||
|
|
75ed6460ac | ||
|
|
a3096c596f | ||
|
|
6105c7527c | ||
|
|
92eee0d470 | ||
|
|
f14fda09d5 | ||
|
|
31d12d8cc8 | ||
|
|
6a002f0dc0 | ||
|
|
9d30d1165f | ||
|
|
b709acd58a | ||
|
|
080e3498a7 | ||
|
|
51010ca578 | ||
|
|
15bc3a4701 | ||
|
|
3ebfa642fa | ||
|
|
8207b87c33 | ||
|
|
602a099ac3 | ||
|
|
2315122c4a | ||
|
|
ab4fcdbaae | ||
|
|
a56cb8c287 | ||
|
|
b93e6d491a | ||
|
|
3152ff2c6e | ||
|
|
dd018cf98b | ||
|
|
d0d9d90096 | ||
|
|
944efada94 | ||
|
|
ef4ad256e3 | ||
|
|
c427cd2492 | ||
|
|
151e993ab7 | ||
|
|
60554d0b96 | ||
|
|
4929f11b6d | ||
|
|
3f4e5b585f | ||
|
|
cd49c81bd3 | ||
|
|
b9f3dc9e1a | ||
|
|
122aba7878 | ||
|
|
05896bde19 | ||
|
|
87a933f39c | ||
|
|
54c0efa3e4 | ||
|
|
bdde0c8943 | ||
|
|
a474303eb9 | ||
|
|
4701485ee5 | ||
|
|
135a8a0adf | ||
|
|
0f5d7d5340 | ||
|
|
d6b3c0aef3 | ||
|
|
f896c01829 | ||
|
|
c3cff0c461 | ||
|
|
8c7f9238cb | ||
|
|
38acebba7c | ||
|
|
622977aeb8 | ||
|
|
9ce1c6f854 | ||
|
|
46865638cd | ||
|
|
564ee0a387 | ||
|
|
11655c0283 | ||
|
|
c24fd30266 | ||
|
|
daa1e3e985 | ||
|
|
44c7f43e57 | ||
|
|
f2c83be4b3 | ||
|
|
6104402bfb | ||
|
|
b3744e3bf5 | ||
|
|
67c8598b87 | ||
|
|
52620c80dc | ||
|
|
7e386ff70c | ||
|
|
aba5550b8e | ||
|
|
309b816e6d | ||
|
|
f552d8abff | ||
|
|
9f6779e9f1 | ||
|
|
bddf5106bb | ||
|
|
39415c350f | ||
|
|
499e481df8 | ||
|
|
843cedd14e | ||
|
|
05156ca793 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.94
|
||||
AVERAGE_THRESHOLD=9.83
|
||||
HOTSPOT_THRESHOLD=10.0
|
||||
AVERAGE_THRESHOLD=9.88
|
||||
|
||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
custom: https://refactoring.fm/
|
||||
60
.github/workflows/ci.yml
vendored
60
.github/workflows/ci.yml
vendored
@@ -167,3 +167,63 @@ jobs:
|
||||
|
||||
- name: Format check (Rust)
|
||||
run: cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
|
||||
|
||||
linux-build:
|
||||
name: Linux build verification
|
||||
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 \
|
||||
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:
|
||||
components: clippy
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Frontend build
|
||||
run: pnpm build
|
||||
|
||||
- name: Cargo check
|
||||
run: cargo check --manifest-path=src-tauri/Cargo.toml
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
|
||||
|
||||
368
.github/workflows/release-stable.yml
vendored
368
.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 }})
|
||||
@@ -62,6 +62,8 @@ jobs:
|
||||
include:
|
||||
- arch: aarch64
|
||||
target: aarch64-apple-darwin
|
||||
- arch: x86_64
|
||||
target: x86_64-apple-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -249,12 +251,243 @@ jobs:
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
|
||||
retention-days: 1
|
||||
|
||||
build-linux:
|
||||
name: Build (linux-x86_64)
|
||||
needs: version
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Tauri Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
curl \
|
||||
wget \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached bundle artifacts
|
||||
run: |
|
||||
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Build Tauri app (Linux bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
|
||||
|
||||
- name: Validate Linux bundles
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage or deb bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Linux bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-windows:
|
||||
name: Build (windows-x86_64)
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\.cargo\registry
|
||||
~\.cargo\git
|
||||
src-tauri\target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached Windows bundle artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
|
||||
|
||||
- name: Set version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.version.outputs.version }}"
|
||||
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
|
||||
$tauri.version = $version
|
||||
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
|
||||
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
|
||||
|
||||
- name: Validate Windows release env
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::$name is required to build signed Windows updater artifacts."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build Tauri app (Windows bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
|
||||
- name: Validate Windows bundles
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no installable NSIS or MSI bundle."
|
||||
exit 1
|
||||
fi
|
||||
for installer in "${installers[@]}"; do
|
||||
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
|
||||
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Windows bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 3: Publish GitHub Release
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: GitHub Release (stable)
|
||||
needs: [version, build]
|
||||
needs: [version, build, build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -266,6 +499,54 @@ jobs:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Normalize macOS release artifact names
|
||||
run: |
|
||||
normalize_macos_artifacts() {
|
||||
local arch="$1"
|
||||
local normalized_updater="$2"
|
||||
local normalized_dmg="$3"
|
||||
local updater_dir="updater-${arch}"
|
||||
local updater_file
|
||||
updater_file=$(find "$updater_dir" -maxdepth 1 -name "*.app.tar.gz" -print -quit)
|
||||
if [ -z "$updater_file" ]; then
|
||||
echo "::error::Missing macOS updater artifact in ${updater_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local sig_file="${updater_file}.sig"
|
||||
if [ ! -f "$sig_file" ]; then
|
||||
echo "::error::Missing macOS updater signature for ${updater_file}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local normalized_sig="${normalized_updater}.sig"
|
||||
if [ "$updater_file" != "$normalized_updater" ]; then
|
||||
mv "$updater_file" "$normalized_updater"
|
||||
fi
|
||||
if [ "$sig_file" != "$normalized_sig" ]; then
|
||||
mv "$sig_file" "$normalized_sig"
|
||||
fi
|
||||
|
||||
local dmg_dir="dmg-${arch}"
|
||||
local dmg_file
|
||||
dmg_file=$(find "$dmg_dir" -maxdepth 1 -name "*.dmg" -print -quit)
|
||||
if [ -z "$dmg_file" ]; then
|
||||
echo "::error::Missing macOS DMG artifact in ${dmg_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$dmg_file" != "$normalized_dmg" ]; then
|
||||
mv "$dmg_file" "$normalized_dmg"
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_macos_artifacts aarch64 \
|
||||
"updater-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.app.tar.gz" \
|
||||
"dmg-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.dmg"
|
||||
normalize_macos_artifacts x86_64 \
|
||||
"updater-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.app.tar.gz" \
|
||||
"dmg-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.dmg"
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
PREV_TAG=$(git tag --list 'stable-v*' --sort=-version:refname | grep -vx "${{ needs.version.outputs.tag }}" | head -n 1 || echo "")
|
||||
@@ -282,7 +563,7 @@ jobs:
|
||||
echo "---"
|
||||
echo "**Stable release — manually promoted from \`main\`**"
|
||||
echo ""
|
||||
echo "**Requires Apple Silicon (M1/M2/M3)**"
|
||||
echo "**Includes macOS (Apple Silicon and Intel), 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 +576,42 @@ jobs:
|
||||
REPO_NAME="${REPO#*/}"
|
||||
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename)
|
||||
find_required() {
|
||||
local patterns=("$@")
|
||||
for pattern in "${patterns[@]}"; do
|
||||
set -- $pattern
|
||||
if [ -e "$1" ]; then
|
||||
printf '%s\n' "$1"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "::error::Missing required artifact matching one of: ${patterns[*]}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig")
|
||||
ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}"
|
||||
ARM_SIG=$(cat "$ARM_SIG_FILE")
|
||||
ARM_TARBALL=$(basename "$ARM_UPDATER_FILE")
|
||||
ARM_DMG=$(basename "$(find_required "dmg-aarch64/*.dmg")")
|
||||
|
||||
INTEL_SIG_FILE=$(find_required "updater-x86_64/*.app.tar.gz.sig")
|
||||
INTEL_UPDATER_FILE="${INTEL_SIG_FILE%.sig}"
|
||||
INTEL_SIG=$(cat "$INTEL_SIG_FILE")
|
||||
INTEL_TARBALL=$(basename "$INTEL_UPDATER_FILE")
|
||||
INTEL_DMG=$(basename "$(find_required "dmg-x86_64/*.dmg")")
|
||||
|
||||
LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*/*.deb.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.deb.sig")
|
||||
LINUX_UPDATER_FILE="${LINUX_SIG_FILE%.sig}"
|
||||
LINUX_SIG=$(cat "$LINUX_SIG_FILE")
|
||||
LINUX_UPDATER=$(basename "$LINUX_UPDATER_FILE")
|
||||
LINUX_DOWNLOAD=$(basename "$(find_required "linux-x86_64-bundles/*/*.AppImage" "linux-x86_64-bundles/*/*.deb" "linux-x86_64-bundles/*/*.AppImage.tar.gz" "linux-x86_64-bundles/*.AppImage" "linux-x86_64-bundles/*.deb" "linux-x86_64-bundles/*.AppImage.tar.gz")")
|
||||
|
||||
WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*/*-setup.exe.sig" "windows-x86_64-bundles/*/*.msi.sig" "windows-x86_64-bundles/*/*.nsis.zip.sig" "windows-x86_64-bundles/*/*.msi.zip.sig" "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig")
|
||||
WINDOWS_UPDATER_FILE="${WINDOWS_SIG_FILE%.sig}"
|
||||
WINDOWS_SIG=$(cat "$WINDOWS_SIG_FILE")
|
||||
WINDOWS_UPDATER=$(basename "$WINDOWS_UPDATER_FILE")
|
||||
WINDOWS_DOWNLOAD=$(basename "$(find_required "windows-x86_64-bundles/*/*-setup.exe" "windows-x86_64-bundles/*/*.msi" "windows-x86_64-bundles/*/*.nsis.zip" "windows-x86_64-bundles/*/*.msi.zip" "windows-x86_64-bundles/*-setup.exe" "windows-x86_64-bundles/*.msi" "windows-x86_64-bundles/*.nsis.zip" "windows-x86_64-bundles/*.msi.zip")")
|
||||
|
||||
cat > stable-latest.json << EOF
|
||||
{
|
||||
@@ -309,6 +623,21 @@ 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}"
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
"signature": "${INTEL_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_TARBALL}",
|
||||
"dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_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 +656,33 @@ jobs:
|
||||
dmg-aarch64/*.dmg
|
||||
updater-aarch64/*.app.tar.gz
|
||||
updater-aarch64/*.app.tar.gz.sig
|
||||
dmg-x86_64/*.dmg
|
||||
updater-x86_64/*.app.tar.gz
|
||||
updater-x86_64/*.app.tar.gz.sig
|
||||
linux-x86_64-bundles/*.deb
|
||||
linux-x86_64-bundles/*.deb.sig
|
||||
linux-x86_64-bundles/*.AppImage
|
||||
linux-x86_64-bundles/*.AppImage.sig
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
||||
linux-x86_64-bundles/*/*.deb
|
||||
linux-x86_64-bundles/*/*.deb.sig
|
||||
linux-x86_64-bundles/*/*.AppImage
|
||||
linux-x86_64-bundles/*/*.AppImage.sig
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz.sig
|
||||
windows-x86_64-bundles/*.exe
|
||||
windows-x86_64-bundles/*.exe.sig
|
||||
windows-x86_64-bundles/*.msi
|
||||
windows-x86_64-bundles/*.msi.sig
|
||||
windows-x86_64-bundles/*.zip
|
||||
windows-x86_64-bundles/*.zip.sig
|
||||
windows-x86_64-bundles/*/*.exe
|
||||
windows-x86_64-bundles/*/*.exe.sig
|
||||
windows-x86_64-bundles/*/*.msi
|
||||
windows-x86_64-bundles/*/*.msi.sig
|
||||
windows-x86_64-bundles/*/*.zip
|
||||
windows-x86_64-bundles/*/*.zip.sig
|
||||
stable-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
345
.github/workflows/release.yml
vendored
345
.github/workflows/release.yml
vendored
@@ -121,6 +121,8 @@ jobs:
|
||||
include:
|
||||
- arch: aarch64
|
||||
target: aarch64-apple-darwin
|
||||
- arch: x86_64
|
||||
target: x86_64-apple-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -303,13 +305,243 @@ jobs:
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
|
||||
retention-days: 1
|
||||
|
||||
build-linux:
|
||||
name: Build (linux-x86_64)
|
||||
needs: version
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Tauri Linux system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev \
|
||||
libxdo-dev \
|
||||
libssl-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
curl \
|
||||
wget \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached bundle artifacts
|
||||
run: |
|
||||
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
|
||||
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
|
||||
|
||||
- name: Build Tauri app (Linux bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
|
||||
|
||||
- name: Validate Linux bundles
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no AppImage or deb bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Linux build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Linux bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-windows:
|
||||
name: Build (windows-x86_64)
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\.cargo\registry
|
||||
~\.cargo\git
|
||||
src-tauri\target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clear cached Windows bundle artifacts
|
||||
shell: pwsh
|
||||
run: |
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
|
||||
|
||||
- name: Set version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.version.outputs.version }}"
|
||||
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
|
||||
$tauri.version = $version
|
||||
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
|
||||
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
|
||||
|
||||
- name: Validate Windows release env
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::$name is required to build signed Windows updater artifacts."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build Tauri app (Windows bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
|
||||
- name: Validate Windows bundles
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no installable NSIS or MSI bundle."
|
||||
exit 1
|
||||
fi
|
||||
for installer in "${installers[@]}"; do
|
||||
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
|
||||
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Windows bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 3: Publish GitHub Release
|
||||
# No lipo/re-signing — use the per-arch artifacts directly
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: GitHub Release (alpha)
|
||||
needs: [version, build]
|
||||
needs: [version, build, build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -321,6 +553,37 @@ jobs:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Normalize macOS updater artifact names
|
||||
run: |
|
||||
normalize_updater() {
|
||||
local arch="$1"
|
||||
local normalized_updater="$2"
|
||||
local artifact_dir="updater-${arch}"
|
||||
local updater_file
|
||||
updater_file=$(find "$artifact_dir" -maxdepth 1 -name "*.app.tar.gz" -print -quit)
|
||||
if [ -z "$updater_file" ]; then
|
||||
echo "::error::Missing macOS updater artifact in ${artifact_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local sig_file="${updater_file}.sig"
|
||||
if [ ! -f "$sig_file" ]; then
|
||||
echo "::error::Missing macOS updater signature for ${updater_file}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local normalized_sig="${normalized_updater}.sig"
|
||||
if [ "$updater_file" != "$normalized_updater" ]; then
|
||||
mv "$updater_file" "$normalized_updater"
|
||||
fi
|
||||
if [ "$sig_file" != "$normalized_sig" ]; then
|
||||
mv "$sig_file" "$normalized_sig"
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_updater aarch64 "updater-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.app.tar.gz"
|
||||
normalize_updater x86_64 "updater-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.app.tar.gz"
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
PREV_TAG=$(python3 <<'PY'
|
||||
@@ -357,7 +620,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 Intel), Linux x64, and Windows x64 bundles**"
|
||||
echo ""
|
||||
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
|
||||
} > release_notes.md
|
||||
@@ -370,8 +633,38 @@ 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")
|
||||
|
||||
INTEL_SIG_FILE=$(find_required "updater-x86_64/*.app.tar.gz.sig")
|
||||
INTEL_UPDATER_FILE="${INTEL_SIG_FILE%.sig}"
|
||||
INTEL_SIG=$(cat "$INTEL_SIG_FILE")
|
||||
INTEL_UPDATER=$(basename "$INTEL_UPDATER_FILE")
|
||||
|
||||
LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*/*.deb.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.deb.sig")
|
||||
LINUX_UPDATER_FILE="${LINUX_SIG_FILE%.sig}"
|
||||
LINUX_SIG=$(cat "$LINUX_SIG_FILE")
|
||||
LINUX_UPDATER=$(basename "$LINUX_UPDATER_FILE")
|
||||
LINUX_DOWNLOAD=$(basename "$(find_required "linux-x86_64-bundles/*/*.AppImage" "linux-x86_64-bundles/*/*.deb" "linux-x86_64-bundles/*/*.AppImage.tar.gz" "linux-x86_64-bundles/*.AppImage" "linux-x86_64-bundles/*.deb" "linux-x86_64-bundles/*.AppImage.tar.gz")")
|
||||
|
||||
WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*/*-setup.exe.sig" "windows-x86_64-bundles/*/*.msi.sig" "windows-x86_64-bundles/*/*.nsis.zip.sig" "windows-x86_64-bundles/*/*.msi.zip.sig" "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig")
|
||||
WINDOWS_UPDATER_FILE="${WINDOWS_SIG_FILE%.sig}"
|
||||
WINDOWS_SIG=$(cat "$WINDOWS_SIG_FILE")
|
||||
WINDOWS_UPDATER=$(basename "$WINDOWS_UPDATER_FILE")
|
||||
WINDOWS_DOWNLOAD=$(basename "$(find_required "windows-x86_64-bundles/*/*-setup.exe" "windows-x86_64-bundles/*/*.msi" "windows-x86_64-bundles/*/*.nsis.zip" "windows-x86_64-bundles/*/*.msi.zip" "windows-x86_64-bundles/*-setup.exe" "windows-x86_64-bundles/*.msi" "windows-x86_64-bundles/*.nsis.zip" "windows-x86_64-bundles/*.msi.zip")")
|
||||
|
||||
cat > alpha-latest.json << EOF
|
||||
{
|
||||
@@ -381,7 +674,23 @@ 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}"
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
"signature": "${INTEL_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_UPDATER}"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"signature": "${LINUX_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_DOWNLOAD}"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": "${WINDOWS_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_DOWNLOAD}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,6 +708,32 @@ jobs:
|
||||
files: |
|
||||
updater-aarch64/*.app.tar.gz
|
||||
updater-aarch64/*.app.tar.gz.sig
|
||||
updater-x86_64/*.app.tar.gz
|
||||
updater-x86_64/*.app.tar.gz.sig
|
||||
linux-x86_64-bundles/*.deb
|
||||
linux-x86_64-bundles/*.deb.sig
|
||||
linux-x86_64-bundles/*.AppImage
|
||||
linux-x86_64-bundles/*.AppImage.sig
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
||||
linux-x86_64-bundles/*/*.deb
|
||||
linux-x86_64-bundles/*/*.deb.sig
|
||||
linux-x86_64-bundles/*/*.AppImage
|
||||
linux-x86_64-bundles/*/*.AppImage.sig
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz.sig
|
||||
windows-x86_64-bundles/*.exe
|
||||
windows-x86_64-bundles/*.exe.sig
|
||||
windows-x86_64-bundles/*.msi
|
||||
windows-x86_64-bundles/*.msi.sig
|
||||
windows-x86_64-bundles/*.zip
|
||||
windows-x86_64-bundles/*.zip.sig
|
||||
windows-x86_64-bundles/*/*.exe
|
||||
windows-x86_64-bundles/*/*.exe.sig
|
||||
windows-x86_64-bundles/*/*.msi
|
||||
windows-x86_64-bundles/*/*.msi.sig
|
||||
windows-x86_64-bundles/*/*.zip
|
||||
windows-x86_64-bundles/*/*.zip.sig
|
||||
alpha-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
49
CONTRIBUTING.md
Normal file
49
CONTRIBUTING.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Contributing to Tolaria
|
||||
|
||||
Thanks for being here! Tolaria is still early, and every bug report, idea, and contribution genuinely helps shape the app.
|
||||
|
||||
## 🗳️ Where to share what
|
||||
|
||||
To keep things clean:
|
||||
|
||||
- 🐛 Bugs → GitHub Issues
|
||||
- 💡 Feature requests / ideas → Canny • <https://tolaria.canny.io/>
|
||||
|
||||
If you have a feature idea, please check Canny first and upvote it if it already exists.
|
||||
|
||||
## 📥 Pull requests are welcome
|
||||
|
||||
PRs are very welcome.
|
||||
|
||||
A few things to keep in mind before opening one:
|
||||
|
||||
- Bug fixes are always great
|
||||
- Small improvements are great too
|
||||
- For bigger features, please check Canny first before building
|
||||
- Try to avoid things that are already marked **in progress**
|
||||
- Requests marked **planned** are usually great contribution targets
|
||||
- Keep PRs small, focused, and easy to review
|
||||
- Include a short explanation of the problem and your solution
|
||||
- Follow the dev process described in Tolaria’s `AGENTS.md` (tests, code health, etc.)
|
||||
- Avoid bundling unrelated refactors into the same PR
|
||||
|
||||
If you want to contribute a feature, the best place to start is here: <https://tolaria.canny.io/>
|
||||
|
||||
## 📋 What makes a good bug report
|
||||
|
||||
If you open a bug report on GitHub, it really helps to include:
|
||||
|
||||
- your Tolaria version
|
||||
- your OS version
|
||||
- steps to reproduce
|
||||
- what you expected to happen
|
||||
- what actually happened
|
||||
- screenshots or screen recordings if useful
|
||||
|
||||
The clearer the report, the easier it is for us to reproduce and fix it.
|
||||
|
||||
## 🙏 Thank you
|
||||
|
||||
Tolaria is getting better because people care enough to try it, report what’s broken, suggest what’s missing, and contribute improvements.
|
||||
|
||||
That means a lot. Thanks for helping build it.
|
||||
27
README.md
27
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
# 💧 Tolaria
|
||||
|
||||
Tolaria is a desktop app for Mac for managing **markdown knowledge bases**. People use it for a variety of use cases:
|
||||
Tolaria is a desktop app for Mac and Linux for managing **markdown knowledge bases**. People use it for a variety of use cases:
|
||||
|
||||
* Operate second brains and personal knowledge
|
||||
* Organize company docs as context for AI
|
||||
@@ -46,7 +46,30 @@ Tolaria is open source and built with Tauri, React, and TypeScript. If you want
|
||||
- Node.js 20+
|
||||
- pnpm 8+
|
||||
- Rust stable
|
||||
- macOS for development
|
||||
- macOS or Linux for development
|
||||
|
||||
#### Linux system dependencies
|
||||
|
||||
Tauri 2 on Linux requires WebKit2GTK 4.1 and GTK 3:
|
||||
|
||||
- Arch / Manjaro:
|
||||
```bash
|
||||
sudo pacman -S --needed webkit2gtk-4.1 base-devel curl wget file openssl \
|
||||
appmenu-gtk-module libappindicator-gtk3 librsvg
|
||||
```
|
||||
- Debian / Ubuntu (22.04+):
|
||||
```bash
|
||||
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
|
||||
libsoup-3.0-dev patchelf
|
||||
```
|
||||
- Fedora 38+:
|
||||
```bash
|
||||
sudo dnf install webkit2gtk4.1-devel openssl-devel curl wget file \
|
||||
libappindicator-gtk3-devel librsvg2-devel
|
||||
```
|
||||
|
||||
The bundled MCP server still spawns the system `node` binary at runtime on Linux, so install Node from your distro package manager if you want the external AI tooling flow.
|
||||
|
||||
### Quick start
|
||||
|
||||
|
||||
@@ -57,6 +57,17 @@ The frontmatter parser (Rust: `vault/mod.rs`, TS: `utils/frontmatter.ts`) must f
|
||||
|
||||
All data lives in markdown files with YAML frontmatter. There is no database — the filesystem is the source of truth.
|
||||
|
||||
### Vault Git Capability
|
||||
|
||||
Git is a per-vault capability, not a prerequisite for the document model. A vault can be:
|
||||
|
||||
| State | Meaning | UI behavior |
|
||||
|---|---|---|
|
||||
| Git-backed | The vault path contains a Git repository | History, changes, commits, sync, conflict resolution, remotes, AutoGit, and auto-sync are available according to remote/config state |
|
||||
| Non-git | The vault path is a plain folder | Markdown scanning, editing, search, and navigation work; Git-dependent status-bar controls and command-palette entries are replaced by `Git disabled` + `Initialize Git for Current Vault` |
|
||||
|
||||
Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. Features that depend on Git must check this capability instead of assuming every vault has `.git`.
|
||||
|
||||
### VaultEntry
|
||||
|
||||
The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`).
|
||||
@@ -132,9 +143,22 @@ interface VaultEntry {
|
||||
trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent)
|
||||
trashedAt: number | null // Kept for backward compatibility (Trash system removed)
|
||||
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
|
||||
fileKind?: 'markdown' | 'text' | 'binary' // Controls editor/raw/preview behavior
|
||||
}
|
||||
```
|
||||
|
||||
### File kinds and binary previews
|
||||
|
||||
`VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained:
|
||||
|
||||
| `fileKind` | Source files | UI behavior |
|
||||
|---|---|---|
|
||||
| `markdown` or absent | `.md`, `.markdown` | Full Tolaria note model: frontmatter, BlockNote, raw editor, relationships, title sync |
|
||||
| `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics |
|
||||
| `binary` | Images, PDFs, archives, other non-text files | Stays a normal vault file; previewable images open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
|
||||
|
||||
Image previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
|
||||
|
||||
### Entity Types (isA / type)
|
||||
|
||||
Entity type is stored in the `type:` frontmatter field (e.g. `type: Quarter`). The legacy field name `Is A:` is still accepted as an alias for backwards compatibility but new notes use `type:`. The `VaultEntry.isA` property in TypeScript/Rust holds the resolved value.
|
||||
@@ -240,6 +264,9 @@ 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.
|
||||
- **Unicode-aware note stems** (`src/utils/noteSlug.ts`, `vault/rename.rs`): frontend and backend slugging preserve Unicode letters/digits in note filenames, untitled-rename detection, and fallback wikilink targets while still collapsing symbol-only titles to `untitled`.
|
||||
- **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.
|
||||
- **Recoverable save failures** (`useEditorSave`, `vault/file.rs`): invalid platform path syntax is reported as a clear retryable save error, while the editor keeps the unsaved buffer intact for another attempt.
|
||||
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
|
||||
|
||||
### Title Surface (UI)
|
||||
@@ -268,7 +295,7 @@ type SidebarSelection =
|
||||
|
||||
`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight.
|
||||
|
||||
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated.
|
||||
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
|
||||
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
|
||||
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
|
||||
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
|
||||
@@ -311,7 +338,9 @@ 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.
|
||||
|
||||
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary.
|
||||
|
||||
### Vault Caching
|
||||
|
||||
@@ -471,6 +500,15 @@ Defined in `src/components/editorSchema.tsx` and styled in `src/components/Edito
|
||||
- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript while still supporting the packaged language aliases such as `ts` → `typescript`.
|
||||
- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep BlockNote's dark shell instead of inheriting the muted inline surface.
|
||||
|
||||
### Markdown Math
|
||||
|
||||
Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
|
||||
|
||||
- `$...$` becomes a `mathInline` schema node and line-owned `$$...$$` / multiline `$$` blocks become `mathBlock` nodes.
|
||||
- The rich editor renders both node types through KaTeX with `throwOnError: false`, so malformed formulas keep their source visible instead of breaking the note.
|
||||
- `serializeMathAwareBlocks()` converts math nodes back to Markdown delimiters before save, raw-mode entry, and editor-position snapshots.
|
||||
- Raw CodeMirror mode always shows the plain Markdown source, so imported technical notes stay editable outside Tolaria.
|
||||
|
||||
### Formatting Surface Policy
|
||||
|
||||
Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`:
|
||||
@@ -480,6 +518,9 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
|
||||
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
|
||||
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
|
||||
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface.
|
||||
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
|
||||
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features.
|
||||
- `useNativePathDrop()` is the shared Tauri file/folder-drop abstraction for text inputs that need filesystem paths instead of attachment import. It consumes native window drag/drop events, gates them to the target element bounds or focused text selection, and lets AI composer / command-palette inputs insert formatted paths at the current cursor.
|
||||
|
||||
### Markdown-to-BlockNote Pipeline
|
||||
|
||||
@@ -487,22 +528,23 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
|
||||
flowchart LR
|
||||
A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"]
|
||||
B --> C["preProcessWikilinks(body)\n[[target]] → ‹token›"]
|
||||
C --> D["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
|
||||
D --> E["injectWikilinks(blocks)\n‹token› → WikiLink node"]
|
||||
E --> F["editor.replaceBlocks()\n→ rendered editor"]
|
||||
C --> D["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
|
||||
D --> E["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
|
||||
E --> F["injectWikilinks + injectMathInBlocks\n tokens → schema nodes"]
|
||||
F --> G["editor.replaceBlocks()\n→ rendered editor"]
|
||||
|
||||
style A fill:#f8f9fa,stroke:#6c757d,color:#000
|
||||
style F fill:#d4edda,stroke:#28a745,color:#000
|
||||
style G fill:#d4edda,stroke:#28a745,color:#000
|
||||
```
|
||||
|
||||
> Placeholder tokens use `\u2039` and `\u203A` to avoid colliding with markdown syntax.
|
||||
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math placeholder tokens use ASCII sentinels with URI-encoded LaTeX payloads.
|
||||
|
||||
### BlockNote-to-Markdown Pipeline (Save)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["✏️ BlockNote blocks\n(editor state)"] --> B["blocksToMarkdownLossy()"]
|
||||
B --> C["postProcessWikilinks()\nWikiLink node → [[target]]"]
|
||||
B --> C["restoreWikilinks + serializeMathAwareBlocks()\nschema nodes → Markdown source"]
|
||||
C --> D["prepend frontmatter yaml"]
|
||||
D --> E["invoke('save_note_content')\n→ disk write"]
|
||||
|
||||
@@ -524,6 +566,8 @@ Wikilink resolution (`resolveEntry` in `src/utils/wikilink.ts`) uses multi-pass
|
||||
Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command.
|
||||
While the user types, `useEditorSaveWithLinks` derives a transient `VaultEntry` patch from parseable frontmatter so the Inspector, relationship chips, and note-list-visible metadata stay in sync with the raw editor before the next vault reload. Temporarily invalid or half-typed frontmatter is ignored until it becomes parseable again, which avoids clobbering the last known good derived state.
|
||||
|
||||
Current-note find/replace is intentionally backed by raw CodeMirror mode. `Cmd+F`, "Find in Note", and "Replace in Note" switch the active Markdown/text note to raw mode, show the compact find bar above CodeMirror, and operate on the current note only. Plain text matching is case-insensitive by default, `Aa` toggles case sensitivity, `.*` toggles JavaScript-regex matching, and regex replacement supports capture groups through JavaScript replacement syntax.
|
||||
|
||||
### Arrow Ligature Normalization
|
||||
|
||||
Typed ASCII arrow sequences are normalized consistently in both editor modes:
|
||||
@@ -534,10 +578,22 @@ 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
|
||||
|
||||
## Localization
|
||||
|
||||
App UI strings are centralized in `src/lib/i18n.ts` (see [ADR-0084](adr/0084-app-localization-foundation.md)):
|
||||
|
||||
- `AppLocale`: currently `'en' | 'zh-Hans'`
|
||||
- `UiLanguagePreference`: `'system' | AppLocale`; persisted settings serialize `system` as `null`
|
||||
- `resolveEffectiveLocale()`: maps an explicit preference or system/browser language list to the effective supported locale
|
||||
- `translate()` / `createTranslator()`: resolve keys with English fallback and simple `{name}` interpolation
|
||||
|
||||
`App.tsx` owns the effective locale and passes it to localized app chrome through props. Settings and command-palette language commands call back into `saveSettings`, so UI language changes update the current session without touching vault content or reopening the vault.
|
||||
|
||||
## Inspector Abstraction
|
||||
|
||||
@@ -593,7 +649,7 @@ No indexing step required — search runs directly against the filesystem.
|
||||
|
||||
Per-vault settings stored locally and scoped by vault path:
|
||||
- Managed by `useVaultConfig` hook and `vaultConfigStore`
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes, Inbox note-list column overrides, explicit organization workflow toggle
|
||||
- Settings: zoom, view mode, editor mode, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
### AI Guidance Files
|
||||
@@ -621,15 +677,16 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
|
||||
`useAiAgentsOnboarding(enabled)` adds a separate first-launch agent step:
|
||||
- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key)
|
||||
- Only shows after vault onboarding has already resolved to a ready state
|
||||
- Uses `get_ai_agents_status`, whose backend treats the app process path, login-shell path, and supported local/toolchain/app install locations, including Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
|
||||
- Persists dismissal locally once the user continues
|
||||
|
||||
### Remote Git Operations
|
||||
|
||||
Tolaria delegates remote auth to the user's system git setup:
|
||||
- `CloneVaultModal` captures a remote URL and local destination
|
||||
- `clone_repo` shells out to system git for clone operations
|
||||
- `clone_git_repo` and `create_getting_started_vault` both run system git clone work in blocking Tokio tasks so clone UIs stay responsive
|
||||
- `git_add_remote` uses the same system git path and refuses remotes whose history is unrelated or ahead of the local vault
|
||||
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors
|
||||
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors, and clone commands fail fast when git wants interactive terminal input
|
||||
- No provider-specific token or username is stored in app settings
|
||||
|
||||
## Settings
|
||||
@@ -647,11 +704,13 @@ 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
|
||||
ui_language: 'en' | 'zh-Hans' | 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. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. `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
|
||||
|
||||
@@ -691,6 +750,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
|
||||
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
|
||||
|
||||
### 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.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` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names.
|
||||
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.
|
||||
|
||||
@@ -32,6 +32,7 @@ Examples:
|
||||
- ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties)
|
||||
- ✅ Vault: `_icon: shapes` in a Type note (icon is part of the type's identity)
|
||||
- ✅ App settings: `zoom: 1.3` (machine-specific preference)
|
||||
- ✅ App settings: `ui_language: "zh-Hans"` (installation-specific UI language)
|
||||
|
||||
### No hardcoded exceptions
|
||||
|
||||
@@ -99,6 +100,7 @@ flowchart LR
|
||||
| Frontmatter parsing | gray_matter | 0.2 |
|
||||
| AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - |
|
||||
| Search | Keyword (walkdir-based file scan) | - |
|
||||
| Localization | App-owned dictionary (`src/lib/i18n.ts`) | English fallback + `zh-Hans` |
|
||||
| MCP | @modelcontextprotocol/sdk | 1.0 |
|
||||
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
|
||||
| Package manager | pnpm | - |
|
||||
@@ -176,15 +178,20 @@ flowchart TD
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
|
||||
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
|
||||
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image binaries get an image indicator and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide-screen left-aligned note column while preserving the same readable max width. Binary image files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize.
|
||||
|
||||
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds sidebar / note-list / expanded-inspector allowances on top, and calls the native `update_current_window_min_size` command whenever view mode or inspector visibility changes. That same native command also grows the current window back out when a wider pane combination is restored, while note windows skip this path and keep their dedicated 800×700 initial sizing.
|
||||
|
||||
The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline.
|
||||
|
||||
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that macOS uses for native menu clicks.
|
||||
When Tolaria is launched from a Linux AppImage, `run()` also injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` unless the user already set that variable. This keeps the workaround scoped to bundled WebKitGTK launches that are prone to Fedora/Wayland DMA-BUF crashes without changing native package installs.
|
||||
|
||||
## Multi-Window (Note Windows)
|
||||
|
||||
Notes can be opened in separate Tauri windows for focused editing. Secondary windows show only the editor panel (no sidebar, no note list).
|
||||
@@ -200,7 +207,7 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window)
|
||||
- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance
|
||||
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload
|
||||
- Secondary windows are sized 800×700 with overlay title bar
|
||||
- Secondary windows are sized 800×700; macOS keeps the overlay title bar, while Linux mounts the shared React titlebar on undecorated windows
|
||||
- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels
|
||||
|
||||
## AI System
|
||||
@@ -214,7 +221,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
|
||||
3. **Agent adapters** — Claude Code still uses `claude_cli.rs`; Codex runs through `codex exec --json` with the CLI's normal approval / sandbox defaults
|
||||
4. **MCP Integration** — Claude receives the generated MCP config file path, while Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides
|
||||
|
||||
Claude Code availability intentionally does not depend only on the desktop app's inherited `PATH`. The detector checks the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, npm-global, and Homebrew paths so first-run onboarding works on fresh macOS installs.
|
||||
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs.
|
||||
|
||||
#### Agent Event Flow
|
||||
|
||||
@@ -310,8 +317,9 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|
||||
### Explicit External Tool Setup
|
||||
|
||||
Tolaria can register itself as an MCP server in:
|
||||
- `~/.claude/mcp.json` (Claude Code)
|
||||
- `~/.claude.json` and `~/.claude/mcp.json` (Claude Code compatibility across current CLI and legacy MCP-file setups)
|
||||
- `~/.cursor/mcp.json` (Cursor)
|
||||
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
|
||||
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`).
|
||||
|
||||
@@ -332,7 +340,7 @@ flowchart TD
|
||||
end
|
||||
|
||||
TAURI["Tauri (mcp.rs)"] -->|"spawn on startup"| MCP
|
||||
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude/mcp.json\n~/.cursor/mcp.json"]
|
||||
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json\n~/.config/mcp/mcp.json"]
|
||||
```
|
||||
|
||||
### WebSocket Bridge
|
||||
@@ -359,8 +367,8 @@ flowchart LR
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
|
||||
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code and Cursor MCP configs on explicit user request |
|
||||
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code and Cursor configs |
|
||||
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code, Cursor, and generic MCP configs on explicit user request |
|
||||
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Cursor, and generic MCP configs |
|
||||
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
|
||||
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
|
||||
@@ -404,10 +412,17 @@ 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.
|
||||
|
||||
## Localization
|
||||
|
||||
Tolaria's app chrome uses an app-owned localization layer in `src/lib/i18n.ts` (see [ADR-0084](adr/0084-app-localization-foundation.md)). English is the canonical fallback, and Simplified Chinese (`zh-Hans`) is the first additional locale. The installation-local `ui_language` setting stores an explicit locale when the user chooses one; `null` means "follow the system language when Tolaria supports it, otherwise English." Missing translation keys fall back to English so partially translated locales do not render broken placeholders.
|
||||
|
||||
`App.tsx` derives the effective locale from settings and browser/system language hints, then passes it down to localized surfaces. Settings exposes a keyboard-accessible shadcn `Select`, and the command palette includes actions to open language settings or switch directly to a supported language.
|
||||
|
||||
## Vault Management
|
||||
|
||||
@@ -430,6 +445,7 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
|
||||
- `zoom`: Float zoom level (0.8–1.5)
|
||||
- `view_mode`: "all" | "editor-list" | "editor-only"
|
||||
- `editor_mode`: "raw" | "preview" (persists across note switches and sessions)
|
||||
- `note_layout`: "centered" | "left" (wide-screen note column alignment for rich and raw editors)
|
||||
- `tag_colors`, `status_colors`: Custom color overrides
|
||||
- `property_display_modes`: Property display preferences
|
||||
- `inbox.noteListProperties`: Optional Inbox-only property chip override for the note list
|
||||
@@ -440,10 +456,12 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
|
||||
|
||||
On first launch, `useOnboarding` checks if the default vault exists. If not, it shows `WelcomeScreen` with three options:
|
||||
- **Create a new vault** → creates an empty git repo in a folder the user chooses
|
||||
- **Open an existing folder** → system file picker
|
||||
- **Open an existing folder** → system file picker; plain Markdown folders without `.git` open immediately in supported non-git mode
|
||||
- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately
|
||||
|
||||
When an opened folder is not yet a git repo, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
|
||||
|
||||
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
|
||||
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
|
||||
|
||||
@@ -460,9 +478,9 @@ Tolaria no longer implements provider-specific OAuth or remote-repository APIs.
|
||||
**Flow:**
|
||||
1. User opens `CloneVaultModal` from onboarding or the vault menu
|
||||
2. User pastes any git URL and chooses a local destination
|
||||
3. `clone_repo()` shells out to `git clone`
|
||||
3. The `clone_git_repo()` Tauri command runs `git clone` inside a blocking Tokio task so the Tauri window stays responsive during slow or failing clones
|
||||
4. `git_push()` / `git_pull()` continue to use the same system git path
|
||||
5. If auth fails, the raw git stderr is surfaced in the UI
|
||||
5. Clone commands disable interactive terminal / askpass prompts and surface the git failure back to the UI instead of freezing the app waiting for input
|
||||
|
||||
**Auth model:**
|
||||
- SSH keys, Git Credential Manager, macOS Keychain helpers, `gh auth`, and other git helpers all work without app-specific setup
|
||||
@@ -493,6 +511,7 @@ sequenceDiagram
|
||||
participant MCP as MCP Server
|
||||
participant U as User
|
||||
|
||||
T->>T: apply Linux AppImage WebKit env override<br/>(AppImage only)
|
||||
T->>T: run_startup_tasks()<br/>(migrate + seed only)
|
||||
T->>MCP: spawn_ws_bridge() — ports 9710 + 9711
|
||||
T->>A: App mounts
|
||||
@@ -566,6 +585,8 @@ flowchart TD
|
||||
|
||||
`useGitRemoteStatus` re-checks `git_remote_status` when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps the flow local-only: the status bar shows a neutral `No remote` chip, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
|
||||
|
||||
If the current vault is not a Git repository, Tolaria treats Git as disabled instead of degraded. The status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders.
|
||||
|
||||
The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds.
|
||||
|
||||
`useCommitFlow` also exposes `runAutomaticCheckpoint()`, a dialog-free commit path shared by AutoGit and the bottom-bar Commit button. `useAutoGit` watches the last editor activity plus app focus/visibility state, and when the vault is git-backed, all saves are flushed, and no unsaved edits remain, it triggers the same deterministic `Updated N note(s)` / `Updated N file(s)` commit message path after the configured idle or inactive thresholds. The bottom-bar quick action reuses that checkpoint flow after forcing a save first, so manual quick commits and scheduled AutoGit commits stay aligned on message generation and push behavior.
|
||||
@@ -590,8 +611,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 |
|
||||
@@ -611,7 +633,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `settings.rs` | App settings persistence |
|
||||
| `vault_config.rs` | Per-vault UI config |
|
||||
| `vault_list.rs` | Vault list persistence |
|
||||
| `menu.rs` | Native macOS menu bar |
|
||||
| `menu.rs` | Native desktop menu definitions and command IDs (not mounted on Linux) |
|
||||
|
||||
## Tauri IPC Commands
|
||||
|
||||
@@ -691,9 +713,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `check_claude_cli` | Check if Claude CLI is available |
|
||||
| `get_ai_agents_status` | Check Claude Code + Codex availability |
|
||||
| `stream_ai_agent` | Stream the selected CLI agent through the normalized event layer |
|
||||
| `register_mcp_tools` | Register MCP in Claude/Cursor config for the active vault |
|
||||
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor config |
|
||||
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor config |
|
||||
| `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault |
|
||||
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config |
|
||||
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor/generic config |
|
||||
|
||||
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
|
||||
|
||||
@@ -709,8 +731,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 |
|
||||
@@ -745,17 +767,19 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
|
||||
| `useNoteRename` | — | Note renaming and folder moves with wikilink update |
|
||||
| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions |
|
||||
| `useTauriDragDropEvent` | — | Shared native window drag/drop event subscription and cleanup |
|
||||
| `useNativePathDrop` | — | Tauri-native file/folder path drops for AI and command text inputs |
|
||||
| `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, UI language, 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 |
|
||||
|
||||
@@ -769,20 +793,24 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
|
||||
| Cmd+P / Cmd+O | Open quick open palette |
|
||||
| Cmd+N | Create new note |
|
||||
| Cmd+S | Save current note |
|
||||
| Cmd+F | Find in current note when the editor is focused; otherwise note-list search can claim it |
|
||||
| Cmd+Shift+F | Find in vault |
|
||||
| Cmd+[ / Cmd+] | Navigate back / forward (replaces tabs) |
|
||||
| Cmd+Z / Cmd+Shift+Z | Undo / Redo |
|
||||
| Cmd+1–9 | Switch to tab N |
|
||||
| Cmd+[ / Cmd+] | Navigate back / forward |
|
||||
| `[[` in editor | Open wikilink suggestion menu |
|
||||
|
||||
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Rename Folder" and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
|
||||
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
|
||||
|
||||
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` and `useMenuEvents` emit the same command IDs for native menu clicks and accelerators
|
||||
- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
|
||||
- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active
|
||||
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same command IDs for native menu clicks, accelerators, and custom titlebar menu actions
|
||||
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
|
||||
- Deterministic QA uses two explicit proof paths from the shared manifest:
|
||||
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`
|
||||
@@ -803,9 +831,13 @@ push to main
|
||||
→ if stable already uses today, advance alpha to the next calendar day so semver still increases
|
||||
→ build job:
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app
|
||||
→ upload signed .app.tar.gz + .sig updater artifacts
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin --bundles app
|
||||
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig updater artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
|
||||
→ 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
|
||||
→ generate alpha-latest.json with darwin-aarch64, darwin-x86_64, Linux, and Windows updater URLs
|
||||
→ publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N
|
||||
→ pages job:
|
||||
→ build static HTML release history page
|
||||
@@ -822,13 +854,20 @@ push stable-vYYYY.M.D tag
|
||||
→ version job: validate YYYY.M.D from the tag
|
||||
→ build job:
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
|
||||
→ upload signed .app.tar.gz + .sig and .dmg artifacts
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin
|
||||
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig and .dmg artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
|
||||
→ 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 Apple Silicon, macOS Intel, 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
|
||||
```
|
||||
|
||||
@@ -8,6 +8,27 @@ How to navigate the codebase, run the app, and find what you need.
|
||||
- **Rust** 1.77.2+ (for the Tauri backend)
|
||||
- **git** CLI (required by the git integration features)
|
||||
|
||||
### Linux system dependencies
|
||||
|
||||
If you run the desktop app on Linux, install Tauri's WebKit2GTK 4.1 dependencies first:
|
||||
|
||||
- Arch / Manjaro:
|
||||
```bash
|
||||
sudo pacman -S --needed webkit2gtk-4.1 base-devel curl wget file openssl \
|
||||
appmenu-gtk-module libappindicator-gtk3 librsvg
|
||||
```
|
||||
- Debian / Ubuntu (22.04+):
|
||||
```bash
|
||||
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
|
||||
libsoup-3.0-dev patchelf
|
||||
```
|
||||
- Fedora 38+:
|
||||
```bash
|
||||
sudo dnf install webkit2gtk4.1-devel openssl-devel curl wget file \
|
||||
libappindicator-gtk3-devel librsvg2-devel
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -42,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
|
||||
@@ -68,6 +89,8 @@ tolaria/
|
||||
│ │ ├── CommandPalette.tsx # Cmd+K command launcher
|
||||
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
|
||||
│ │ ├── WelcomeScreen.tsx # Onboarding screen
|
||||
│ │ ├── LinuxTitlebar.tsx # Linux-only custom window chrome + controls
|
||||
│ │ ├── LinuxMenuButton.tsx # Linux titlebar menu mirroring app commands
|
||||
│ │ ├── CloneVaultModal.tsx # Clone a vault from any git URL
|
||||
│ │ ├── AddRemoteModal.tsx # Connect a local-only vault to a remote later
|
||||
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
|
||||
@@ -118,6 +141,7 @@ tolaria/
|
||||
│ ├── utils/ # Pure utility functions (~48 files)
|
||||
│ │ ├── wikilinks.ts # Wikilink preprocessing pipeline
|
||||
│ │ ├── frontmatter.ts # TypeScript YAML parser
|
||||
│ │ ├── platform.ts # Runtime platform + Linux chrome gating helpers
|
||||
│ │ ├── ai-agent.ts # Agent stream utilities
|
||||
│ │ ├── ai-chat.ts # Token estimation utilities
|
||||
│ │ ├── ai-context.ts # Context snapshot builder
|
||||
@@ -133,6 +157,7 @@ tolaria/
|
||||
│ ├── lib/
|
||||
│ │ ├── aiAgents.ts # Shared agent registry + status helpers
|
||||
│ │ ├── appUpdater.ts # Frontend wrapper around channel-aware updater commands
|
||||
│ │ ├── i18n.ts # App-owned localization dictionary and locale resolution
|
||||
│ │ ├── releaseChannel.ts # Alpha/stable normalization helpers
|
||||
│ │ └── utils.ts # Tailwind merge + cn() helper
|
||||
│ │
|
||||
@@ -263,19 +288,19 @@ 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, UI language, 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. |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, default AI agent, and the vault-level explicit organization toggle. |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, default AI agent, and the vault-level explicit organization toggle. |
|
||||
| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
|
||||
|
||||
## Architecture Patterns
|
||||
@@ -311,10 +336,12 @@ type SidebarSelection =
|
||||
|
||||
### Command Registry
|
||||
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
|
||||
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
|
||||
|
||||
Current-note find/replace is a surface-aware command: editor focus enables "Find in Note" / "Replace in Note" and routes Cmd+F into raw CodeMirror mode; note-list focus enables existing note-list search instead. When adding another focus-dependent command, mirror this pattern with an availability event consumed by `useMenuEvents.ts` and `update_menu_state`.
|
||||
|
||||
For automated shortcut QA, use the explicit proof path from `appCommandCatalog.ts`:
|
||||
|
||||
- `window.__laputaTest.triggerShortcutCommand()` for deterministic renderer shortcut-event coverage
|
||||
@@ -380,7 +407,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
|
||||
|
||||
37
docs/adr/0079-linux-window-chrome-and-menu-reuse.md
Normal file
37
docs/adr/0079-linux-window-chrome-and-menu-reuse.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0079"
|
||||
title: "Linux window chrome and menu reuse"
|
||||
status: active
|
||||
date: 2026-04-24
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's desktop shell was designed around macOS window chrome. `titleBarStyle: "Overlay"` and `hiddenTitle: true` give the app a clean single-surface titlebar on macOS, but Linux ignores those flags and draws native GTK decorations and a native menu bar on top of the React UI. That creates a double-titlebar effect, mismatched theming, and inconsistent behavior between the main window and detached note windows.
|
||||
|
||||
We still need Linux to reuse Tolaria's existing command palette, shortcut manifest, and deterministic menu-command routing instead of inventing a Linux-only command path.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria uses custom React-rendered window chrome on Linux and routes its menu through the existing shared command IDs.**
|
||||
|
||||
- The main Tauri window disables server-side decorations on Linux during app setup.
|
||||
- Detached note windows set `decorations: false` when Linux chrome is active.
|
||||
- `LinuxTitlebar` renders the drag region, resize handles, and window controls for Linux windows.
|
||||
- `LinuxMenuButton` mirrors the app's File/Edit/View/Go/Note/Vault/Window menus, but dispatches the existing command IDs through `trigger_menu_command`.
|
||||
- The native Tauri menu bar is not mounted on Linux; macOS and other existing desktop targets keep the native menu.
|
||||
- Shared shortcuts remain defined in `appCommandCatalog.ts`, including `Cmd+Shift+L` on macOS and `Ctrl+Shift+L` on Linux through the same command manifest.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **React-rendered Linux chrome with shared command IDs** (chosen): keeps Linux visually aligned with Tolaria's existing shell and preserves one command-routing model across keyboard shortcuts, menu clicks, and QA helpers. Cons: Tolaria now owns Linux window chrome behavior directly.
|
||||
- **Keep native GTK decorations and menu bar on Linux**: cheaper to ship, but it breaks visual consistency and produces overlapping titlebar/menu surfaces that do not match the rest of the app.
|
||||
- **Introduce Linux-only command wiring for the custom menu**: would allow a Linux-specific implementation, but it would fork the shortcut/menu architecture and weaken deterministic QA.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Linux main windows and detached note windows now present one consistent titlebar surface controlled by Tolaria.
|
||||
- Menu commands, command palette actions, and deterministic QA still share the same command IDs, which limits platform-specific drift.
|
||||
- Linux packaging and CI must install WebKit2GTK 4.1 dependencies and produce Linux bundles explicitly.
|
||||
- Tolaria now owns Linux resize handles, maximize/minimize/close behavior, and titlebar drag-region behavior in the renderer, so regressions in those surfaces require direct tests.
|
||||
@@ -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.
|
||||
40
docs/adr/0082-markdown-durable-math-notes.md
Normal file
40
docs/adr/0082-markdown-durable-math-notes.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0082"
|
||||
title: "Markdown-durable math in notes"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria notes are durable Markdown files, while the main editor uses BlockNote and raw mode uses CodeMirror. Users coming from technical note-taking tools expect inline math such as `$E=mc^2$` and display math such as `$$ ... $$` to render inside notes without turning the note into an app-only document format.
|
||||
|
||||
BlockNote does not currently ship a first-party math block in the local editor package. Tiptap now offers an official Mathematics extension that renders KaTeX nodes, but Tolaria's save path still depends on BlockNote's Markdown parser and `blocksToMarkdownLossy()` serializer. Adding opaque ProseMirror math nodes without an explicit Tolaria serializer would risk losing or rewriting the original Markdown source.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria will support note math through a Markdown placeholder round-trip owned by the editor pipeline.**
|
||||
|
||||
The initial implementation:
|
||||
|
||||
- Treats `$...$` as inline math and line-owned `$$...$$` / multiline `$$` blocks as display math.
|
||||
- Converts math source to temporary placeholders before BlockNote parses Markdown.
|
||||
- Replaces placeholders with Tolaria schema nodes that render via the existing `katex` dependency.
|
||||
- Serializes those schema nodes back to the original Markdown delimiters before saving or entering raw mode.
|
||||
- Uses KaTeX with `throwOnError: false` and `trust: false` so malformed or untrusted formulas remain visible rather than breaking the note.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Tolaria-owned placeholder round-trip with KaTeX rendering** (chosen): matches the existing wikilink architecture, preserves plain-text source, and avoids depending on BlockNote support for non-default ProseMirror math nodes.
|
||||
- **Tiptap Mathematics extension directly in BlockNote**: attractive because it is official Tiptap and KaTeX-backed, but it does not by itself solve Tolaria's BlockNote Markdown serializer contract.
|
||||
- **Raw-mode-only math support**: preserves source but fails the rich editor reading experience users expect.
|
||||
- **Store formulas as custom JSON/frontmatter metadata**: richer structured editing later, but violates the Markdown-first durability requirement.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `src/utils/mathMarkdown.ts` is the canonical parser/serializer bridge for note math.
|
||||
- The rich editor renders math as schema nodes; raw mode remains the most direct way to edit exact math source.
|
||||
- CodeMirror raw editing keeps the literal Markdown delimiters, so imported Obsidian-style notes remain understandable outside Tolaria.
|
||||
- Future equation editing helpers can be added on top of the same Markdown source contract instead of changing the storage model.
|
||||
- Re-evaluate direct Tiptap Mathematics integration only if it can be proven to preserve Tolaria's Markdown save path without custom lossy behavior.
|
||||
38
docs/adr/0083-dual-architecture-macos-release-artifacts.md
Normal file
38
docs/adr/0083-dual-architecture-macos-release-artifacts.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0083"
|
||||
title: "Dual-architecture macOS release artifacts"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
supersedes: "0080"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0080 made Tolaria's desktop release pipeline cross-platform, but the macOS leg still shipped only Apple Silicon artifacts. That left Intel Mac users without a compatible build in both the alpha feed and stable releases, even though Tauri and Rust can produce `x86_64-apple-darwin` bundles from the same release workflow.
|
||||
|
||||
The updater manifest also needs to distinguish macOS CPU architectures. A generic `darwin` or macOS-only entry would make it too easy for an Intel installation to see an Apple Silicon updater bundle, and browser user agents cannot reliably tell Apple Silicon Macs apart from Intel Macs.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria publishes macOS release artifacts for both Apple Silicon (`darwin-aarch64`) and Intel (`darwin-x86_64`) in every alpha and stable release.**
|
||||
|
||||
- Alpha and stable workflows build the macOS matrix for `aarch64-apple-darwin` and `x86_64-apple-darwin`.
|
||||
- Alpha manifests include signed updater tarballs for `darwin-aarch64` and `darwin-x86_64`.
|
||||
- Stable manifests include both macOS updater tarballs and both manual DMG downloads, alongside the existing Windows x64 and Linux x86_64 entries.
|
||||
- Release jobs normalize macOS artifact filenames with the architecture suffix before publishing so GitHub release assets stay unambiguous.
|
||||
- The stable download page exposes separate Apple Silicon and Intel Mac links. When both Mac links exist, a generic macOS browser is not auto-redirected because user-agent architecture detection is unreliable.
|
||||
- The cross-platform filename portability decisions from ADR-0080 remain in force.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Publish separate Apple Silicon and Intel Mac artifacts** (chosen): gives each updater client an architecture-specific manifest key and gives users explicit manual download links. Cons: doubles the macOS release matrix and signing/notarization surface.
|
||||
- **Publish a universal macOS binary**: gives users one download, but requires lipo/re-sign/notarize coordination and reintroduces the artifact-combining complexity the release pipeline intentionally avoided.
|
||||
- **Keep Apple Silicon-only macOS releases**: keeps CI cheaper, but leaves Intel Mac users unsupported and makes the release artifacts inconsistent with Tolaria's desktop support goals.
|
||||
|
||||
## Consequences
|
||||
|
||||
- macOS release jobs now run one matrix entry per CPU architecture.
|
||||
- Release manifest consumers must treat `darwin-aarch64` and `darwin-x86_64` as distinct platform keys.
|
||||
- Stable manual downloads show two Mac choices instead of pretending browser detection can select the right CPU architecture.
|
||||
- Future macOS release changes must validate both updater and manual-download artifacts for both architectures.
|
||||
35
docs/adr/0084-app-localization-foundation.md
Normal file
35
docs/adr/0084-app-localization-foundation.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0084"
|
||||
title: "App-owned localization foundation"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria was effectively English-only. Users requested a general i18n foundation and Chinese-language support. We need a path that lets the UI adopt additional locales without pushing UI-language preferences into vault files or making every partially translated string a runtime failure.
|
||||
|
||||
## Decision
|
||||
|
||||
Tolaria owns a dependency-free frontend localization layer in `src/lib/i18n.ts`.
|
||||
|
||||
- English is the canonical fallback locale.
|
||||
- Simplified Chinese (`zh-Hans`) is the first additional locale.
|
||||
- `ui_language` is an installation-local app setting in `~/.config/com.tolaria.app/settings.json`; `null` means "follow system language when supported, otherwise English".
|
||||
- Missing translation keys fall back to English.
|
||||
- App-level chrome receives locale through props from `App.tsx`, following the existing props-down/callbacks-up architecture instead of introducing global React context.
|
||||
- Language switching is exposed in Settings and through command-palette actions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Add an i18n dependency now**: useful long term, but unnecessary for the first locale and would add framework surface before we know Tolaria's locale workflow.
|
||||
- **Store language in the vault**: rejected because UI language is an installation preference, not content structure.
|
||||
- **Translate ad hoc strings inline**: rejected because it would make fallback behavior inconsistent and future locales expensive.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New UI strings should be added to `src/lib/i18n.ts` first and rendered through `translate()` / `createTranslator()` where the surface already receives locale.
|
||||
- Partially translated locales remain usable because English is the fallback for missing keys.
|
||||
- Locale choice changes UI chrome immediately after settings save or command-palette language commands without reopening the vault.
|
||||
- Larger feature surfaces can migrate to the shared localization module incrementally.
|
||||
39
docs/adr/0085-non-git-vault-support.md
Normal file
39
docs/adr/0085-non-git-vault-support.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0085"
|
||||
title: "Non-git vaults open with explicit later Git initialization"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
supersedes: "0034"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0034 made Git a hard prerequisite for opening a vault because Git-backed cache, history, change, and sync flows failed invisibly when users opened plain Markdown folders. That protected Git features, but it blocked the common adoption path of opening an existing folder from Obsidian, iCloud, Dropbox, or a manually maintained notes directory.
|
||||
|
||||
Tolaria now needs the opposite default: browsing and editing Markdown should work immediately, while Git remains an explicit capability users can enable when they want history, sync, commits, or collaboration.
|
||||
|
||||
## Decision
|
||||
|
||||
**Open existing Markdown folders even when they are not Git repositories.** A non-git vault is a supported state, not an error state. On open, Tolaria asks whether to initialize Git; if the user dismisses the prompt, the app keeps working and the status bar permanently shows a `Git disabled` warning. Clicking that warning, or running `Initialize Git for Current Vault` from the command palette, reopens the setup action.
|
||||
|
||||
While a vault is not Git-backed:
|
||||
|
||||
- Git history, change, commit, sync, conflict, and remote actions are hidden or disabled.
|
||||
- Background auto-sync and AutoGit checkpoints do not run.
|
||||
- Markdown scanning, note browsing, note editing, search, and non-Git vault features continue normally.
|
||||
|
||||
`init_git_repo` remains the single backend command for enabling Git later. It creates the repository, writes Tolaria's default `.gitignore`, stages the vault, and creates the unsigned setup commit.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A (chosen): Supported non-git mode with explicit later initialization.** Best adoption path; keeps Git capabilities visible without blocking the basic notes workflow.
|
||||
- **Option B: Keep the ADR-0034 blocking modal.** Prevents Git feature ambiguity, but rejects valid plain-folder workflows.
|
||||
- **Option C: Auto-initialize Git when opening a plain folder.** Low friction, but surprising for users who do not want Tolaria to mutate folder metadata.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Existing Git-backed vaults keep the same history, commit, sync, and remote behavior.
|
||||
- UI surfaces must treat Git capability as stateful per vault, not as an app-wide invariant.
|
||||
- Tests need to cover both Git-backed and non-git vaults in browser mocks and native QA.
|
||||
- Future Git-dependent features must check the current vault's Git state before registering commands or running background work.
|
||||
32
docs/adr/0086-in-app-image-file-preview.md
Normal file
32
docs/adr/0086-in-app-image-file-preview.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0086"
|
||||
title: "In-app image previews for binary vault files"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
supersedes: "0041"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0041 made the vault scanner index all visible files and introduced `fileKind` as `"markdown"`, `"text"`, or `"binary"`. Binary files were deliberately shown as inert entries until Tolaria had a dedicated preview model.
|
||||
|
||||
That made image references visible in folder views, but opening an image still felt outside the normal Tolaria workflow. Users need to inspect screenshots, diagrams, and other image assets while keeping their place in the vault.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria previews supported image files in the editor pane while keeping them as ordinary binary `VaultEntry` files.**
|
||||
|
||||
- The scanner keeps the existing `fileKind: "binary"` representation. Image previewability is inferred in the renderer from the file extension, not by introducing a proprietary image document type.
|
||||
- Opening a binary entry creates the same single active-tab state used for notes, but with empty content and no `get_note_content` text read.
|
||||
- `FilePreview` renders supported image extensions through Tauri's asset protocol (`convertFileSrc`) so the original file remains on disk.
|
||||
- Broken images and unsupported binary files render an explicit fallback state with an intentional "Open in default app" action instead of launching another app automatically.
|
||||
- Note-list rows use an image indicator for previewable image binaries. Unsupported binary rows remain muted and non-clickable in the normal list surface.
|
||||
- The preview surface is keyboard focusable and `Escape` returns focus to the note list, matching the app's keyboard-first navigation model.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The existing `VaultEntry` model and cache version do not need to change.
|
||||
- Supported image files can participate in normal selection/navigation context without being converted into Markdown notes.
|
||||
- Unsupported/broken binary files have a clear in-app state when reached through navigation paths that can select them.
|
||||
- Any future PDF, audio, or video preview should extend the same file-preview renderer rather than adding new vault-owned document representations.
|
||||
@@ -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 |
|
||||
@@ -89,7 +89,7 @@ proposed → active → superseded
|
||||
| [0031](0031-full-app-for-note-windows.md) | Full App instance for secondary note windows | active |
|
||||
| [0032](0032-status-bar-for-git-actions.md) | Git actions (Changes, Pulse, Commit) in status bar, not sidebar | active |
|
||||
| [0033](0033-subfolder-scanning-and-folder-tree.md) | Subfolder scanning and folder tree navigation | active |
|
||||
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | active |
|
||||
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | superseded → [0085](0085-non-git-vault-support.md) |
|
||||
| [0035](0035-path-suffix-wikilink-resolution.md) | Path-suffix wikilink resolution for subfolder vaults | active |
|
||||
| [0036](0036-external-rename-detection-via-git-diff.md) | External rename detection via git diff on focus regain | active |
|
||||
| [0037](0037-codemirror-language-markdown-highlighting.md) | Language-based markdown syntax highlighting in raw editor | active |
|
||||
@@ -134,3 +134,9 @@ 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 | superseded → [0083](0083-dual-architecture-macos-release-artifacts.md) |
|
||||
| [0081](0081-internal-light-dark-theme-runtime.md) | Internal light and dark theme runtime | active |
|
||||
| [0082](0082-markdown-durable-math-notes.md) | Markdown-durable math in notes | active |
|
||||
| [0083](0083-dual-architecture-macos-release-artifacts.md) | Dual-architecture macOS release artifacts | active |
|
||||
| [0085](0085-non-git-vault-support.md) | Non-git vaults open with explicit later Git initialization | 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>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "node scripts/run-vitest-coverage.mjs",
|
||||
|
||||
@@ -30,7 +30,7 @@ export default defineConfig({
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
|
||||
webServer: {
|
||||
command: `pnpm dev --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
command: `node scripts/playwright-smoke-server.mjs ${port}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer,
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -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)
|
||||
|
||||
32
scripts/playwright-smoke-server.mjs
Normal file
32
scripts/playwright-smoke-server.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const port = process.argv[2] ?? process.env.PORT ?? '41741'
|
||||
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['dev', '--host', '127.0.0.1', '--port', port, '--strictPort'],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
},
|
||||
)
|
||||
|
||||
function forwardSignal(signal) {
|
||||
if (child.killed) return
|
||||
child.kill(signal)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => forwardSignal('SIGINT'))
|
||||
process.on('SIGTERM', () => forwardSignal('SIGTERM'))
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -122,7 +122,11 @@ fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
}
|
||||
|
||||
fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_codex_binary_on_path()? {
|
||||
if let Some(binary) = find_codex_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_codex_binary_in_user_shell() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
@@ -133,27 +137,76 @@ fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
|
||||
}
|
||||
|
||||
fn find_codex_binary_on_path() -> Result<Option<PathBuf>, String> {
|
||||
let output = Command::new("which")
|
||||
fn find_codex_binary_on_path() -> Option<PathBuf> {
|
||||
Command::new("which")
|
||||
.arg("codex")
|
||||
.output()
|
||||
.map_err(|error| format!("Failed to run `which codex`: {error}"))?;
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(Some(PathBuf::from(path)));
|
||||
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
.filter(|shell| shell.exists())
|
||||
.find_map(|shell| command_path_from_shell(&shell, "codex"))
|
||||
}
|
||||
|
||||
fn user_shell_candidates() -> Vec<PathBuf> {
|
||||
let mut shells = Vec::new();
|
||||
if let Some(shell) = std::env::var_os("SHELL") {
|
||||
if !shell.is_empty() {
|
||||
shells.push(PathBuf::from(shell));
|
||||
}
|
||||
}
|
||||
shells.push(PathBuf::from("/bin/zsh"));
|
||||
shells.push(PathBuf::from("/bin/bash"));
|
||||
shells
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
|
||||
Command::new(shell)
|
||||
.arg("-lc")
|
||||
.arg(format!("command -v {command}"))
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
|
||||
if output.status.success() {
|
||||
first_existing_path(&String::from_utf8_lossy(&output.stdout))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
|
||||
stdout.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidate = PathBuf::from(trimmed);
|
||||
candidate.exists().then_some(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
fn codex_binary_candidates() -> Vec<PathBuf> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
dirs::home_dir()
|
||||
.map(|home| codex_binary_candidates_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".npm/bin/codex"),
|
||||
home.join(".bun/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
@@ -419,6 +472,65 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_supported_macos_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
let candidates = codex_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".bun/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_existing_path_skips_empty_and_missing_lines() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = dir.path().join("missing-codex");
|
||||
let codex = dir.path().join("codex");
|
||||
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
|
||||
|
||||
let stdout = format!("\n{}\n{}\n", missing.display(), codex.display());
|
||||
|
||||
assert_eq!(first_existing_path(&stdout), Some(codex));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn command_path_from_shell_finds_codex_from_login_shell() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let codex = dir.path().join("codex");
|
||||
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let shell = dir.path().join("shell");
|
||||
std::fs::write(
|
||||
&shell,
|
||||
format!(
|
||||
"#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n",
|
||||
codex.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_command_events_maps_to_bash_events() {
|
||||
let mut events = Vec::new();
|
||||
|
||||
@@ -63,7 +63,7 @@ pub struct AgentStreamRequest {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_claude_binary_on_path()? {
|
||||
if let Some(binary) = find_claude_binary_on_path() {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
@@ -78,13 +78,20 @@ pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
|
||||
Err("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code".into())
|
||||
}
|
||||
|
||||
fn find_claude_binary_on_path() -> Result<Option<PathBuf>, String> {
|
||||
let output = Command::new("which")
|
||||
fn find_claude_binary_on_path() -> Option<PathBuf> {
|
||||
Command::new(claude_path_lookup_command())
|
||||
.arg("claude")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run `which claude`: {e}"))?;
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
Ok(path_from_successful_output(&output))
|
||||
fn claude_path_lookup_command() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
}
|
||||
}
|
||||
|
||||
fn find_claude_binary_in_user_shell() -> Option<PathBuf> {
|
||||
@@ -143,11 +150,24 @@ fn claude_binary_candidates() -> Vec<PathBuf> {
|
||||
fn claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".local/bin/claude"),
|
||||
home.join(".local/bin/claude.exe"),
|
||||
home.join(".claude/local/claude"),
|
||||
home.join(".claude/local/claude.exe"),
|
||||
home.join(".local/share/mise/shims/claude"),
|
||||
home.join(".local/share/mise/shims/claude.exe"),
|
||||
home.join(".asdf/shims/claude"),
|
||||
home.join(".asdf/shims/claude.exe"),
|
||||
home.join(".npm-global/bin/claude"),
|
||||
home.join(".npm-global/bin/claude.cmd"),
|
||||
home.join(".npm-global/bin/claude.exe"),
|
||||
home.join(".npm/bin/claude"),
|
||||
home.join(".npm/bin/claude.cmd"),
|
||||
home.join(".npm/bin/claude.exe"),
|
||||
home.join("AppData/Roaming/npm/claude.cmd"),
|
||||
home.join("AppData/Roaming/npm/claude.exe"),
|
||||
home.join("AppData/Local/pnpm/claude.cmd"),
|
||||
home.join("AppData/Local/pnpm/claude.exe"),
|
||||
home.join("scoop/shims/claude.exe"),
|
||||
PathBuf::from("/opt/homebrew/bin/claude"),
|
||||
PathBuf::from("/usr/local/bin/claude"),
|
||||
]
|
||||
@@ -1099,6 +1119,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_binary_candidates_include_windows_exe_installs() {
|
||||
let home = PathBuf::from(r"C:\Users\alex");
|
||||
let candidates = claude_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/claude.exe"),
|
||||
home.join(".claude/local/claude.exe"),
|
||||
home.join("AppData/Roaming/npm/claude.cmd"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_path_lookup_command_matches_current_platform() {
|
||||
let expected = if cfg!(windows) { "where" } else { "which" };
|
||||
|
||||
assert_eq!(claude_path_lookup_command(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_existing_binary_finds_windows_exe_candidate() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let claude = dir.path().join(".local/bin/claude.exe");
|
||||
std::fs::create_dir_all(claude.parent().unwrap()).unwrap();
|
||||
std::fs::write(&claude, "").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
find_existing_binary(claude_binary_candidates_for_home(dir.path())),
|
||||
Some(claude)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_claude_binary_returns_result() {
|
||||
let result = find_claude_binary();
|
||||
|
||||
@@ -149,3 +149,28 @@ pub async fn stream_ai_agent(
|
||||
) -> Result<String, String> {
|
||||
Err("CLI AI agents are not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vault::AiGuidanceFileState;
|
||||
|
||||
#[test]
|
||||
fn guidance_commands_report_and_restore_vault_guidance_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_string_lossy().to_string();
|
||||
|
||||
let initial = get_vault_ai_guidance_status(vault_path.clone()).unwrap();
|
||||
assert_eq!(initial.agents_state, AiGuidanceFileState::Missing);
|
||||
assert_eq!(initial.claude_state, AiGuidanceFileState::Missing);
|
||||
assert!(initial.can_restore);
|
||||
|
||||
let restored = restore_vault_ai_guidance(vault_path.clone()).unwrap();
|
||||
assert_eq!(restored.agents_state, AiGuidanceFileState::Managed);
|
||||
assert_eq!(restored.claude_state, AiGuidanceFileState::Managed);
|
||||
assert!(!restored.can_restore);
|
||||
|
||||
assert!(dir.path().join("AGENTS.md").exists());
|
||||
assert!(dir.path().join("CLAUDE.md").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,3 +13,32 @@ pub async fn batch_delete_notes_async(
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_delete_notes_async_validates_and_deletes_inside_vault() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let first = dir.path().join("first.md");
|
||||
let second = dir.path().join("nested/second.md");
|
||||
std::fs::create_dir_all(second.parent().unwrap()).unwrap();
|
||||
std::fs::write(&first, "# First\n").unwrap();
|
||||
std::fs::write(&second, "# Second\n").unwrap();
|
||||
|
||||
let deleted = batch_delete_notes_async(
|
||||
vec![
|
||||
first.to_string_lossy().to_string(),
|
||||
"nested/second.md".to_string(),
|
||||
],
|
||||
Some(dir.path().to_string_lossy().to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(deleted.len(), 2);
|
||||
assert!(!first.exists());
|
||||
assert!(!second.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,30 @@ pub fn delete_vault_folder(vault_path: String, folder_path: String) -> Result<St
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::delete_folder(std::path::Path::new(vault_path.as_ref()), &folder_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folder_commands_route_through_vault_path_boundary() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_string_lossy().to_string();
|
||||
let folder = dir.path().join("Inbox");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
std::fs::write(folder.join("note.md"), "# Note\n").unwrap();
|
||||
|
||||
let renamed = rename_vault_folder(
|
||||
vault_path.clone(),
|
||||
"Inbox".to_string(),
|
||||
"Organized".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(renamed.new_path.ends_with("Organized"));
|
||||
assert!(dir.path().join("Organized/note.md").exists());
|
||||
|
||||
let deleted = delete_vault_folder(vault_path, "Organized".to_string()).unwrap();
|
||||
assert_eq!(deleted, "Organized");
|
||||
assert!(!dir.path().join("Organized").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,22 @@ use crate::git::{
|
||||
|
||||
use super::expand_tilde;
|
||||
|
||||
type VaultPathArg = String;
|
||||
type NotePathArg = String;
|
||||
type CommitHashArg = String;
|
||||
type CommitMessageArg = String;
|
||||
type ConflictStrategyArg = String;
|
||||
type RemoteUrlArg = String;
|
||||
type LocalPathArg = String;
|
||||
|
||||
// ── Git commands (desktop) ──────────────────────────────────────────────────
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
|
||||
pub fn get_file_history(
|
||||
vault_path: VaultPathArg,
|
||||
path: NotePathArg,
|
||||
) -> Result<Vec<GitCommit>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
crate::git::get_file_history(&vault_path, &path)
|
||||
@@ -17,14 +28,14 @@ pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommi
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
|
||||
pub fn get_modified_files(vault_path: VaultPathArg) -> Result<Vec<ModifiedFile>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::get_modified_files(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
|
||||
pub fn get_file_diff(vault_path: VaultPathArg, path: NotePathArg) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
crate::git::get_file_diff(&vault_path, &path)
|
||||
@@ -33,9 +44,9 @@ pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String>
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff_at_commit(
|
||||
vault_path: String,
|
||||
path: String,
|
||||
commit_hash: String,
|
||||
vault_path: VaultPathArg,
|
||||
path: NotePathArg,
|
||||
commit_hash: CommitHashArg,
|
||||
) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
@@ -45,7 +56,7 @@ pub fn get_file_diff_at_commit(
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_vault_pulse(
|
||||
vault_path: String,
|
||||
vault_path: VaultPathArg,
|
||||
limit: Option<usize>,
|
||||
skip: Option<usize>,
|
||||
) -> Result<Vec<PulseCommit>, String> {
|
||||
@@ -57,21 +68,21 @@ pub fn get_vault_pulse(
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn git_commit(vault_path: String, message: String) -> Result<String, String> {
|
||||
pub fn git_commit(vault_path: VaultPathArg, message: CommitMessageArg) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::git_commit(&vault_path, &message)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
|
||||
pub fn get_last_commit_info(vault_path: VaultPathArg) -> Result<Option<LastCommitInfo>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::get_last_commit_info(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
pub async fn git_pull(vault_path: VaultPathArg) -> Result<GitPullResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || crate::git::git_pull(&vault_path))
|
||||
.await
|
||||
@@ -80,14 +91,14 @@ pub async fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
|
||||
pub fn get_conflict_files(vault_path: VaultPathArg) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::get_conflict_files(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_mode(vault_path: String) -> String {
|
||||
pub fn get_conflict_mode(vault_path: VaultPathArg) -> String {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::get_conflict_mode(&vault_path)
|
||||
}
|
||||
@@ -95,9 +106,9 @@ pub fn get_conflict_mode(vault_path: String) -> String {
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn git_resolve_conflict(
|
||||
vault_path: String,
|
||||
file: String,
|
||||
strategy: String,
|
||||
vault_path: VaultPathArg,
|
||||
file: NotePathArg,
|
||||
strategy: ConflictStrategyArg,
|
||||
) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::git_resolve_conflict(&vault_path, &file, &strategy)
|
||||
@@ -105,14 +116,14 @@ pub fn git_resolve_conflict(
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
|
||||
pub fn git_commit_conflict_resolution(vault_path: VaultPathArg) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::git_commit_conflict_resolution(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
pub async fn git_push(vault_path: VaultPathArg) -> Result<GitPushResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || crate::git::git_push(&vault_path))
|
||||
.await
|
||||
@@ -121,7 +132,7 @@ pub async fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, String> {
|
||||
pub async fn git_remote_status(vault_path: VaultPathArg) -> Result<GitRemoteStatus, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || crate::git::git_remote_status(&vault_path))
|
||||
.await
|
||||
@@ -130,14 +141,17 @@ pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, St
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn git_discard_file(vault_path: String, relative_path: String) -> Result<(), String> {
|
||||
pub fn git_discard_file(
|
||||
vault_path: VaultPathArg,
|
||||
relative_path: NotePathArg,
|
||||
) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::discard_file_changes(&vault_path, &relative_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn is_git_repo(vault_path: String) -> bool {
|
||||
pub fn is_git_repo(vault_path: VaultPathArg) -> bool {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
std::path::Path::new(vault_path.as_ref())
|
||||
.join(".git")
|
||||
@@ -146,14 +160,14 @@ pub fn is_git_repo(vault_path: String) -> bool {
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn init_git_repo(vault_path: String) -> Result<(), String> {
|
||||
pub fn init_git_repo(vault_path: VaultPathArg) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::init_repo(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(url: String, local_path: String) -> Result<String, String> {
|
||||
pub fn clone_repo(url: RemoteUrlArg, local_path: LocalPathArg) -> Result<String, String> {
|
||||
let local_path = expand_tilde(&local_path);
|
||||
crate::git::clone_repo(&url, &local_path)
|
||||
}
|
||||
@@ -162,28 +176,31 @@ pub fn clone_repo(url: String, local_path: String) -> Result<String, String> {
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_history(_vault_path: String, _path: String) -> Result<Vec<GitCommit>, String> {
|
||||
pub fn get_file_history(
|
||||
_vault_path: VaultPathArg,
|
||||
_path: NotePathArg,
|
||||
) -> Result<Vec<GitCommit>, String> {
|
||||
Err("Git history is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_modified_files(_vault_path: String) -> Result<Vec<ModifiedFile>, String> {
|
||||
pub fn get_modified_files(_vault_path: VaultPathArg) -> Result<Vec<ModifiedFile>, String> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff(_vault_path: String, _path: String) -> Result<String, String> {
|
||||
pub fn get_file_diff(_vault_path: VaultPathArg, _path: NotePathArg) -> Result<String, String> {
|
||||
Err("Git diff is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff_at_commit(
|
||||
_vault_path: String,
|
||||
_path: String,
|
||||
_commit_hash: String,
|
||||
_vault_path: VaultPathArg,
|
||||
_path: NotePathArg,
|
||||
_commit_hash: CommitHashArg,
|
||||
) -> Result<String, String> {
|
||||
Err("Git diff is not available on mobile".into())
|
||||
}
|
||||
@@ -191,7 +208,7 @@ pub fn get_file_diff_at_commit(
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_vault_pulse(
|
||||
_vault_path: String,
|
||||
_vault_path: VaultPathArg,
|
||||
_limit: Option<usize>,
|
||||
_skip: Option<usize>,
|
||||
) -> Result<Vec<PulseCommit>, String> {
|
||||
@@ -200,59 +217,59 @@ pub fn get_vault_pulse(
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn git_commit(_vault_path: String, _message: String) -> Result<String, String> {
|
||||
pub fn git_commit(_vault_path: VaultPathArg, _message: CommitMessageArg) -> Result<String, String> {
|
||||
Err("Git commit is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_last_commit_info(_vault_path: String) -> Result<Option<LastCommitInfo>, String> {
|
||||
pub fn get_last_commit_info(_vault_path: VaultPathArg) -> Result<Option<LastCommitInfo>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn git_pull(_vault_path: String) -> Result<GitPullResult, String> {
|
||||
pub async fn git_pull(_vault_path: VaultPathArg) -> Result<GitPullResult, String> {
|
||||
Err("Git pull is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_files(_vault_path: String) -> Result<Vec<String>, String> {
|
||||
pub fn get_conflict_files(_vault_path: VaultPathArg) -> Result<Vec<String>, String> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_mode(_vault_path: String) -> String {
|
||||
pub fn get_conflict_mode(_vault_path: VaultPathArg) -> String {
|
||||
"none".to_string()
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn git_resolve_conflict(
|
||||
_vault_path: String,
|
||||
_file: String,
|
||||
_strategy: String,
|
||||
_vault_path: VaultPathArg,
|
||||
_file: NotePathArg,
|
||||
_strategy: ConflictStrategyArg,
|
||||
) -> Result<(), String> {
|
||||
Err("Git conflict resolution is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn git_commit_conflict_resolution(_vault_path: String) -> Result<String, String> {
|
||||
pub fn git_commit_conflict_resolution(_vault_path: VaultPathArg) -> Result<String, String> {
|
||||
Err("Git conflict resolution is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn git_push(_vault_path: String) -> Result<GitPushResult, String> {
|
||||
pub async fn git_push(_vault_path: VaultPathArg) -> Result<GitPushResult, String> {
|
||||
Err("Git push is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, String> {
|
||||
pub async fn git_remote_status(_vault_path: VaultPathArg) -> Result<GitRemoteStatus, String> {
|
||||
Ok(GitRemoteStatus {
|
||||
branch: String::new(),
|
||||
has_remote: false,
|
||||
@@ -263,24 +280,112 @@ pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, S
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn git_discard_file(_vault_path: String, _relative_path: String) -> Result<(), String> {
|
||||
pub fn git_discard_file(
|
||||
_vault_path: VaultPathArg,
|
||||
_relative_path: NotePathArg,
|
||||
) -> Result<(), String> {
|
||||
Err("Git discard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn is_git_repo(_vault_path: String) -> bool {
|
||||
pub fn is_git_repo(_vault_path: VaultPathArg) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn init_git_repo(_vault_path: String) -> Result<(), String> {
|
||||
pub fn init_git_repo(_vault_path: VaultPathArg) -> Result<(), String> {
|
||||
Err("Git init is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(_url: String, _local_path: String) -> Result<String, String> {
|
||||
pub fn clone_repo(_url: RemoteUrlArg, _local_path: LocalPathArg) -> Result<String, String> {
|
||||
Err("Git clone is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn vault_path(dir: &TempDir) -> String {
|
||||
dir.path().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn note_path(dir: &TempDir, name: &str) -> String {
|
||||
dir.path().join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn create_initialized_vault() -> (TempDir, String) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(dir.path().join("note.md"), "# Note\n").unwrap();
|
||||
let vault = vault_path(&dir);
|
||||
init_git_repo(vault.clone()).unwrap();
|
||||
(dir, vault)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_git_commands_route_to_git_backend() {
|
||||
let (dir, vault) = create_initialized_vault();
|
||||
let note = note_path(&dir, "note.md");
|
||||
|
||||
assert!(is_git_repo(vault.clone()));
|
||||
|
||||
fs::write(dir.path().join("note.md"), "# Updated\n").unwrap();
|
||||
let modified = get_modified_files(vault.clone()).unwrap();
|
||||
assert!(modified.iter().any(|file| file.relative_path == "note.md"));
|
||||
|
||||
let diff = get_file_diff(vault.clone(), note.clone()).unwrap();
|
||||
assert!(diff.contains("# Updated"));
|
||||
|
||||
git_commit(vault.clone(), "Update note".to_string()).unwrap();
|
||||
let history = get_file_history(vault.clone(), note.clone()).unwrap();
|
||||
assert!(history.iter().any(|commit| commit.message == "Update note"));
|
||||
|
||||
let last_commit = get_last_commit_info(vault.clone()).unwrap().unwrap();
|
||||
assert!(!last_commit.short_hash.is_empty());
|
||||
|
||||
let commit_diff = get_file_diff_at_commit(
|
||||
vault.clone(),
|
||||
note.clone(),
|
||||
history.first().unwrap().hash.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(commit_diff.contains("# Updated"));
|
||||
|
||||
let pulse = get_vault_pulse(vault.clone(), Some(5), Some(0)).unwrap();
|
||||
assert!(!pulse.is_empty());
|
||||
|
||||
fs::write(dir.path().join("note.md"), "# Discard me\n").unwrap();
|
||||
git_discard_file(vault.clone(), "note.md".to_string()).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.path().join("note.md")).unwrap(),
|
||||
"# Updated\n"
|
||||
);
|
||||
|
||||
assert!(get_conflict_files(vault.clone()).unwrap().is_empty());
|
||||
assert_eq!(get_conflict_mode(vault.clone()), "none");
|
||||
assert!(
|
||||
git_resolve_conflict(vault.clone(), "note.md".to_string(), "invalid".to_string(),)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn desktop_remote_commands_report_no_remote() {
|
||||
let (_dir, vault) = create_initialized_vault();
|
||||
|
||||
let pull = git_pull(vault.clone()).await.unwrap();
|
||||
assert_eq!(pull.status, "no_remote");
|
||||
|
||||
let push = git_push(vault.clone()).await.unwrap();
|
||||
assert_eq!(push.status, "error");
|
||||
|
||||
let status = git_remote_status(vault.clone()).await.unwrap();
|
||||
assert!(!status.has_remote);
|
||||
assert_eq!((status.ahead, status.behind), (0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
18
src-tauri/src/commands/git_clone.rs
Normal file
18
src-tauri/src/commands/git_clone.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use super::expand_tilde;
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn clone_git_repo(url: String, local_path: String) -> Result<String, String> {
|
||||
let url = url.trim().to_string();
|
||||
let local_path = expand_tilde(&local_path).into_owned();
|
||||
|
||||
tokio::task::spawn_blocking(move || super::git::clone_repo(url, local_path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn clone_git_repo(_url: String, _local_path: String) -> Result<String, String> {
|
||||
Err("Git clone is not available on mobile".into())
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod ai;
|
||||
mod delete;
|
||||
mod folders;
|
||||
mod git;
|
||||
pub mod git_clone;
|
||||
mod git_connect;
|
||||
mod system;
|
||||
mod vault;
|
||||
|
||||
@@ -162,6 +162,7 @@ pub struct MenuStateUpdate {
|
||||
has_restorable_deleted_note: Option<bool>,
|
||||
has_no_remote: Option<bool>,
|
||||
note_list_search_enabled: Option<bool>,
|
||||
editor_find_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
@@ -186,6 +187,9 @@ pub fn update_menu_state(
|
||||
if let Some(v) = state.note_list_search_enabled {
|
||||
menu::set_note_list_search_items_enabled(&app_handle, v);
|
||||
}
|
||||
if let Some(v) = state.editor_find_enabled {
|
||||
menu::set_editor_find_items_enabled(&app_handle, v);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,41 @@ fn with_requested_root_path<T>(
|
||||
with_requested_root(raw_vault_path.as_ref(), action)
|
||||
}
|
||||
|
||||
fn sync_image_asset_scope(
|
||||
app_handle: &tauri::AppHandle,
|
||||
requested_root: &str,
|
||||
) -> Result<(), String> {
|
||||
#[cfg(desktop)]
|
||||
crate::sync_vault_asset_scope(app_handle, Path::new(requested_root))?;
|
||||
#[cfg(not(desktop))]
|
||||
let _ = requested_root;
|
||||
#[cfg(not(desktop))]
|
||||
let _ = app_handle;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn with_image_asset_scope(
|
||||
app_handle: &tauri::AppHandle,
|
||||
vault_path: &Path,
|
||||
action: impl FnOnce(&str) -> Result<String, String>,
|
||||
) -> Result<String, String> {
|
||||
with_requested_root_path(vault_path, |requested_root| {
|
||||
let saved_path = action(requested_root)?;
|
||||
sync_image_asset_scope(app_handle, requested_root)?;
|
||||
Ok(saved_path)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sync_vault_asset_scope_for_window(
|
||||
app_handle: tauri::AppHandle,
|
||||
vault_path: PathBuf,
|
||||
) -> Result<(), String> {
|
||||
with_requested_root_path(vault_path.as_path(), |requested_root| {
|
||||
sync_image_asset_scope(&app_handle, requested_root)
|
||||
})
|
||||
}
|
||||
|
||||
fn with_writable_note_path<T>(
|
||||
path: PathBuf,
|
||||
vault_path: Option<PathBuf>,
|
||||
@@ -114,6 +150,7 @@ pub fn create_vault_folder(vault_path: PathBuf, folder_name: PathBuf) -> Result<
|
||||
with_boundary(Some(raw_vault_path.as_ref()), |boundary| {
|
||||
let folder_name = folder_name.to_string_lossy();
|
||||
let folder_path = boundary.child_path(folder_name.as_ref())?;
|
||||
validate_folder_name(folder_name.as_ref())?;
|
||||
ensure_missing_folder(&folder_path, folder_name.as_ref())?;
|
||||
std::fs::create_dir_all(&folder_path)
|
||||
.map_err(|e| format!("Failed to create folder: {}", e))?;
|
||||
@@ -146,15 +183,24 @@ pub fn sync_note_title(path: PathBuf, vault_path: Option<PathBuf>) -> Result<boo
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_image(vault_path: PathBuf, filename: String, data: String) -> Result<String, String> {
|
||||
with_requested_root_path(vault_path.as_path(), |requested_root| {
|
||||
pub fn save_image(
|
||||
app_handle: tauri::AppHandle,
|
||||
vault_path: PathBuf,
|
||||
filename: String,
|
||||
data: String,
|
||||
) -> Result<String, String> {
|
||||
with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| {
|
||||
vault::save_image(requested_root, &filename, &data)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn copy_image_to_vault(vault_path: PathBuf, source_path: PathBuf) -> Result<String, String> {
|
||||
with_requested_root_path(vault_path.as_path(), |requested_root| {
|
||||
pub fn copy_image_to_vault(
|
||||
app_handle: tauri::AppHandle,
|
||||
vault_path: PathBuf,
|
||||
source_path: PathBuf,
|
||||
) -> Result<String, String> {
|
||||
with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| {
|
||||
vault::copy_image_to_vault(requested_root, source_path.to_string_lossy().as_ref())
|
||||
})
|
||||
}
|
||||
@@ -168,3 +214,113 @@ pub fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
|
||||
pub fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
|
||||
with_expanded_vault_root(path.as_path(), vault::scan_vault_folders)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn vault_root(dir: &TempDir) -> PathBuf {
|
||||
dir.path().to_path_buf()
|
||||
}
|
||||
|
||||
fn note_path(dir: &TempDir, name: &str) -> PathBuf {
|
||||
dir.path().join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_content_commands_roundtrip_with_requested_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = vault_root(&dir);
|
||||
let note = note_path(&dir, "notes/command-note.md");
|
||||
|
||||
create_note_content(
|
||||
note.clone(),
|
||||
"# Command Note\n".to_string(),
|
||||
Some(root.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
get_note_content(note.clone(), Some(root.clone())).unwrap(),
|
||||
"# Command Note\n"
|
||||
);
|
||||
|
||||
save_note_content(
|
||||
note.clone(),
|
||||
"---\ntitle: Command Note\n---\n# Command Note\nBody\n".to_string(),
|
||||
Some(root.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!sync_note_title(note.clone(), Some(root.clone())).unwrap());
|
||||
|
||||
save_note_content(
|
||||
note.clone(),
|
||||
"# Updated Command Note\n".to_string(),
|
||||
Some(root.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(sync_note_title(note.clone(), Some(root.clone())).unwrap());
|
||||
assert!(get_note_content(note, Some(root))
|
||||
.unwrap()
|
||||
.contains("title: Command Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_content_commands_accept_windows_sensitive_valid_segments() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = vault_root(&dir);
|
||||
let note = root
|
||||
.join("@raflymln")
|
||||
.join("notes with spaces")
|
||||
.join("résumé note.md");
|
||||
|
||||
save_note_content(
|
||||
note.clone(),
|
||||
"# Windows-Sensitive Path\n\nBody\n".to_string(),
|
||||
Some(root.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
get_note_content(note, Some(root)).unwrap(),
|
||||
"# Windows-Sensitive Path\n\nBody\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_and_listing_commands_use_expanded_vault_root() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = vault_root(&dir);
|
||||
fs::write(dir.path().join("root.md"), "# Root\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
create_vault_folder(root.clone(), PathBuf::from("Projects")).unwrap(),
|
||||
"Projects"
|
||||
);
|
||||
fs::write(dir.path().join("Projects/project.md"), "# Project\n").unwrap();
|
||||
|
||||
let entries = list_vault(root.clone()).unwrap();
|
||||
assert!(entries.iter().any(|entry| entry.filename == "root.md"));
|
||||
assert!(entries.iter().any(|entry| entry.filename == "project.md"));
|
||||
|
||||
let folders = list_vault_folders(root).unwrap();
|
||||
assert!(folders.iter().any(|folder| folder.name == "Projects"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_reject_paths_outside_requested_vault() {
|
||||
let vault = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let outside_note = outside.path().join("outside.md");
|
||||
fs::write(&outside_note, "# Outside\n").unwrap();
|
||||
|
||||
let error = get_note_content(outside_note, Some(vault.path().to_path_buf())).unwrap_err();
|
||||
assert!(error.contains("Path must stay inside the active vault"));
|
||||
|
||||
let folder_error =
|
||||
create_vault_folder(vault.path().to_path_buf(), PathBuf::from("../escape"))
|
||||
.unwrap_err();
|
||||
assert!(folder_error.contains("Path must stay inside the active vault"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +57,11 @@ fn canonical_vault_path_string(vault_dir: &Path) -> String {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
|
||||
pub async fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
|
||||
let path = resolve_getting_started_target(target_path.as_deref())?;
|
||||
vault::create_getting_started_vault(&path)
|
||||
tokio::task::spawn_blocking(move || vault::create_getting_started_vault(&path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
fn resolve_getting_started_target(target_path: Option<&str>) -> Result<String, String> {
|
||||
|
||||
@@ -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,120 @@ 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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn vault_path(dir: &TempDir) -> String {
|
||||
dir.path().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn write_note(dir: &TempDir, relative_path: &str, content: &str) -> String {
|
||||
let path = dir.path().join(relative_path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&path, content).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_note_command_updates_title_file_and_links() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = vault_path(&dir);
|
||||
let old_path = write_note(
|
||||
&dir,
|
||||
"old-title.md",
|
||||
"---\ntitle: Old Title\n---\n# Old Title\n",
|
||||
);
|
||||
let linked_path = write_note(&dir, "linked.md", "See [[Old Title]].\n");
|
||||
|
||||
let result = rename_note(
|
||||
vault.clone(),
|
||||
old_path.clone(),
|
||||
"New Title".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.new_path.ends_with("new-title.md"));
|
||||
assert!(!Path::new(&old_path).exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
assert!(fs::read_to_string(linked_path)
|
||||
.unwrap()
|
||||
.contains("[[new-title]]"));
|
||||
assert_eq!(result.failed_updates, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filename_and_folder_commands_preserve_note_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = vault_path(&dir);
|
||||
let old_path = write_note(
|
||||
&dir,
|
||||
"draft.md",
|
||||
"---\ntitle: Draft Title\n---\n# Draft Title\n",
|
||||
);
|
||||
|
||||
let renamed =
|
||||
rename_note_filename(vault.clone(), old_path, "custom-name".to_string()).unwrap();
|
||||
assert!(renamed.new_path.ends_with("custom-name.md"));
|
||||
|
||||
fs::create_dir(dir.path().join("Projects")).unwrap();
|
||||
let moved = move_note_to_folder(
|
||||
vault.clone(),
|
||||
renamed.new_path.clone(),
|
||||
"Projects".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(moved.new_path.ends_with("Projects/custom-name.md"));
|
||||
assert!(fs::read_to_string(moved.new_path)
|
||||
.unwrap()
|
||||
.contains("Draft Title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_rename_and_detected_rename_commands_route_through_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = vault_path(&dir);
|
||||
let untitled = write_note(&dir, "untitled-note-123.md", "# Project Plan\n");
|
||||
|
||||
let auto = auto_rename_untitled(vault.clone(), untitled)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(auto.new_path.ends_with("project-plan.md"));
|
||||
|
||||
crate::git::init_repo(&vault).unwrap();
|
||||
let old_path = dir.path().join("project-plan.md");
|
||||
let new_path = dir.path().join("plans.md");
|
||||
fs::rename(&old_path, &new_path).unwrap();
|
||||
crate::hidden_command("git")
|
||||
.args(["add", "-A"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let renames = detect_renames(vault.clone()).unwrap();
|
||||
assert_eq!(renames.len(), 1);
|
||||
assert_eq!(renames[0].old_path, "project-plan.md");
|
||||
assert_eq!(renames[0].new_path, "plans.md");
|
||||
|
||||
assert_eq!(update_wikilinks_for_renames(vault, renames).unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_note_to_folder_rejects_empty_folder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = vault_path(&dir);
|
||||
let note = write_note(&dir, "note.md", "# Note\n");
|
||||
|
||||
let error = move_note_to_folder(vault, note, " ".to_string()).unwrap_err();
|
||||
assert!(error.contains("Folder path cannot be empty"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
use super::git_command;
|
||||
|
||||
struct CloneRequest<'a> {
|
||||
url: &'a str,
|
||||
dest: &'a Path,
|
||||
}
|
||||
|
||||
/// Clone a git repository to a local path using the system git configuration.
|
||||
pub fn clone_repo(url: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
let request = CloneRequest { url, dest };
|
||||
prepare_clone_destination(dest)?;
|
||||
|
||||
if let Err(err) = run_clone(url, dest) {
|
||||
if let Err(err) = run_clone(&request) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
@@ -64,12 +72,14 @@ fn directory_has_entries(dest: &Path) -> Result<bool, String> {
|
||||
.map(|mut entries| entries.next().is_some())
|
||||
}
|
||||
|
||||
fn run_clone(url: &str, dest: &Path) -> Result<(), String> {
|
||||
let destination = dest
|
||||
.to_str()
|
||||
.ok_or_else(|| format!("Destination '{}' is not valid UTF-8", dest.display()))?;
|
||||
let output = Command::new("git")
|
||||
.args(["clone", "--progress", url, destination])
|
||||
fn run_clone(request: &CloneRequest<'_>) -> Result<(), String> {
|
||||
let destination = request.dest.to_str().ok_or_else(|| {
|
||||
format!(
|
||||
"Destination '{}' is not valid UTF-8",
|
||||
request.dest.display()
|
||||
)
|
||||
})?;
|
||||
let output = build_clone_command(request, destination)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
@@ -77,8 +87,36 @@ fn run_clone(url: &str, dest: &Path) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"git clone failed: {}",
|
||||
clone_failure_message(&output)
|
||||
))
|
||||
}
|
||||
|
||||
fn build_clone_command(request: &CloneRequest<'_>, destination: &str) -> Command {
|
||||
let mut command = git_command();
|
||||
command
|
||||
.args(["clone", "--quiet", request.url, destination])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("SSH_ASKPASS_REQUIRE", "never")
|
||||
.stdin(Stdio::null());
|
||||
command
|
||||
}
|
||||
|
||||
fn clone_failure_message(output: &Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("git clone failed: {}", stderr.trim()))
|
||||
let stderr = stderr.trim();
|
||||
if !stderr.is_empty() {
|
||||
return stderr.to_string();
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stdout = stdout.trim();
|
||||
if !stdout.is_empty() {
|
||||
return stdout.to_string();
|
||||
}
|
||||
|
||||
format!("git clone exited with status {}", output.status)
|
||||
}
|
||||
|
||||
fn cleanup_failed_clone(dest: &Path) {
|
||||
@@ -91,6 +129,7 @@ fn cleanup_failed_clone(dest: &Path) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::path::Path;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
@@ -160,4 +199,56 @@ mod tests {
|
||||
);
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_failure_message_falls_back_to_stdout() {
|
||||
let output = Output {
|
||||
status: std::process::ExitStatus::from_raw(128),
|
||||
stdout: b"fatal: stdout only".to_vec(),
|
||||
stderr: Vec::new(),
|
||||
};
|
||||
|
||||
assert_eq!(clone_failure_message(&output), "fatal: stdout only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_clone_command_disables_interactive_prompts() {
|
||||
let dest = Path::new("/tmp/repo");
|
||||
let request = CloneRequest {
|
||||
url: "https://example.com/repo.git",
|
||||
dest,
|
||||
};
|
||||
let command = build_clone_command(&request, "/tmp/repo");
|
||||
let args = command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let envs = command
|
||||
.get_envs()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key.to_string_lossy().to_string(),
|
||||
value.map(|entry| entry.to_string_lossy().to_string()),
|
||||
)
|
||||
})
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
|
||||
assert_eq!(
|
||||
envs.get("GIT_TERMINAL_PROMPT"),
|
||||
Some(&Some("0".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
envs.get("SSH_ASKPASS_REQUIRE"),
|
||||
Some(&Some("never".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"clone".to_string(),
|
||||
"--quiet".to_string(),
|
||||
"https://example.com/repo.git".to_string(),
|
||||
"/tmp/repo".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::git_command;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
struct CommitFailure {
|
||||
stdout: String,
|
||||
@@ -11,7 +11,7 @@ pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
// Stage all changes
|
||||
let add = Command::new("git")
|
||||
let add = git_command()
|
||||
.args(["add", "-A"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -37,7 +37,7 @@ pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
|
||||
}
|
||||
|
||||
fn run_commit(vault: &Path, message: &str, disable_signing: bool) -> Result<String, CommitFailure> {
|
||||
let mut command = Command::new("git");
|
||||
let mut command = git_command();
|
||||
if disable_signing {
|
||||
command.args(["-c", "commit.gpgsign=false"]);
|
||||
}
|
||||
@@ -85,10 +85,10 @@ fn is_commit_signing_failure(detail: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::git_command;
|
||||
use super::*;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_git_commit() {
|
||||
@@ -101,7 +101,7 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify the commit exists
|
||||
let log = Command::new("git")
|
||||
let log = git_command()
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -135,12 +135,12 @@ mod tests {
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["config", "commit.gpgsign", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["config", "gpg.program", "/missing/tolaria-test-gpg"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -153,14 +153,14 @@ mod tests {
|
||||
"commit should retry unsigned when signing helper is missing: {result:?}"
|
||||
);
|
||||
|
||||
let log = Command::new("git")
|
||||
let log = git_command()
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&log.stdout).contains("Commit with broken signing config"));
|
||||
|
||||
let config = Command::new("git")
|
||||
let config = git_command()
|
||||
.args(["config", "commit.gpgsign"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::run_git;
|
||||
use super::{git_command, run_git};
|
||||
|
||||
/// List files with merge conflicts (unmerged paths).
|
||||
///
|
||||
@@ -10,7 +9,7 @@ use super::run_git;
|
||||
/// stale (e.g. after a reboot or when MERGE_HEAD is missing).
|
||||
pub fn get_conflict_files(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["ls-files", "--unmerged"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -93,13 +92,13 @@ pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String
|
||||
|
||||
let mode = get_conflict_mode(vault_path);
|
||||
let output = match mode.as_str() {
|
||||
"rebase" => Command::new("git")
|
||||
"rebase" => git_command()
|
||||
.args(["rebase", "--continue"])
|
||||
.env("GIT_EDITOR", "true")
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git rebase --continue: {}", e))?,
|
||||
_ => Command::new("git")
|
||||
_ => git_command()
|
||||
.args(["commit", "-m", "Resolve merge conflicts"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -131,7 +130,6 @@ mod tests {
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair};
|
||||
use crate::git::{git_commit, git_pull, git_push};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
@@ -203,35 +201,30 @@ mod tests {
|
||||
(bare_dir, clone_a_dir, clone_b_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_ours() {
|
||||
fn assert_resolve_conflict_strategy(strategy: &str, expected_content: &str) {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
let conflicts = get_conflict_files(vp_b).unwrap();
|
||||
assert!(conflicts.contains(&"conflict.md".to_string()));
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
|
||||
git_resolve_conflict(vp_b, "conflict.md", strategy).unwrap();
|
||||
|
||||
let remaining = get_conflict_files(vp_b).unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
|
||||
assert_eq!(content, "# Version B\n");
|
||||
assert_eq!(content, expected_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_ours() {
|
||||
assert_resolve_conflict_strategy("ours", "# Version B\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_conflict_theirs() {
|
||||
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
|
||||
|
||||
let remaining = get_conflict_files(vp_b).unwrap();
|
||||
assert!(remaining.is_empty());
|
||||
|
||||
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
|
||||
assert_eq!(content, "# Version A\n");
|
||||
assert_resolve_conflict_strategy("theirs", "# Version A\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -244,7 +237,7 @@ mod tests {
|
||||
let result = git_commit_conflict_resolution(vp_b);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let log = Command::new("git")
|
||||
let log = git_command()
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(clone_b.path())
|
||||
.output()
|
||||
@@ -310,7 +303,7 @@ mod tests {
|
||||
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
|
||||
git_commit(vp_b, "B's change").unwrap();
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["pull", "--rebase"])
|
||||
.current_dir(clone_b_dir.path())
|
||||
.output()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
use std::process::Output;
|
||||
|
||||
use super::git_command;
|
||||
|
||||
const DEFAULT_REMOTE_NAME: &str = "origin";
|
||||
|
||||
@@ -192,14 +194,14 @@ fn list_remotes(vault: &Path) -> Result<Vec<String>, String> {
|
||||
}
|
||||
|
||||
fn unset_upstream(vault: &Path) {
|
||||
let _ = Command::new("git")
|
||||
let _ = git_command()
|
||||
.args(["branch", "--unset-upstream"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
}
|
||||
|
||||
fn run_git(vault: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(args)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -237,7 +239,7 @@ fn list_remote_branches(vault: &Path) -> Result<Vec<String>, String> {
|
||||
}
|
||||
|
||||
fn histories_share_base(vault: &Path, connection: &RemoteConnection) -> bool {
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["merge-base", "HEAD", connection.remote_branch.as_str()])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -315,7 +317,7 @@ fn classify_connect_error(stderr: &str) -> GitAddRemoteResult {
|
||||
}
|
||||
|
||||
fn git_output(vault: &Path, args: &[&str]) -> Result<Output, String> {
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(args)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::git_command;
|
||||
use chrono::DateTime;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Git-derived creation and modification timestamps for a file.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -19,7 +19,7 @@ pub struct GitDates {
|
||||
/// Files not yet committed (untracked / only staged) will not appear in the map;
|
||||
/// callers should fall back to filesystem metadata for those.
|
||||
pub fn get_all_file_dates(vault_path: &Path) -> HashMap<String, GitDates> {
|
||||
let output = match Command::new("git")
|
||||
let output = match git_command()
|
||||
.args(["log", "--format=COMMIT %aI", "--name-only"])
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::git_command;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::GitCommit;
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitComm
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args([
|
||||
"log",
|
||||
"--format=%H|%h|%an|%aI|%s",
|
||||
@@ -80,7 +80,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
|
||||
// First try tracked file diff
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["diff", "--", relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -90,7 +90,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
|
||||
|
||||
// If no diff (maybe staged or untracked), try diff --cached
|
||||
if stdout.is_empty() {
|
||||
let cached = Command::new("git")
|
||||
let cached = git_command()
|
||||
.args(["diff", "--cached", "--", relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -102,7 +102,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
|
||||
}
|
||||
|
||||
// Try showing untracked file as all-new
|
||||
let status = Command::new("git")
|
||||
let status = git_command()
|
||||
.args(["status", "--porcelain", "--", relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -144,7 +144,7 @@ pub fn get_file_diff_at_commit(
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
|
||||
// Show diff between commit^ and commit for this file
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args([
|
||||
"diff",
|
||||
&format!("{}^", commit_hash),
|
||||
@@ -161,7 +161,7 @@ pub fn get_file_diff_at_commit(
|
||||
// If diff is empty, it might be the initial commit (no parent).
|
||||
// Fall back to showing the full file content as added.
|
||||
if stdout.is_empty() {
|
||||
let show = Command::new("git")
|
||||
let show = git_command()
|
||||
.args(["show", &format!("{}:{}", commit_hash, relative_str)])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -184,10 +184,10 @@ pub fn get_file_diff_at_commit(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::git_command;
|
||||
use super::*;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_get_file_history_with_commits() {
|
||||
@@ -196,24 +196,24 @@ mod tests {
|
||||
let file = vault.join("test.md");
|
||||
|
||||
fs::write(&file, "# Initial\n").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
fs::write(&file, "# Updated\n\nNew content.").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "Update test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -248,12 +248,12 @@ mod tests {
|
||||
let file = vault.join("diff-test.md");
|
||||
|
||||
fs::write(&file, "# Test\n\nOriginal content.").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "diff-test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "Add diff-test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -275,31 +275,31 @@ mod tests {
|
||||
let file = vault.join("diff-at-commit.md");
|
||||
|
||||
fs::write(&file, "# First\n\nOriginal content.").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "diff-at-commit.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "First commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
fs::write(&file, "# First\n\nModified content.").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "diff-at-commit.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "Second commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Get hash of second commit
|
||||
let log = Command::new("git")
|
||||
let log = git_command()
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -321,18 +321,18 @@ mod tests {
|
||||
let file = vault.join("initial.md");
|
||||
|
||||
fs::write(&file, "# Initial\n\nHello world.").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "initial.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let log = Command::new("git")
|
||||
let log = git_command()
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
|
||||
@@ -56,6 +56,10 @@ const DEFAULT_GITIGNORE: &str = "# Tolaria app files (machine-specific, never co
|
||||
*.swp\n\
|
||||
*.swo\n";
|
||||
|
||||
fn git_command() -> Command {
|
||||
crate::hidden_command("git")
|
||||
}
|
||||
|
||||
/// Ensure a `.gitignore` with sensible defaults exists in the vault directory.
|
||||
/// Creates the file if missing; leaves existing `.gitignore` files untouched.
|
||||
pub fn ensure_gitignore(path: &str) -> Result<(), String> {
|
||||
@@ -99,7 +103,7 @@ fn commit_initial_vault_setup(dir: &Path) -> Result<(), String> {
|
||||
|
||||
/// Run a git command in the given directory, returning an error on failure.
|
||||
fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
@@ -130,7 +134,7 @@ fn ensure_author_config(dir: &Path) -> Result<(), String> {
|
||||
("user.name", "Tolaria"),
|
||||
("user.email", "vault@tolaria.app"),
|
||||
] {
|
||||
let check = Command::new("git")
|
||||
let check = git_command()
|
||||
.args(["config", key])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
@@ -174,7 +178,6 @@ fn parse_github_repo_path(url: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn assert_repo_path(url: &str, expected: Option<&str>) {
|
||||
@@ -188,19 +191,19 @@ mod tests {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["init", "--initial-branch=main"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["config", "user.name", "Test User"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
@@ -214,14 +217,14 @@ mod tests {
|
||||
let bare_dir = TempDir::new().unwrap();
|
||||
let bare = bare_dir.path();
|
||||
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["init", "--bare"])
|
||||
.current_dir(bare)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let clone_a_dir = TempDir::new().unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["clone", bare.to_str().unwrap(), "."])
|
||||
.current_dir(clone_a_dir.path())
|
||||
.output()
|
||||
@@ -230,7 +233,7 @@ mod tests {
|
||||
&["config", "user.email", "a@test.com"][..],
|
||||
&["config", "user.name", "User A"][..],
|
||||
] {
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(*cmd)
|
||||
.current_dir(clone_a_dir.path())
|
||||
.output()
|
||||
@@ -238,7 +241,7 @@ mod tests {
|
||||
}
|
||||
|
||||
let clone_b_dir = TempDir::new().unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["clone", bare.to_str().unwrap(), "."])
|
||||
.current_dir(clone_b_dir.path())
|
||||
.output()
|
||||
@@ -247,7 +250,7 @@ mod tests {
|
||||
&["config", "user.email", "b@test.com"][..],
|
||||
&["config", "user.name", "User B"][..],
|
||||
] {
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(*cmd)
|
||||
.current_dir(clone_b_dir.path())
|
||||
.output()
|
||||
@@ -301,7 +304,7 @@ mod tests {
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let log = Command::new("git")
|
||||
let log = git_command()
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
@@ -317,17 +320,17 @@ mod tests {
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Test\n").unwrap();
|
||||
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["init"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["config", "commit.gpgsign", "true"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["config", "gpg.program", "/missing/tolaria-test-gpg"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
@@ -335,7 +338,7 @@ mod tests {
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let log = Command::new("git")
|
||||
let log = git_command()
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
@@ -353,7 +356,7 @@ mod tests {
|
||||
|
||||
init_repo(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let status = Command::new("git")
|
||||
let status = git_command()
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&vault)
|
||||
.output()
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::parse_github_repo_path;
|
||||
use super::{git_command, parse_github_repo_path};
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct PulseFile {
|
||||
@@ -67,7 +66,7 @@ pub fn get_vault_pulse(
|
||||
|
||||
let limit_str = limit.to_string();
|
||||
let skip_str = skip.to_string();
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args([
|
||||
"log",
|
||||
"--name-status",
|
||||
@@ -99,7 +98,7 @@ pub fn get_vault_pulse(
|
||||
|
||||
fn get_github_base_url(vault_path: &str) -> Option<String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -123,69 +122,90 @@ fn parse_pulse_output(stdout: &str, github_base: &Option<String>) -> Vec<PulseCo
|
||||
continue;
|
||||
}
|
||||
|
||||
if line.contains('|')
|
||||
&& !line.starts_with(|c: char| {
|
||||
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
|
||||
})
|
||||
{
|
||||
// Commit header line: hash|short_hash|message|date
|
||||
if let Some(commit) = current.take() {
|
||||
commits.push(commit);
|
||||
}
|
||||
let parts: Vec<&str> = line.splitn(4, '|').collect();
|
||||
if parts.len() == 4 {
|
||||
let hash = parts[0];
|
||||
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0);
|
||||
let github_url = github_base
|
||||
.as_ref()
|
||||
.map(|base| format!("{}/commit/{}", base, hash));
|
||||
if is_commit_header(line) {
|
||||
push_current_commit(&mut commits, &mut current);
|
||||
current = parse_commit_header(line, github_base);
|
||||
continue;
|
||||
}
|
||||
|
||||
current = Some(PulseCommit {
|
||||
hash: hash.to_string(),
|
||||
short_hash: parts[1].to_string(),
|
||||
message: parts[2].to_string(),
|
||||
date,
|
||||
github_url,
|
||||
files: Vec::new(),
|
||||
added: 0,
|
||||
modified: 0,
|
||||
deleted: 0,
|
||||
});
|
||||
}
|
||||
} else if let Some(ref mut commit) = current {
|
||||
// File status line: A\tpath or M\tpath
|
||||
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
|
||||
if file_parts.len() == 2 {
|
||||
let status = parse_file_status(file_parts[0].trim());
|
||||
let path = file_parts[1].trim();
|
||||
match status {
|
||||
"added" => commit.added += 1,
|
||||
"deleted" => commit.deleted += 1,
|
||||
_ => commit.modified += 1,
|
||||
}
|
||||
commit.files.push(PulseFile {
|
||||
path: path.to_string(),
|
||||
status: status.to_string(),
|
||||
title: title_from_path(path),
|
||||
});
|
||||
}
|
||||
if let Some(ref mut commit) = current {
|
||||
add_file_change(commit, line);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(commit) = current {
|
||||
commits.push(commit);
|
||||
}
|
||||
push_current_commit(&mut commits, &mut current);
|
||||
|
||||
commits
|
||||
}
|
||||
|
||||
fn is_git_status_line(line: &str) -> bool {
|
||||
line.starts_with(|c: char| {
|
||||
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
|
||||
})
|
||||
}
|
||||
|
||||
fn is_commit_header(line: &str) -> bool {
|
||||
line.contains('|') && !is_git_status_line(line)
|
||||
}
|
||||
|
||||
fn push_current_commit(commits: &mut Vec<PulseCommit>, current: &mut Option<PulseCommit>) {
|
||||
if let Some(commit) = current.take() {
|
||||
commits.push(commit);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_commit_header(line: &str, github_base: &Option<String>) -> Option<PulseCommit> {
|
||||
let parts: Vec<&str> = line.splitn(4, '|').collect();
|
||||
if parts.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hash = parts[0];
|
||||
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0);
|
||||
let github_url = github_base
|
||||
.as_ref()
|
||||
.map(|base| format!("{}/commit/{}", base, hash));
|
||||
|
||||
Some(PulseCommit {
|
||||
hash: hash.to_string(),
|
||||
short_hash: parts[1].to_string(),
|
||||
message: parts[2].to_string(),
|
||||
date,
|
||||
github_url,
|
||||
files: Vec::new(),
|
||||
added: 0,
|
||||
modified: 0,
|
||||
deleted: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn add_file_change(commit: &mut PulseCommit, line: &str) {
|
||||
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
|
||||
if file_parts.len() != 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let status = parse_file_status(file_parts[0].trim());
|
||||
let path = file_parts[1].trim();
|
||||
match status {
|
||||
"added" => commit.added += 1,
|
||||
"deleted" => commit.deleted += 1,
|
||||
_ => commit.modified += 1,
|
||||
}
|
||||
commit.files.push(PulseFile {
|
||||
path: path.to_string(),
|
||||
status: status.to_string(),
|
||||
title: title_from_path(path),
|
||||
});
|
||||
}
|
||||
|
||||
/// Get the last commit's short hash and a GitHub URL (if remote is GitHub).
|
||||
pub fn get_last_commit_info(vault_path: &str) -> Result<Option<LastCommitInfo>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["log", "-1", "--format=%H|%h"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -223,23 +243,7 @@ pub fn get_last_commit_info(vault_path: &str) -> Result<Option<LastCommitInfo>,
|
||||
|
||||
/// Try to build a GitHub commit URL from the origin remote URL.
|
||||
fn get_github_commit_url(vault_path: &str, full_hash: &str) -> Option<String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
.args(["remote", "get-url", "origin"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let repo_path = parse_github_repo_path(&url)?;
|
||||
Some(format!(
|
||||
"https://github.com/{}/commit/{}",
|
||||
repo_path, full_hash
|
||||
))
|
||||
get_github_base_url(vault_path).map(|base| format!("{}/commit/{}", base, full_hash))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::git_command;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use super::conflict::get_conflict_files;
|
||||
|
||||
@@ -17,7 +17,7 @@ pub struct GitPullResult {
|
||||
/// Check whether the vault repo has at least one remote configured.
|
||||
pub fn has_remote(vault_path: &str) -> Result<bool, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["remote"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -40,7 +40,7 @@ pub fn git_pull(vault_path: &str) -> Result<GitPullResult, String> {
|
||||
});
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["pull", "--no-rebase"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -134,14 +134,14 @@ pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
|
||||
}
|
||||
|
||||
// Fetch latest remote refs (silent, best-effort)
|
||||
let _ = Command::new("git")
|
||||
let _ = git_command()
|
||||
.args(["fetch", "--quiet"])
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
let branch = current_branch(vault)?;
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -171,7 +171,7 @@ pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
|
||||
}
|
||||
|
||||
fn current_branch(vault: &Path) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["branch", "--show-current"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -189,59 +189,93 @@ pub struct GitPushResult {
|
||||
pub fn classify_push_error(stderr: &str) -> GitPushResult {
|
||||
let lower = stderr.to_lowercase();
|
||||
|
||||
if lower.contains("non-fast-forward")
|
||||
|| lower.contains("[rejected]")
|
||||
|| lower.contains("fetch first")
|
||||
|| lower.contains("failed to push some refs")
|
||||
&& (lower.contains("updates were rejected") || lower.contains("non-fast-forward"))
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "rejected".to_string(),
|
||||
message: "Push rejected: remote has new commits. Pull first, then push.".to_string(),
|
||||
};
|
||||
if is_rejected_push_error(&lower) {
|
||||
return push_error(
|
||||
"rejected",
|
||||
"Push rejected: remote has new commits. Pull first, then push.",
|
||||
);
|
||||
}
|
||||
|
||||
if lower.contains("authentication failed")
|
||||
|| lower.contains("could not read username")
|
||||
|| lower.contains("permission denied")
|
||||
|| lower.contains("403")
|
||||
|| lower.contains("invalid credentials")
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "auth_error".to_string(),
|
||||
message: "Push failed: authentication error. Check your credentials.".to_string(),
|
||||
};
|
||||
if is_auth_push_error(&lower) {
|
||||
return push_error(
|
||||
"auth_error",
|
||||
"Push failed: authentication error. Check your credentials.",
|
||||
);
|
||||
}
|
||||
|
||||
if lower.contains("could not resolve host")
|
||||
|| lower.contains("unable to access")
|
||||
|| lower.contains("connection refused")
|
||||
|| lower.contains("network is unreachable")
|
||||
|| lower.contains("timed out")
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "network_error".to_string(),
|
||||
message: "Push failed: network error. Check your connection and try again.".to_string(),
|
||||
};
|
||||
if is_network_push_error(&lower) {
|
||||
return push_error(
|
||||
"network_error",
|
||||
"Push failed: network error. Check your connection and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: extract the hint line if present, otherwise use the full stderr
|
||||
push_error(
|
||||
"error",
|
||||
format!("Push failed: {}", push_error_detail(stderr)),
|
||||
)
|
||||
}
|
||||
|
||||
fn push_error(status: &str, message: impl Into<String>) -> GitPushResult {
|
||||
GitPushResult {
|
||||
status: status.to_string(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_any(haystack: &str, needles: &[&str]) -> bool {
|
||||
needles.iter().any(|needle| haystack.contains(needle))
|
||||
}
|
||||
|
||||
fn is_rejected_push_error(lower: &str) -> bool {
|
||||
contains_any(lower, &["non-fast-forward", "[rejected]", "fetch first"])
|
||||
|| (lower.contains("failed to push some refs")
|
||||
&& contains_any(lower, &["updates were rejected", "non-fast-forward"]))
|
||||
}
|
||||
|
||||
fn is_auth_push_error(lower: &str) -> bool {
|
||||
contains_any(
|
||||
lower,
|
||||
&[
|
||||
"authentication failed",
|
||||
"could not read username",
|
||||
"permission denied",
|
||||
"403",
|
||||
"invalid credentials",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn is_network_push_error(lower: &str) -> bool {
|
||||
contains_any(
|
||||
lower,
|
||||
&[
|
||||
"could not resolve host",
|
||||
"unable to access",
|
||||
"connection refused",
|
||||
"network is unreachable",
|
||||
"timed out",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn push_error_detail(stderr: &str) -> String {
|
||||
let hint_line = stderr
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("hint:"))
|
||||
.map(|l| l.trim_start().strip_prefix("hint:").unwrap_or(l).trim())
|
||||
.find(|line| line.trim_start().starts_with("hint:"))
|
||||
.map(|line| {
|
||||
line.trim_start()
|
||||
.strip_prefix("hint:")
|
||||
.unwrap_or(line)
|
||||
.trim()
|
||||
})
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let detail = if hint_line.is_empty() {
|
||||
if hint_line.is_empty() {
|
||||
stderr.trim().to_string()
|
||||
} else {
|
||||
hint_line
|
||||
};
|
||||
|
||||
GitPushResult {
|
||||
status: "error".to_string(),
|
||||
message: format!("Push failed: {detail}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +283,7 @@ pub fn classify_push_error(stderr: &str) -> GitPushResult {
|
||||
pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["push"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -272,7 +306,6 @@ mod tests {
|
||||
use crate::git::git_commit;
|
||||
use crate::git::tests::{setup_git_repo, setup_remote_pair};
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_has_remote_returns_false_for_local_repo() {
|
||||
@@ -289,7 +322,7 @@ mod tests {
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["remote", "add", "origin", "https://example.com/repo.git"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::git_command;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ModifiedFile {
|
||||
@@ -23,14 +23,34 @@ struct DiffStats {
|
||||
binary: bool,
|
||||
}
|
||||
|
||||
fn status_label(status_code: &str) -> &'static str {
|
||||
match status_code.trim() {
|
||||
"M" | "MM" | "AM" => "modified",
|
||||
"A" => "added",
|
||||
"D" => "deleted",
|
||||
"??" => "untracked",
|
||||
"R" | "RM" => "renamed",
|
||||
_ => "modified",
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FileChangeStatus {
|
||||
Modified,
|
||||
Added,
|
||||
Deleted,
|
||||
Untracked,
|
||||
Renamed,
|
||||
}
|
||||
|
||||
impl FileChangeStatus {
|
||||
fn from_code(status_code: &str) -> Self {
|
||||
match status_code.trim() {
|
||||
"A" => Self::Added,
|
||||
"D" => Self::Deleted,
|
||||
"??" => Self::Untracked,
|
||||
"R" | "RM" => Self::Renamed,
|
||||
_ => Self::Modified,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Added => "added",
|
||||
Self::Deleted => "deleted",
|
||||
Self::Untracked => "untracked",
|
||||
Self::Renamed => "renamed",
|
||||
Self::Modified => "modified",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +88,7 @@ fn parse_numstat_line(line: &str) -> Option<(String, DiffStats)> {
|
||||
}
|
||||
|
||||
fn repo_has_head(vault: &Path) -> Result<bool, String> {
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["rev-parse", "--verify", "HEAD"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -82,7 +102,7 @@ fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["diff", "--numstat", "--find-renames", "HEAD", "--"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -100,7 +120,7 @@ fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
|
||||
.collect::<HashMap<_, _>>())
|
||||
}
|
||||
|
||||
fn count_worktree_lines(vault: &Path, relative_path: &str) -> DiffStats {
|
||||
fn count_worktree_lines(vault: &Path, relative_path: &Path) -> DiffStats {
|
||||
let full_path = vault.join(relative_path);
|
||||
let added_lines = std::fs::read_to_string(full_path)
|
||||
.ok()
|
||||
@@ -115,19 +135,20 @@ fn count_worktree_lines(vault: &Path, relative_path: &str) -> DiffStats {
|
||||
|
||||
fn resolve_diff_stats(
|
||||
vault: &Path,
|
||||
relative_path: &str,
|
||||
status: &str,
|
||||
relative_path: &Path,
|
||||
status: FileChangeStatus,
|
||||
diff_stats: &HashMap<String, DiffStats>,
|
||||
) -> DiffStats {
|
||||
if status == "untracked" {
|
||||
if status == FileChangeStatus::Untracked {
|
||||
return count_worktree_lines(vault, relative_path);
|
||||
}
|
||||
|
||||
diff_stats.get(relative_path).copied().unwrap_or_default()
|
||||
let key = relative_path.to_string_lossy();
|
||||
diff_stats.get(key.as_ref()).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn ensure_path_within_vault(vault: &Path, relative_path: &str, abs: &Path) -> Result<(), String> {
|
||||
for component in Path::new(relative_path).components() {
|
||||
fn ensure_path_within_vault(vault: &Path, relative_path: &Path, abs: &Path) -> Result<(), String> {
|
||||
for component in relative_path.components() {
|
||||
if matches!(component, std::path::Component::ParentDir) {
|
||||
return Err("File path is outside the vault".into());
|
||||
}
|
||||
@@ -151,9 +172,10 @@ fn ensure_path_within_vault(vault: &Path, relative_path: &str, abs: &Path) -> Re
|
||||
}
|
||||
}
|
||||
|
||||
fn load_file_status(vault: &Path, relative_path: &str) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain", "--", relative_path])
|
||||
fn load_file_status(vault: &Path, relative_path: &Path) -> Result<String, String> {
|
||||
let output = git_command()
|
||||
.args(["status", "--porcelain", "--"])
|
||||
.arg(relative_path)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {e}"))?;
|
||||
@@ -166,14 +188,16 @@ fn load_file_status(vault: &Path, relative_path: &str) -> Result<String, String>
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn restore_tracked_file(vault: &Path, relative_path: &str) -> Result<(), String> {
|
||||
let _ = Command::new("git")
|
||||
.args(["reset", "HEAD", "--", relative_path])
|
||||
fn restore_tracked_file(vault: &Path, relative_path: &Path) -> Result<(), String> {
|
||||
let _ = git_command()
|
||||
.args(["reset", "HEAD", "--"])
|
||||
.arg(relative_path)
|
||||
.current_dir(vault)
|
||||
.output();
|
||||
|
||||
let checkout = Command::new("git")
|
||||
.args(["checkout", "--", relative_path])
|
||||
let checkout = git_command()
|
||||
.args(["checkout", "--"])
|
||||
.arg(relative_path)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git checkout: {e}"))?;
|
||||
@@ -189,7 +213,7 @@ fn restore_tracked_file(vault: &Path, relative_path: &str) -> Result<(), String>
|
||||
/// Get list of modified/added/deleted files in the vault (uncommitted changes).
|
||||
pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = Command::new("git")
|
||||
let output = git_command()
|
||||
.args(["status", "--porcelain", "--untracked-files=all"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -217,14 +241,14 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
|
||||
return None;
|
||||
}
|
||||
|
||||
let status = status_label(status_code);
|
||||
let status = FileChangeStatus::from_code(status_code);
|
||||
let full_path = vault.join(&relative_path).to_string_lossy().to_string();
|
||||
let stats = resolve_diff_stats(vault, &relative_path, status, &diff_stats);
|
||||
let stats = resolve_diff_stats(vault, Path::new(&relative_path), status, &diff_stats);
|
||||
|
||||
Some(ModifiedFile {
|
||||
path: full_path,
|
||||
relative_path,
|
||||
status: status.to_string(),
|
||||
status: status.label().to_string(),
|
||||
added_lines: stats.added_lines,
|
||||
deleted_lines: stats.deleted_lines,
|
||||
binary: stats.binary,
|
||||
@@ -244,10 +268,11 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
|
||||
/// returned by [`get_modified_files`]).
|
||||
pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(), String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let abs = vault.join(relative_path);
|
||||
let relative = Path::new(relative_path);
|
||||
let abs = vault.join(relative);
|
||||
|
||||
ensure_path_within_vault(vault, relative_path, &abs)?;
|
||||
let status_code = load_file_status(vault, relative_path)?;
|
||||
ensure_path_within_vault(vault, relative, &abs)?;
|
||||
let status_code = load_file_status(vault, relative)?;
|
||||
|
||||
match status_code.as_str() {
|
||||
"??" => {
|
||||
@@ -255,7 +280,7 @@ pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(),
|
||||
.map_err(|e| format!("Failed to delete untracked file: {e}"))?;
|
||||
}
|
||||
_ => {
|
||||
restore_tracked_file(vault, relative_path)?;
|
||||
restore_tracked_file(vault, relative)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,11 +289,11 @@ pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(),
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::git_command;
|
||||
use super::*;
|
||||
use crate::git::git_commit;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
fn write_and_commit_markdown(vault: &Path, vp: &str, relative_path: &str, content: &str) {
|
||||
fs::write(vault.join(relative_path), content).unwrap();
|
||||
@@ -282,12 +307,12 @@ mod tests {
|
||||
|
||||
// Create and commit a file
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "note.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "Add note"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -327,12 +352,12 @@ mod tests {
|
||||
|
||||
// Create initial commit so git is initialized
|
||||
fs::write(vault.join("init.md"), "# Init\n").unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["add", "init.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
git_command()
|
||||
.args(["commit", "-m", "Initial"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
|
||||
@@ -12,6 +12,11 @@ pub mod settings;
|
||||
pub mod telemetry;
|
||||
pub mod vault;
|
||||
pub mod vault_list;
|
||||
#[cfg(desktop)]
|
||||
mod window_state;
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::process::Command;
|
||||
|
||||
#[cfg(desktop)]
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -20,12 +25,69 @@ use std::process::Child;
|
||||
#[cfg(desktop)]
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
pub(crate) fn hidden_command(program: impl AsRef<OsStr>) -> Command {
|
||||
let mut command = Command::new(program);
|
||||
suppress_windows_console(&mut command);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn suppress_windows_console(command: &mut Command) {
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn suppress_windows_console(_command: &mut Command) {}
|
||||
|
||||
#[cfg(desktop)]
|
||||
struct WsBridgeChild(Mutex<Option<Child>>);
|
||||
|
||||
#[cfg(desktop)]
|
||||
struct ActiveAssetScopeRoots(Mutex<Vec<PathBuf>>);
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct StartupEnvOverride {
|
||||
key: &'static str,
|
||||
value: &'static str,
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
fn linux_appimage_startup_env_overrides_with<F>(mut get_var: F) -> Vec<StartupEnvOverride>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
let is_appimage = ["APPIMAGE", "APPDIR"]
|
||||
.into_iter()
|
||||
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()));
|
||||
if !is_appimage {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if get_var("WEBKIT_DISABLE_DMABUF_RENDERER").is_some_and(|value| !value.trim().is_empty()) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![StartupEnvOverride {
|
||||
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
|
||||
value: "1",
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn apply_linux_appimage_startup_env_overrides() {
|
||||
for env_override in linux_appimage_startup_env_overrides_with(|key| std::env::var(key).ok()) {
|
||||
std::env::set_var(env_override.key, env_override.value);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(desktop, target_os = "linux")))]
|
||||
fn apply_linux_appimage_startup_env_overrides() {}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
match result {
|
||||
@@ -91,11 +153,31 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||
app.handle().plugin(tauri_plugin_process::init())?;
|
||||
app.handle().plugin(tauri_plugin_opener::init())?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
menu::setup_menu(app)?;
|
||||
setup_linux_window_chrome(app)?;
|
||||
window_state::restore_main_window_state(app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O"];
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn setup_linux_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use tauri::Manager;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_decorations(false);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(all(desktop, target_os = "linux")))]
|
||||
fn setup_linux_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "macos")))]
|
||||
const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O", "F"];
|
||||
#[cfg(any(test, all(desktop, target_os = "macos")))]
|
||||
const MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS: &[&str] = &["L"];
|
||||
|
||||
#[cfg(all(desktop, target_os = "macos"))]
|
||||
@@ -238,6 +320,7 @@ macro_rules! app_invoke_handler {
|
||||
commands::stream_ai_agent,
|
||||
commands::reload_vault,
|
||||
commands::reload_vault_entry,
|
||||
commands::sync_vault_asset_scope_for_window,
|
||||
commands::sync_note_title,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
@@ -259,7 +342,7 @@ macro_rules! app_invoke_handler {
|
||||
commands::download_and_install_app_update,
|
||||
commands::load_vault_list,
|
||||
commands::save_vault_list,
|
||||
commands::clone_repo,
|
||||
commands::git_clone::clone_git_repo,
|
||||
commands::search_vault,
|
||||
commands::create_empty_vault,
|
||||
commands::create_getting_started_vault,
|
||||
@@ -285,6 +368,8 @@ fn with_invoke_handler(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<ta
|
||||
fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
|
||||
use tauri::Manager;
|
||||
|
||||
window_state::handle_run_event(app_handle, event);
|
||||
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
@@ -297,12 +382,14 @@ fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
apply_linux_appimage_startup_env_overrides();
|
||||
let builder = tauri::Builder::default();
|
||||
|
||||
#[cfg(desktop)]
|
||||
let builder = builder
|
||||
.manage(WsBridgeChild(Mutex::new(None)))
|
||||
.manage(ActiveAssetScopeRoots(Mutex::new(Vec::new())));
|
||||
.manage(ActiveAssetScopeRoots(Mutex::new(Vec::new())))
|
||||
.manage(window_state::MainWindowFrameState::default());
|
||||
|
||||
with_invoke_handler(builder)
|
||||
.setup(setup_app)
|
||||
@@ -316,6 +403,9 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::linux_appimage_startup_env_overrides_with;
|
||||
use super::StartupEnvOverride;
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
|
||||
|
||||
#[cfg(all(desktop, unix))]
|
||||
@@ -323,9 +413,44 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn macos_webview_shortcut_prevention_includes_ai_panel_shortcut() {
|
||||
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_KEYS, ["O", "F"]);
|
||||
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_appimage_startup_env_overrides_are_empty_outside_appimage_launches() {
|
||||
let overrides = linux_appimage_startup_env_overrides_with(|_| None);
|
||||
|
||||
assert!(overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_appimage_startup_env_overrides_disable_dmabuf_for_appimages() {
|
||||
let overrides = linux_appimage_startup_env_overrides_with(|key| match key {
|
||||
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
overrides,
|
||||
vec![StartupEnvOverride {
|
||||
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
|
||||
value: "1",
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_appimage_startup_env_overrides_preserve_explicit_user_setting() {
|
||||
let overrides = linux_appimage_startup_env_overrides_with(|key| match key {
|
||||
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
|
||||
"WEBKIT_DISABLE_DMABUF_RENDERER" => Some("0".to_string()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
assert!(overrides.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, unix))]
|
||||
#[test]
|
||||
fn vault_asset_scope_roots_include_requested_symlink_path() {
|
||||
|
||||
@@ -17,10 +17,9 @@ pub enum McpStatus {
|
||||
|
||||
/// Find the `node` binary path at runtime.
|
||||
pub(crate) fn find_node() -> Result<PathBuf, String> {
|
||||
let output = Command::new("which")
|
||||
.arg("node")
|
||||
let output = node_lookup_command()
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run `which node`: {e}"))?;
|
||||
.map_err(|e| format!("Failed to locate node on PATH: {e}"))?;
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
@@ -35,6 +34,16 @@ pub(crate) fn find_node() -> Result<PathBuf, String> {
|
||||
Err("node not found in PATH or common install locations".into())
|
||||
}
|
||||
|
||||
fn node_lookup_command() -> Command {
|
||||
#[cfg(windows)]
|
||||
let mut command = crate::hidden_command("where.exe");
|
||||
#[cfg(not(windows))]
|
||||
let mut command = crate::hidden_command("which");
|
||||
|
||||
command.arg("node");
|
||||
command
|
||||
}
|
||||
|
||||
fn fallback_node_path() -> Option<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
PathBuf::from("/opt/homebrew/bin/node"),
|
||||
@@ -99,7 +108,7 @@ pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let script = server_dir.join("ws-bridge.js");
|
||||
|
||||
let child = Command::new(node)
|
||||
let child = crate::hidden_command(node)
|
||||
.arg(&script)
|
||||
.env("VAULT_PATH", vault_path)
|
||||
.env("WS_PORT", "9710")
|
||||
@@ -115,13 +124,18 @@ pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
|
||||
}
|
||||
|
||||
fn mcp_config_paths() -> Vec<PathBuf> {
|
||||
[
|
||||
dirs::home_dir().map(|home| home.join(".claude").join("mcp.json")),
|
||||
dirs::home_dir().map(|home| home.join(".cursor").join("mcp.json")),
|
||||
dirs::home_dir()
|
||||
.map(|home| mcp_config_paths_for_home(&home))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn mcp_config_paths_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
home.join(".claude.json"),
|
||||
home.join(".claude").join("mcp.json"),
|
||||
home.join(".cursor").join("mcp.json"),
|
||||
home.join(".config").join("mcp").join("mcp.json"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_registered_mcp_entry(config_path: &Path) -> Option<serde_json::Value> {
|
||||
@@ -184,7 +198,7 @@ fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf])
|
||||
status.to_string()
|
||||
}
|
||||
|
||||
/// Register Tolaria as an MCP server in Claude Code and Cursor config files.
|
||||
/// Register Tolaria as an MCP server in external AI tool config files.
|
||||
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
|
||||
let server_dir = mcp_server_dir()?;
|
||||
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
|
||||
@@ -297,7 +311,7 @@ pub fn remove_mcp() -> String {
|
||||
/// Check whether the MCP server is properly installed and registered.
|
||||
///
|
||||
/// Returns `Installed` when the Tolaria entry exists for the active vault in
|
||||
/// Claude Code or Cursor config and the referenced index.js file is present.
|
||||
/// an external AI tool config and the referenced index.js file is present.
|
||||
/// Otherwise returns `NotInstalled`.
|
||||
pub fn check_mcp_status(vault_path: &str) -> McpStatus {
|
||||
let active_vault_path = Path::new(vault_path);
|
||||
@@ -321,6 +335,32 @@ mod tests {
|
||||
serde_json::from_str(&raw).unwrap()
|
||||
}
|
||||
|
||||
fn temp_config_path(file_name: &str) -> (tempfile::TempDir, PathBuf) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join(file_name);
|
||||
(tmp, config_path)
|
||||
}
|
||||
|
||||
fn write_config_json(config_path: &Path, config: serde_json::Value) {
|
||||
std::fs::write(config_path, serde_json::to_string(&config).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn managed_server(index_js: &str, vault_path: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"command": "node",
|
||||
"args": [index_js],
|
||||
"env": { "VAULT_PATH": vault_path }
|
||||
})
|
||||
}
|
||||
|
||||
fn write_mcp_servers_config(config_path: &Path, servers: Vec<(&str, serde_json::Value)>) {
|
||||
let servers = servers
|
||||
.into_iter()
|
||||
.map(|(name, server)| (name.to_string(), server))
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
write_config_json(config_path, serde_json::json!({ "mcpServers": servers }));
|
||||
}
|
||||
|
||||
fn write_index_js(dir: &Path) -> PathBuf {
|
||||
let index_js = dir.join("index.js");
|
||||
std::fs::write(&index_js, "console.log('ok');").unwrap();
|
||||
@@ -404,15 +444,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_other_servers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
|
||||
let existing = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"other-server": { "command": "other", "args": [] }
|
||||
}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
|
||||
let (_tmp, config_path) = temp_config_path("mcp.json");
|
||||
write_mcp_servers_config(
|
||||
&config_path,
|
||||
vec![(
|
||||
"other-server",
|
||||
serde_json::json!({ "command": "other", "args": [] }),
|
||||
)],
|
||||
);
|
||||
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
@@ -423,6 +462,30 @@ mod tests {
|
||||
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_preserves_other_top_level_settings() {
|
||||
let (_tmp, config_path) = temp_config_path(".claude.json");
|
||||
write_config_json(
|
||||
&config_path,
|
||||
serde_json::json!({
|
||||
"model": "sonnet",
|
||||
"theme": "dark",
|
||||
"mcpServers": {
|
||||
"other-server": { "command": "other", "args": [] }
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
upsert_mcp_config(&config_path, &entry).unwrap();
|
||||
|
||||
let config = read_config(&config_path);
|
||||
assert_eq!(config["model"], "sonnet");
|
||||
assert_eq!(config["theme"], "dark");
|
||||
assert!(config["mcpServers"]["other-server"].is_object());
|
||||
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_creates_parent_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -491,22 +554,50 @@ mod tests {
|
||||
#[test]
|
||||
fn register_mcp_to_configs_writes_multiple_configs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let claude_user_cfg = tmp.path().join(".claude.json");
|
||||
let claude_cfg = tmp.path().join("claude").join("mcp.json");
|
||||
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
|
||||
let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json");
|
||||
let entry = build_mcp_entry("/test/index.js", "/vault");
|
||||
|
||||
register_mcp_to_configs(&entry, &[claude_cfg.clone(), cursor_cfg.clone()]);
|
||||
register_mcp_to_configs(
|
||||
&entry,
|
||||
&[
|
||||
claude_user_cfg.clone(),
|
||||
claude_cfg.clone(),
|
||||
cursor_cfg.clone(),
|
||||
generic_cfg.clone(),
|
||||
],
|
||||
);
|
||||
|
||||
assert!(claude_user_cfg.exists());
|
||||
assert!(claude_cfg.exists());
|
||||
assert!(cursor_cfg.exists());
|
||||
assert!(generic_cfg.exists());
|
||||
|
||||
let raw = std::fs::read_to_string(&claude_cfg).unwrap();
|
||||
let raw = std::fs::read_to_string(&claude_user_cfg).unwrap();
|
||||
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
|
||||
assert_eq!(
|
||||
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
|
||||
"/test/index.js"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_config_paths_for_home_includes_all_supported_config_paths() {
|
||||
let home = Path::new("/Users/tester");
|
||||
let paths = mcp_config_paths_for_home(home);
|
||||
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
home.join(".claude.json"),
|
||||
home.join(".claude").join("mcp.json"),
|
||||
home.join(".cursor").join("mcp.json"),
|
||||
home.join(".config").join("mcp").join("mcp.json"),
|
||||
]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn upsert_returns_error_for_invalid_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -528,23 +619,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn read_registered_mcp_entry_prefers_primary_server_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
let config = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"tolaria": {
|
||||
"command": "node",
|
||||
"args": ["/primary/index.js"],
|
||||
"env": { "VAULT_PATH": "/primary" }
|
||||
},
|
||||
"laputa": {
|
||||
"command": "node",
|
||||
"args": ["/legacy/index.js"],
|
||||
"env": { "VAULT_PATH": "/legacy" }
|
||||
}
|
||||
}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
|
||||
let (_tmp, config_path) = temp_config_path("mcp.json");
|
||||
write_mcp_servers_config(
|
||||
&config_path,
|
||||
vec![
|
||||
(
|
||||
MCP_SERVER_NAME,
|
||||
managed_server("/primary/index.js", "/primary"),
|
||||
),
|
||||
(
|
||||
LEGACY_MCP_SERVER_NAME,
|
||||
managed_server("/legacy/index.js", "/legacy"),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let entry = read_registered_mcp_entry(&config_path).unwrap();
|
||||
assert_eq!(entry["env"]["VAULT_PATH"], "/primary");
|
||||
@@ -552,18 +640,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn read_registered_mcp_entry_uses_legacy_server_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("mcp.json");
|
||||
let config = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"laputa": {
|
||||
"command": "node",
|
||||
"args": ["/legacy/index.js"],
|
||||
"env": { "VAULT_PATH": "/legacy" }
|
||||
}
|
||||
}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
|
||||
let (_tmp, config_path) = temp_config_path("mcp.json");
|
||||
write_mcp_servers_config(
|
||||
&config_path,
|
||||
vec![(
|
||||
LEGACY_MCP_SERVER_NAME,
|
||||
managed_server("/legacy/index.js", "/legacy"),
|
||||
)],
|
||||
);
|
||||
|
||||
let entry = read_registered_mcp_entry(&config_path).unwrap();
|
||||
assert_eq!(entry["env"]["VAULT_PATH"], "/legacy");
|
||||
|
||||
@@ -13,6 +13,8 @@ const FILE_QUICK_OPEN: &str = "file-quick-open";
|
||||
const FILE_QUICK_OPEN_ALIAS: &str = "file-quick-open-alias";
|
||||
const FILE_SAVE: &str = "file-save";
|
||||
|
||||
const EDIT_FIND_IN_NOTE: &str = "edit-find-in-note";
|
||||
const EDIT_REPLACE_IN_NOTE: &str = "edit-replace-in-note";
|
||||
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
|
||||
const EDIT_TOGGLE_NOTE_LIST_SEARCH: &str = "edit-toggle-note-list-search";
|
||||
const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor";
|
||||
@@ -62,6 +64,8 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
FILE_QUICK_OPEN,
|
||||
FILE_QUICK_OPEN_ALIAS,
|
||||
FILE_SAVE,
|
||||
EDIT_FIND_IN_NOTE,
|
||||
EDIT_REPLACE_IN_NOTE,
|
||||
EDIT_FIND_IN_VAULT,
|
||||
EDIT_TOGGLE_NOTE_LIST_SEARCH,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
@@ -112,6 +116,9 @@ const NOTE_DEPENDENT_IDS: &[&str] = &[
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
];
|
||||
|
||||
/// IDs of menu items that depend on the editor being the active surface.
|
||||
const EDITOR_FIND_DEPENDENT_IDS: &[&str] = &[EDIT_FIND_IN_NOTE, EDIT_REPLACE_IN_NOTE];
|
||||
|
||||
/// IDs of menu items that depend on the note list being the active surface.
|
||||
const NOTE_LIST_SEARCH_DEPENDENT_IDS: &[&str] = &[EDIT_TOGGLE_NOTE_LIST_SEARCH];
|
||||
|
||||
@@ -156,6 +163,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 +179,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)?;
|
||||
@@ -186,6 +198,15 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
}
|
||||
|
||||
fn build_edit_menu(app: &App) -> MenuResult {
|
||||
let find_in_note = MenuItemBuilder::new("Find in Note")
|
||||
.id(EDIT_FIND_IN_NOTE)
|
||||
.accelerator("CmdOrCtrl+F")
|
||||
.enabled(false)
|
||||
.build(app)?;
|
||||
let replace_in_note = MenuItemBuilder::new("Replace in Note")
|
||||
.id(EDIT_REPLACE_IN_NOTE)
|
||||
.enabled(false)
|
||||
.build(app)?;
|
||||
let find_in_vault = MenuItemBuilder::new("Find in Vault")
|
||||
.id(EDIT_FIND_IN_VAULT)
|
||||
.accelerator("CmdOrCtrl+Shift+F")
|
||||
@@ -209,6 +230,8 @@ fn build_edit_menu(app: &App) -> MenuResult {
|
||||
.separator()
|
||||
.select_all()
|
||||
.separator()
|
||||
.item(&find_in_note)
|
||||
.item(&replace_in_note)
|
||||
.item(&find_in_vault)
|
||||
.item(&toggle_note_list_search)
|
||||
.item(&toggle_diff)
|
||||
@@ -320,7 +343,7 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.build(app)?;
|
||||
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
|
||||
.id(VIEW_TOGGLE_AI_CHAT)
|
||||
.accelerator("Cmd+Shift+L")
|
||||
.accelerator("CmdOrCtrl+Shift+L")
|
||||
.build(app)?;
|
||||
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
|
||||
.id(VIEW_TOGGLE_BACKLINKS)
|
||||
@@ -463,6 +486,11 @@ pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
|
||||
set_items_enabled(app_handle, NOTE_DEPENDENT_IDS, enabled);
|
||||
}
|
||||
|
||||
/// Enable or disable menu items that depend on the editor being the active surface.
|
||||
pub fn set_editor_find_items_enabled(app_handle: &AppHandle, enabled: bool) {
|
||||
set_items_enabled(app_handle, EDITOR_FIND_DEPENDENT_IDS, enabled);
|
||||
}
|
||||
|
||||
/// Enable or disable menu items that depend on the note list being the active surface.
|
||||
pub fn set_note_list_search_items_enabled(app_handle: &AppHandle, enabled: bool) {
|
||||
set_items_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_IDS, enabled);
|
||||
@@ -501,6 +529,8 @@ mod tests {
|
||||
FILE_NEW_TYPE,
|
||||
FILE_QUICK_OPEN,
|
||||
FILE_SAVE,
|
||||
EDIT_FIND_IN_NOTE,
|
||||
EDIT_REPLACE_IN_NOTE,
|
||||
EDIT_FIND_IN_VAULT,
|
||||
EDIT_TOGGLE_NOTE_LIST_SEARCH,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
@@ -559,6 +589,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_find_dependent_ids_are_subset_of_custom_ids() {
|
||||
for id in EDITOR_FIND_DEPENDENT_IDS {
|
||||
assert!(
|
||||
CUSTOM_IDS.contains(id),
|
||||
"editor-find-dependent ID {id} not in CUSTOM_IDS"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_dependent_ids_are_subset_of_custom_ids() {
|
||||
for id in GIT_COMMIT_DEPENDENT_IDS {
|
||||
|
||||
@@ -5,17 +5,20 @@ 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>,
|
||||
pub autogit_idle_threshold_seconds: Option<u32>,
|
||||
pub autogit_inactive_threshold_seconds: Option<u32>,
|
||||
pub auto_advance_inbox_after_organize: Option<bool>,
|
||||
pub telemetry_consent: Option<bool>,
|
||||
pub crash_reporting_enabled: Option<bool>,
|
||||
pub analytics_enabled: Option<bool>,
|
||||
pub anonymous_id: Option<String>,
|
||||
pub release_channel: Option<String>,
|
||||
pub theme_mode: Option<String>,
|
||||
pub ui_language: Option<String>,
|
||||
pub initial_h1_auto_rename_enabled: Option<bool>,
|
||||
pub default_ai_agent: Option<String>,
|
||||
}
|
||||
@@ -52,6 +55,41 @@ 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 canonical_language_code(value: &str) -> Option<String> {
|
||||
let code = value.trim().replace('_', "-").to_ascii_lowercase();
|
||||
if code.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(code)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_english_language(code: &str) -> bool {
|
||||
code == "en" || code.starts_with("en-")
|
||||
}
|
||||
|
||||
fn is_simplified_chinese_language(code: &str) -> bool {
|
||||
matches!(code, "zh" | "zh-cn" | "zh-hans" | "zh-sg")
|
||||
}
|
||||
|
||||
pub fn normalize_ui_language(value: Option<&str>) -> Option<String> {
|
||||
let language = canonical_language_code(value?)?;
|
||||
if is_english_language(&language) {
|
||||
return Some("en".to_string());
|
||||
}
|
||||
if is_simplified_chinese_language(&language) {
|
||||
return Some("zh-Hans".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_settings(settings: Settings) -> Settings {
|
||||
Settings {
|
||||
auto_pull_interval_minutes: settings.auto_pull_interval_minutes,
|
||||
@@ -62,11 +100,14 @@ fn normalize_settings(settings: Settings) -> Settings {
|
||||
autogit_inactive_threshold_seconds: normalize_optional_positive_u32(
|
||||
settings.autogit_inactive_threshold_seconds,
|
||||
),
|
||||
auto_advance_inbox_after_organize: settings.auto_advance_inbox_after_organize,
|
||||
telemetry_consent: settings.telemetry_consent,
|
||||
crash_reporting_enabled: settings.crash_reporting_enabled,
|
||||
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()),
|
||||
ui_language: normalize_ui_language(settings.ui_language.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()),
|
||||
}
|
||||
@@ -76,7 +117,7 @@ fn app_config_dir() -> Result<PathBuf, String> {
|
||||
dirs::config_dir().ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
|
||||
pub(crate) fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
|
||||
Ok(app_config_dir()?.join(APP_CONFIG_DIR).join(file_name))
|
||||
}
|
||||
|
||||
@@ -164,41 +205,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<&'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.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)
|
||||
);
|
||||
assert_eq!(settings, &Settings::default());
|
||||
}
|
||||
|
||||
/// Helper: save settings to a temp file and reload them.
|
||||
@@ -234,17 +242,20 @@ mod tests {
|
||||
autogit_enabled: Some(true),
|
||||
autogit_idle_threshold_seconds: Some(90),
|
||||
autogit_inactive_threshold_seconds: Some(30),
|
||||
auto_advance_inbox_after_organize: Some(true),
|
||||
telemetry_consent: Some(true),
|
||||
crash_reporting_enabled: Some(true),
|
||||
analytics_enabled: Some(false),
|
||||
anonymous_id: Some("abc-123-uuid".to_string()),
|
||||
release_channel: Some("alpha".to_string()),
|
||||
theme_mode: Some("dark".to_string()),
|
||||
ui_language: Some("zh-Hans".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]
|
||||
@@ -262,7 +273,10 @@ mod tests {
|
||||
autogit_enabled: Some(true),
|
||||
autogit_idle_threshold_seconds: Some(90),
|
||||
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()),
|
||||
ui_language: Some("zh-Hans".to_string()),
|
||||
initial_h1_auto_rename_enabled: Some(false),
|
||||
default_ai_agent: Some("codex".to_string()),
|
||||
..Default::default()
|
||||
@@ -271,7 +285,10 @@ mod tests {
|
||||
assert_eq!(loaded.autogit_enabled, Some(true));
|
||||
assert_eq!(loaded.autogit_idle_threshold_seconds, Some(90));
|
||||
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.ui_language.as_deref(), Some("zh-Hans"));
|
||||
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
|
||||
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
|
||||
}
|
||||
@@ -281,11 +298,15 @@ 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()),
|
||||
ui_language: Some(" zh-cn ".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.ui_language.as_deref(), Some("zh-Hans"));
|
||||
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
|
||||
}
|
||||
|
||||
@@ -327,6 +348,33 @@ 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_invalid_ui_language_is_filtered() {
|
||||
let loaded = save_and_reload(Settings {
|
||||
ui_language: Some("fr-FR".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(loaded.ui_language.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_language_aliases_are_canonicalized() {
|
||||
assert_eq!(normalize_ui_language(Some("en-US")).as_deref(), Some("en"));
|
||||
assert_eq!(
|
||||
normalize_ui_language(Some("zh_CN")).as_deref(),
|
||||
Some("zh-Hans")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_settings_normalizes_legacy_beta_channel() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
@@ -377,20 +425,14 @@ mod tests {
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
settings_snapshot(&loaded),
|
||||
(
|
||||
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()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ fn git_head_hash(vault: &Path) -> Option<String> {
|
||||
|
||||
/// Run a git command in the given directory and return stdout if successful.
|
||||
fn run_git(vault: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = std::process::Command::new("git")
|
||||
let output = crate::hidden_command("git")
|
||||
.args(args)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -510,7 +510,7 @@ fn migrate_legacy_cache(vault: &Path) {
|
||||
}
|
||||
|
||||
// Remove legacy file from git tracking if present
|
||||
let _ = std::process::Command::new("git")
|
||||
let _ = crate::hidden_command("git")
|
||||
.args([
|
||||
"rm",
|
||||
"--cached",
|
||||
@@ -727,17 +727,17 @@ mod tests {
|
||||
}
|
||||
|
||||
fn init_git_repo(vault: &Path) {
|
||||
std::process::Command::new("git")
|
||||
crate::hidden_command("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
crate::hidden_command("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
crate::hidden_command("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -756,12 +756,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn git_add_commit(vault: &Path, msg: &str) {
|
||||
std::process::Command::new("git")
|
||||
crate::hidden_command("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
crate::hidden_command("git")
|
||||
.args(["commit", "-m", msg])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -1121,7 +1121,7 @@ mod tests {
|
||||
// Delete file via filesystem (simulates Finder delete)
|
||||
fs::remove_file(vault.join("remove.md")).unwrap();
|
||||
// Also stage the deletion so git status is clean for this file
|
||||
std::process::Command::new("git")
|
||||
crate::hidden_command("git")
|
||||
.args(["add", "remove.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::fs;
|
||||
use std::io::{ErrorKind, Write};
|
||||
use std::io::{Error, ErrorKind, Write};
|
||||
use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
@@ -25,6 +25,49 @@ fn invalid_utf8_text_error(path: &Path) -> String {
|
||||
format!("File is not valid UTF-8 text: {}", path.display())
|
||||
}
|
||||
|
||||
fn is_invalid_platform_path_error(error: &Error) -> bool {
|
||||
error.kind() == ErrorKind::InvalidInput || error.raw_os_error() == Some(123)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum NoteIoOperation {
|
||||
Save,
|
||||
Create,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct NotePathDisplay<'a> {
|
||||
value: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> NotePathDisplay<'a> {
|
||||
fn new(value: &'a str) -> Self {
|
||||
Self { value }
|
||||
}
|
||||
}
|
||||
|
||||
impl NoteIoOperation {
|
||||
fn verb(self) -> &'static str {
|
||||
match self {
|
||||
Self::Save => "save",
|
||||
Self::Create => "create",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn note_io_error(operation: NoteIoOperation, path: NotePathDisplay<'_>, error: &Error) -> String {
|
||||
let verb = operation.verb();
|
||||
if is_invalid_platform_path_error(error) {
|
||||
let path = path.value;
|
||||
format!(
|
||||
"Failed to {verb} note: the path is invalid on this platform. Rename the note or move it to a valid folder, then try again. Path: {path}"
|
||||
)
|
||||
} else {
|
||||
let path = path.value;
|
||||
format!("Failed to {verb} {path}: {error}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the content of a single note file.
|
||||
pub fn get_note_content(path: &Path) -> Result<String, String> {
|
||||
if !path.exists() {
|
||||
@@ -62,12 +105,14 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
|
||||
let file_path = Path::new(path);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
validate_save_path(file_path, path)?;
|
||||
fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e))
|
||||
fs::write(file_path, content)
|
||||
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
|
||||
}
|
||||
|
||||
/// Create a new note file without overwriting any existing file.
|
||||
@@ -75,8 +120,9 @@ pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
|
||||
let file_path = Path::new(path);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
validate_save_path(file_path, path)?;
|
||||
@@ -86,8 +132,27 @@ pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
|
||||
.open(file_path)
|
||||
.map_err(|e| match e.kind() {
|
||||
ErrorKind::AlreadyExists => format!("File already exists: {}", path),
|
||||
_ => format!("Failed to create {}: {}", path, e),
|
||||
_ => note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e),
|
||||
})?;
|
||||
file.write_all(content.as_bytes())
|
||||
.map_err(|e| format!("Failed to save {}: {}", path, e))
|
||||
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_windows_invalid_path_syntax_as_recoverable_save_error() {
|
||||
let path = r"C:\Users\@raflymln\notes\untitled-note-1777236475.md";
|
||||
let message = note_io_error(
|
||||
NoteIoOperation::Save,
|
||||
NotePathDisplay::new(path),
|
||||
&Error::from_raw_os_error(123),
|
||||
);
|
||||
|
||||
assert!(message.contains("path is invalid on this platform"));
|
||||
assert!(message.contains("Rename the note or move it to a valid folder"));
|
||||
assert!(!message.contains("os error 123"));
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Public starter vault cloned when the user chooses Getting Started.
|
||||
pub const GETTING_STARTED_REPO_URL: &str =
|
||||
@@ -578,7 +577,7 @@ fn refresh_cloned_vault_config_files(vault_path: &Path) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn vault_has_pending_changes(vault_path: &Path) -> Result<bool, String> {
|
||||
let output = Command::new("git")
|
||||
let output = crate::hidden_command("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
@@ -599,7 +598,7 @@ fn ensure_commit_identity(vault_path: &Path) -> Result<(), String> {
|
||||
("user.name", "Tolaria"),
|
||||
("user.email", "vault@tolaria.app"),
|
||||
] {
|
||||
let output = Command::new("git")
|
||||
let output = crate::hidden_command("git")
|
||||
.args(["config", key])
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
@@ -609,7 +608,7 @@ fn ensure_commit_identity(vault_path: &Path) -> Result<(), String> {
|
||||
continue;
|
||||
}
|
||||
|
||||
let set_output = Command::new("git")
|
||||
let set_output = crate::hidden_command("git")
|
||||
.args(["config", key, fallback])
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
|
||||
@@ -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,23 +21,56 @@ 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,
|
||||
failed_updates: usize,
|
||||
}
|
||||
|
||||
/// Convert a title to a filename slug (lowercase, hyphens, no special chars).
|
||||
/// Convert a title to a filename slug (lowercase, hyphens, Unicode letters/digits preserved).
|
||||
pub(super) fn title_to_slug(title: &str) -> String {
|
||||
title
|
||||
let slug = title
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<&str>>()
|
||||
.join("-")
|
||||
.join("-");
|
||||
if slug.is_empty() {
|
||||
"untitled".to_string()
|
||||
} else {
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a regex that matches wiki links referencing any of the provided targets.
|
||||
@@ -176,12 +210,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 +232,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 +247,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 +273,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 +304,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 +314,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 +330,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 +342,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 +394,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 +428,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 +463,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 +475,20 @@ pub fn auto_rename_untitled(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", note_path, e))?;
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", request.note_path, e))?;
|
||||
|
||||
let h1_title = match super::parsing::extract_h1_title(&content) {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let result = rename_note(vault_path, note_path, &h1_title, None)?;
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: request.vault_path,
|
||||
old_path: request.note_path,
|
||||
new_title: &h1_title,
|
||||
old_title_hint: None,
|
||||
})?;
|
||||
Ok(Some(result))
|
||||
}
|
||||
|
||||
@@ -484,9 +500,8 @@ pub struct DetectedRename {
|
||||
}
|
||||
|
||||
/// Detect renamed files by comparing working tree against HEAD using git diff.
|
||||
pub fn detect_renames(vault_path: &str) -> Result<Vec<DetectedRename>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let output = std::process::Command::new("git")
|
||||
pub fn detect_renames(vault: &Path) -> Result<Vec<DetectedRename>, String> {
|
||||
let output = crate::hidden_command("git")
|
||||
.args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -521,28 +536,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 +563,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 +585,86 @@ 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());
|
||||
}
|
||||
|
||||
fn assert_slug_case(input: &str, expected: &str) {
|
||||
assert_eq!(title_to_slug(input), expected);
|
||||
}
|
||||
|
||||
fn assert_unicode_rename_path(result: &RenameResult) {
|
||||
assert!(
|
||||
result.new_path.ends_with("你好.md"),
|
||||
"got {}",
|
||||
result.new_path
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_unicode_rename_filesystem(vault: &Path, old_path: &Path, result: &RenameResult) {
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
assert!(!old_path.exists());
|
||||
assert!(
|
||||
!vault.join(".md").exists(),
|
||||
"must not produce a stem-less .md file"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_unicode_rename_frontmatter(result: &RenameResult) {
|
||||
let new_content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert!(new_content.contains("title: 你好"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_to_slug() {
|
||||
assert_eq!(title_to_slug("Weekly Review"), "weekly-review");
|
||||
@@ -594,6 +672,44 @@ mod tests {
|
||||
assert_eq!(title_to_slug("Hello World"), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_to_slug_preserves_unicode_letters() {
|
||||
assert_slug_case("你好", "你好");
|
||||
assert_slug_case("こんにちは", "こんにちは");
|
||||
assert_slug_case("My Note 你好", "my-note-你好");
|
||||
assert_slug_case("项目-2025 ✦ Q1", "项目-2025-q1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_to_slug_falls_back_to_untitled_for_symbol_only_titles() {
|
||||
assert_eq!(title_to_slug("!?"), "untitled");
|
||||
assert_eq!(title_to_slug("---"), "untitled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_with_cjk_title_writes_unicode_filename() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"untitled-note-1700000000.md",
|
||||
"---\ntype: Note\n---\n# Untitled Note\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("untitled-note-1700000000.md");
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "你好",
|
||||
old_title_hint: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_unicode_rename_path(&result);
|
||||
assert_unicode_rename_filesystem(vault, &old_path, &result);
|
||||
assert_unicode_rename_frontmatter(&result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_basic() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -605,12 +721,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 +760,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 +785,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 +883,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 +901,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 +989,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 +1030,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 +1066,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 +1088,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 +1115,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 +1138,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 +1152,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 +1180,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 +1212,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();
|
||||
|
||||
514
src-tauri/src/window_state.rs
Normal file
514
src-tauri/src/window_state.rs
Normal file
@@ -0,0 +1,514 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{
|
||||
App, AppHandle, LogicalPosition, LogicalSize, Manager, Position, RunEvent, Size, WebviewWindow,
|
||||
WindowEvent,
|
||||
};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const WINDOW_STATE_FILE: &str = "window-state.json";
|
||||
const MIN_WINDOW_WIDTH: u32 = 480;
|
||||
const MIN_WINDOW_HEIGHT: u32 = 400;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct MainWindowFrameState(Mutex<Option<WindowFrame>>);
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
|
||||
struct WindowFrame {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
struct ScreenArea {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct PersistedWindowState {
|
||||
main: Option<WindowFrame>,
|
||||
#[serde(default)]
|
||||
coordinate_space: WindowFrameCoordinateSpace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WindowFrameCoordinateSpace {
|
||||
#[default]
|
||||
Physical,
|
||||
Logical,
|
||||
}
|
||||
|
||||
pub(crate) fn restore_main_window_state(app: &mut App) {
|
||||
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
|
||||
return;
|
||||
};
|
||||
restore_main_window_frame(app.handle(), &window, "during setup");
|
||||
}
|
||||
|
||||
pub(crate) fn handle_run_event(app_handle: &AppHandle, event: &RunEvent) {
|
||||
match event {
|
||||
event if restores_window_frame_after_runtime_ready(event) => {
|
||||
restore_main_window_state_from_handle(app_handle)
|
||||
}
|
||||
RunEvent::WindowEvent {
|
||||
label,
|
||||
event:
|
||||
WindowEvent::Moved(_) | WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. },
|
||||
..
|
||||
} if label == MAIN_WINDOW_LABEL => cache_current_normal_frame(app_handle),
|
||||
RunEvent::WindowEvent {
|
||||
label,
|
||||
event: WindowEvent::CloseRequested { .. } | WindowEvent::Destroyed,
|
||||
..
|
||||
} if label == MAIN_WINDOW_LABEL => save_main_window_frame(app_handle),
|
||||
RunEvent::Exit => save_main_window_frame(app_handle),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn restores_window_frame_after_runtime_ready(event: &RunEvent) -> bool {
|
||||
matches!(event, RunEvent::Ready)
|
||||
}
|
||||
|
||||
fn restore_main_window_state_from_handle(app_handle: &AppHandle) {
|
||||
let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) else {
|
||||
return;
|
||||
};
|
||||
restore_main_window_frame(app_handle, &window, "after runtime ready");
|
||||
}
|
||||
|
||||
fn restore_main_window_frame(app_handle: &AppHandle, window: &WebviewWindow, phase: &str) {
|
||||
let Some(frame) = read_main_window_frame(window_scale_factor(window)) else {
|
||||
return;
|
||||
};
|
||||
let areas = current_screen_areas(window);
|
||||
let Some(restored_frame) = fit_frame_to_screens(frame, &areas) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(err) = apply_window_frame(window, restored_frame) {
|
||||
log::warn!("Failed to restore main window state {phase}: {err}");
|
||||
return;
|
||||
}
|
||||
|
||||
cache_frame(app_handle, restored_frame);
|
||||
}
|
||||
|
||||
fn cache_current_normal_frame(app_handle: &AppHandle) {
|
||||
if let Some(frame) = current_normal_main_window_frame(app_handle) {
|
||||
cache_frame(app_handle, frame);
|
||||
}
|
||||
}
|
||||
|
||||
fn save_main_window_frame(app_handle: &AppHandle) {
|
||||
let frame = current_normal_main_window_frame(app_handle).or_else(|| cached_frame(app_handle));
|
||||
if let Some(frame) = frame {
|
||||
if let Err(err) = write_main_window_frame(frame) {
|
||||
log::warn!("Failed to save main window state: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_normal_main_window_frame(app_handle: &AppHandle) -> Option<WindowFrame> {
|
||||
let window = app_handle.get_webview_window(MAIN_WINDOW_LABEL)?;
|
||||
if !is_normal_window(&window) {
|
||||
return None;
|
||||
}
|
||||
read_window_frame(&window).filter(is_valid_saved_frame)
|
||||
}
|
||||
|
||||
fn is_normal_window(window: &WebviewWindow) -> bool {
|
||||
let is_fullscreen = window.is_fullscreen().unwrap_or(false);
|
||||
let is_maximized = window.is_maximized().unwrap_or(false);
|
||||
let is_minimized = window.is_minimized().unwrap_or(false);
|
||||
!is_fullscreen && !is_maximized && !is_minimized
|
||||
}
|
||||
|
||||
fn read_window_frame(window: &WebviewWindow) -> Option<WindowFrame> {
|
||||
let scale_factor = window_scale_factor(window);
|
||||
let position = window.outer_position().ok()?;
|
||||
let size = window.inner_size().ok()?;
|
||||
Some(WindowFrame::from_logical_geometry(
|
||||
position.to_logical::<f64>(scale_factor),
|
||||
size.to_logical::<f64>(scale_factor),
|
||||
))
|
||||
}
|
||||
|
||||
fn apply_window_frame(window: &WebviewWindow, frame: WindowFrame) -> tauri::Result<()> {
|
||||
window.set_size(Size::Logical(LogicalSize::new(
|
||||
frame.width as f64,
|
||||
frame.height as f64,
|
||||
)))?;
|
||||
window.set_position(Position::Logical(LogicalPosition::new(
|
||||
frame.x as f64,
|
||||
frame.y as f64,
|
||||
)))
|
||||
}
|
||||
|
||||
fn current_screen_areas(window: &WebviewWindow) -> Vec<ScreenArea> {
|
||||
let scale_factor = window_scale_factor(window);
|
||||
window
|
||||
.available_monitors()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|monitor| {
|
||||
let area = monitor.work_area();
|
||||
let position = area.position.to_logical::<f64>(scale_factor);
|
||||
let size = area.size.to_logical::<f64>(scale_factor);
|
||||
ScreenArea {
|
||||
x: rounded_i32(position.x),
|
||||
y: rounded_i32(position.y),
|
||||
width: rounded_u32(size.width),
|
||||
height: rounded_u32(size.height),
|
||||
}
|
||||
})
|
||||
.filter(ScreenArea::has_area)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn window_scale_factor(window: &WebviewWindow) -> f64 {
|
||||
window.scale_factor().unwrap_or(1.0).max(1.0)
|
||||
}
|
||||
|
||||
fn fit_frame_to_screens(frame: WindowFrame, screens: &[ScreenArea]) -> Option<WindowFrame> {
|
||||
if frame_is_visible(frame, screens) {
|
||||
return Some(frame);
|
||||
}
|
||||
|
||||
let screen = best_screen_for_frame(frame, screens)?;
|
||||
let width = clamp_dimension(frame.width, MIN_WINDOW_WIDTH, screen.width);
|
||||
let height = clamp_dimension(frame.height, MIN_WINDOW_HEIGHT, screen.height);
|
||||
Some(WindowFrame {
|
||||
x: clamp_axis(frame.x, width, screen.x, screen.width),
|
||||
y: clamp_axis(frame.y, height, screen.y, screen.height),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn frame_is_visible(frame: WindowFrame, screens: &[ScreenArea]) -> bool {
|
||||
frame_corners(frame)
|
||||
.into_iter()
|
||||
.all(|point| screens.iter().any(|screen| screen.contains(point)))
|
||||
}
|
||||
|
||||
fn frame_corners(frame: WindowFrame) -> [(i32, i32); 4] {
|
||||
let right = frame.right() - 1;
|
||||
let bottom = frame.bottom() - 1;
|
||||
[
|
||||
(frame.x, frame.y),
|
||||
(right, frame.y),
|
||||
(frame.x, bottom),
|
||||
(right, bottom),
|
||||
]
|
||||
}
|
||||
|
||||
fn best_screen_for_frame(frame: WindowFrame, screens: &[ScreenArea]) -> Option<ScreenArea> {
|
||||
screens
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(ScreenArea::has_area)
|
||||
.max_by_key(|screen| intersection_area(frame, *screen))
|
||||
}
|
||||
|
||||
fn intersection_area(frame: WindowFrame, screen: ScreenArea) -> u64 {
|
||||
let left = frame.x.max(screen.x);
|
||||
let top = frame.y.max(screen.y);
|
||||
let right = frame.right().min(screen.right());
|
||||
let bottom = frame.bottom().min(screen.bottom());
|
||||
if right <= left || bottom <= top {
|
||||
return 0;
|
||||
}
|
||||
(right - left) as u64 * (bottom - top) as u64
|
||||
}
|
||||
|
||||
fn clamp_dimension(value: u32, min: u32, max: u32) -> u32 {
|
||||
if max < min {
|
||||
max
|
||||
} else {
|
||||
value.clamp(min, max)
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_axis(value: i32, size: u32, area_start: i32, area_size: u32) -> i32 {
|
||||
let max_start = area_start + area_size as i32 - size as i32;
|
||||
if max_start < area_start {
|
||||
return area_start;
|
||||
}
|
||||
value.clamp(area_start, max_start)
|
||||
}
|
||||
|
||||
fn cache_frame(app_handle: &AppHandle, frame: WindowFrame) {
|
||||
let state: tauri::State<'_, MainWindowFrameState> = app_handle.state();
|
||||
if let Ok(mut cached_frame) = state.0.lock() {
|
||||
*cached_frame = Some(frame);
|
||||
};
|
||||
}
|
||||
|
||||
fn cached_frame(app_handle: &AppHandle) -> Option<WindowFrame> {
|
||||
let state: tauri::State<'_, MainWindowFrameState> = app_handle.state();
|
||||
state.0.lock().ok().and_then(|cached_frame| *cached_frame)
|
||||
}
|
||||
|
||||
fn window_state_path() -> Result<PathBuf, String> {
|
||||
crate::settings::preferred_app_config_path(WINDOW_STATE_FILE)
|
||||
}
|
||||
|
||||
fn read_main_window_frame(scale_factor: f64) -> Option<WindowFrame> {
|
||||
let content = fs::read_to_string(window_state_path().ok()?).ok()?;
|
||||
let persisted: PersistedWindowState = serde_json::from_str(&content).ok()?;
|
||||
persisted
|
||||
.main
|
||||
.map(|frame| {
|
||||
persisted
|
||||
.coordinate_space
|
||||
.to_logical_frame(frame, scale_factor)
|
||||
})
|
||||
.filter(is_valid_saved_frame)
|
||||
}
|
||||
|
||||
fn write_main_window_frame(frame: WindowFrame) -> Result<(), String> {
|
||||
let path = window_state_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create window state directory: {e}"))?;
|
||||
}
|
||||
|
||||
let persisted = PersistedWindowState {
|
||||
main: Some(frame),
|
||||
coordinate_space: WindowFrameCoordinateSpace::Logical,
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&persisted)
|
||||
.map_err(|e| format!("Failed to serialize window state: {e}"))?;
|
||||
fs::write(path, json).map_err(|e| format!("Failed to write window state: {e}"))
|
||||
}
|
||||
|
||||
fn is_valid_saved_frame(frame: &WindowFrame) -> bool {
|
||||
frame.width >= MIN_WINDOW_WIDTH && frame.height >= MIN_WINDOW_HEIGHT
|
||||
}
|
||||
|
||||
fn rounded_i32(value: f64) -> i32 {
|
||||
value.round() as i32
|
||||
}
|
||||
|
||||
fn rounded_u32(value: f64) -> u32 {
|
||||
value.round().max(0.0) as u32
|
||||
}
|
||||
|
||||
impl WindowFrame {
|
||||
fn from_logical_geometry(position: LogicalPosition<f64>, size: LogicalSize<f64>) -> Self {
|
||||
Self {
|
||||
x: rounded_i32(position.x),
|
||||
y: rounded_i32(position.y),
|
||||
width: rounded_u32(size.width),
|
||||
height: rounded_u32(size.height),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_logical(self, scale_factor: f64) -> Self {
|
||||
let scale_factor = scale_factor.max(1.0);
|
||||
Self {
|
||||
x: rounded_i32(self.x as f64 / scale_factor),
|
||||
y: rounded_i32(self.y as f64 / scale_factor),
|
||||
width: rounded_u32(self.width as f64 / scale_factor),
|
||||
height: rounded_u32(self.height as f64 / scale_factor),
|
||||
}
|
||||
}
|
||||
|
||||
fn right(self) -> i32 {
|
||||
self.x + self.width as i32
|
||||
}
|
||||
|
||||
fn bottom(self) -> i32 {
|
||||
self.y + self.height as i32
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowFrameCoordinateSpace {
|
||||
fn to_logical_frame(self, frame: WindowFrame, scale_factor: f64) -> WindowFrame {
|
||||
match self {
|
||||
Self::Logical => frame,
|
||||
Self::Physical => frame.to_logical(scale_factor),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreenArea {
|
||||
fn right(self) -> i32 {
|
||||
self.x + self.width as i32
|
||||
}
|
||||
|
||||
fn bottom(self) -> i32 {
|
||||
self.y + self.height as i32
|
||||
}
|
||||
|
||||
fn has_area(&self) -> bool {
|
||||
self.width > 0 && self.height > 0
|
||||
}
|
||||
|
||||
fn contains(&self, point: (i32, i32)) -> bool {
|
||||
let (x, y) = point;
|
||||
x >= self.x && x < self.right() && y >= self.y && y < self.bottom()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn frame(x: i32, y: i32, width: u32, height: u32) -> WindowFrame {
|
||||
WindowFrame {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
fn screen(x: i32, y: i32, width: u32, height: u32) -> ScreenArea {
|
||||
ScreenArea {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_logical_window_geometry_for_persistence() {
|
||||
let saved = WindowFrame::from_logical_geometry(
|
||||
LogicalPosition::new(80.0, 120.0),
|
||||
LogicalSize::new(1100.0, 700.0),
|
||||
);
|
||||
|
||||
assert_eq!(saved, frame(80, 120, 1100, 700));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_legacy_physical_frames_to_logical_points() {
|
||||
let saved = frame(160, 240, 2200, 1400);
|
||||
|
||||
assert_eq!(
|
||||
WindowFrameCoordinateSpace::Physical.to_logical_frame(saved, 2.0),
|
||||
frame(80, 120, 1100, 700)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_explicit_logical_frames_unscaled() {
|
||||
let saved = frame(80, 120, 1100, 700);
|
||||
|
||||
assert_eq!(
|
||||
WindowFrameCoordinateSpace::Logical.to_logical_frame(saved, 2.0),
|
||||
saved
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_valid_frame_unchanged() {
|
||||
let saved = frame(120, 80, 1400, 900);
|
||||
let screens = [screen(0, 0, 1920, 1080)];
|
||||
|
||||
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamps_oversized_frame_to_current_work_area() {
|
||||
let saved = frame(-100, -80, 2600, 1800);
|
||||
let screens = [screen(0, 0, 1440, 900)];
|
||||
|
||||
assert_eq!(
|
||||
fit_frame_to_screens(saved, &screens),
|
||||
Some(frame(0, 0, 1440, 900))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_offscreen_frame_back_to_a_visible_screen() {
|
||||
let saved = frame(3200, 1800, 900, 700);
|
||||
let screens = [screen(0, 0, 1440, 900)];
|
||||
|
||||
assert_eq!(
|
||||
fit_frame_to_screens(saved, &screens),
|
||||
Some(frame(540, 200, 900, 700))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_screen_with_the_largest_visible_overlap() {
|
||||
let saved = frame(1700, 100, 900, 700);
|
||||
let screens = [screen(0, 0, 1920, 1080), screen(1920, 0, 1440, 900)];
|
||||
|
||||
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_empty_screen_areas_when_restoring() {
|
||||
let saved = frame(100, 100, 800, 600);
|
||||
let screens = [screen(0, 0, 0, 900), screen(0, 0, 1440, 900)];
|
||||
|
||||
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_usable_screens_exist() {
|
||||
let saved = frame(100, 100, 800, 600);
|
||||
|
||||
assert_eq!(fit_frame_to_screens(saved, &[]), None);
|
||||
assert_eq!(fit_frame_to_screens(saved, &[screen(0, 0, 0, 0)]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fits_to_tiny_work_area_when_it_is_smaller_than_minimum_size() {
|
||||
let saved = frame(100, 100, 800, 600);
|
||||
let screens = [screen(0, 0, 320, 240)];
|
||||
|
||||
assert_eq!(
|
||||
fit_frame_to_screens(saved, &screens),
|
||||
Some(frame(0, 0, 320, 240))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_visibility_across_adjacent_screens() {
|
||||
let screens = [screen(0, 0, 1920, 1080), screen(1920, 0, 1440, 900)];
|
||||
|
||||
assert!(frame_is_visible(frame(1700, 100, 900, 700), &screens));
|
||||
assert!(!frame_is_visible(frame(1700, 850, 900, 300), &screens));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computes_frame_and_screen_edges_for_overlap_checks() {
|
||||
let saved = frame(10, 20, 800, 600);
|
||||
let area = screen(0, 0, 500, 400);
|
||||
|
||||
assert_eq!(saved.right(), 810);
|
||||
assert_eq!(saved.bottom(), 620);
|
||||
assert_eq!(area.right(), 500);
|
||||
assert_eq!(area.bottom(), 400);
|
||||
assert_eq!(intersection_area(saved, area), 490 * 380);
|
||||
assert_eq!(intersection_area(saved, screen(900, 900, 200, 200)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_corrupted_tiny_saved_frames() {
|
||||
assert!(!is_valid_saved_frame(&frame(100, 100, 1, 900)));
|
||||
assert!(!is_valid_saved_frame(&frame(100, 100, 1400, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restores_again_after_runtime_ready() {
|
||||
assert!(restores_window_frame_after_runtime_ready(&RunEvent::Ready));
|
||||
assert!(!restores_window_frame_after_runtime_ready(
|
||||
&RunEvent::Resumed
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"backgroundColor": "#F7F6F3",
|
||||
"dragDropEnabled": false
|
||||
"dragDropEnabled": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -33,6 +33,8 @@
|
||||
"connect-src": "'self' ipc: http://ipc.localhost ws://localhost:9710 ws://127.0.0.1:9710 ws://localhost:9711 ws://127.0.0.1:9711 https:",
|
||||
"img-src": "'self' asset: http://asset.localhost data: blob: https:",
|
||||
"style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"style-src-elem": "'self' 'nonce-tolaria-runtime-style' https://fonts.googleapis.com",
|
||||
"style-src-attr": "'unsafe-inline'",
|
||||
"font-src": "'self' data: https://fonts.gstatic.com",
|
||||
"media-src": "'self' data: blob: https:",
|
||||
"object-src": "'none'"
|
||||
@@ -47,6 +49,11 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "downloadBootstrapper"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"resources/mcp-server/**/*": "mcp-server/"
|
||||
},
|
||||
@@ -63,6 +70,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 {
|
||||
|
||||
276
src/App.test.tsx
276
src/App.test.tsx
@@ -2,6 +2,8 @@ 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'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
// Provide a localStorage mock that supports all methods (jsdom's may be incomplete)
|
||||
const localStorageMock = (() => {
|
||||
@@ -58,13 +60,30 @@ const mockEntries = [
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
archived: false,
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null,
|
||||
view: null,
|
||||
visible: true,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
},
|
||||
{
|
||||
path: '/vault/topic/dev.md',
|
||||
@@ -75,13 +94,30 @@ const mockEntries = [
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null,
|
||||
view: null,
|
||||
visible: true,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -107,8 +143,12 @@ const mockCommandResults: Record<string, unknown> = {
|
||||
get_all_content: mockAllContent,
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
reload_vault_entry: ({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null,
|
||||
sync_vault_asset_scope_for_window: null,
|
||||
get_file_history: [],
|
||||
get_settings: { auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null },
|
||||
is_git_repo: true,
|
||||
init_git_repo: null,
|
||||
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
|
||||
save_settings: null,
|
||||
check_vault_exists: true,
|
||||
@@ -238,15 +278,20 @@ function resetMockCommandResults() {
|
||||
get_all_content: mockAllContent,
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
reload_vault_entry: ({ path }: { path: string }) => mockEntries.find((entry) => entry.path === path) ?? null,
|
||||
sync_vault_asset_scope_for_window: null,
|
||||
get_file_history: [],
|
||||
get_settings: {
|
||||
auto_pull_interval_minutes: null,
|
||||
auto_advance_inbox_after_organize: null,
|
||||
telemetry_consent: true,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
},
|
||||
is_git_repo: true,
|
||||
init_git_repo: null,
|
||||
save_settings: null,
|
||||
check_vault_exists: true,
|
||||
get_default_vault_path: expectedDefaultVaultPath,
|
||||
@@ -263,10 +308,11 @@ function resolveMockCommandResult(cmd: string, args?: unknown) {
|
||||
}
|
||||
|
||||
vi.mock('./mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
isTauri: vi.fn(() => false),
|
||||
mockInvoke: vi.fn(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args)),
|
||||
addMockEntry: vi.fn(),
|
||||
updateMockContent: vi.fn(),
|
||||
trackMockChange: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock ai-chat utilities
|
||||
@@ -276,6 +322,24 @@ vi.mock('./utils/ai-chat', () => ({
|
||||
streamClaudeChat: vi.fn(async () => 'mock-session'),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks/useUpdater', async () => {
|
||||
const actual = await vi.importActual<typeof import('./hooks/useUpdater')>('./hooks/useUpdater')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useUpdater: vi.fn(() => ({
|
||||
status: { state: 'idle' },
|
||||
actions: {
|
||||
checkForUpdates: vi.fn(async () => ({ kind: 'up-to-date' })),
|
||||
startDownload: vi.fn(),
|
||||
openReleaseNotes: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
})),
|
||||
restartApp: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock BlockNote components (they need DOM APIs not available in jsdom)
|
||||
vi.mock('@blocknote/core', () => ({
|
||||
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
|
||||
@@ -294,6 +358,7 @@ vi.mock('@blocknote/core/extensions', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/react', () => ({
|
||||
createReactBlockSpec: () => () => ({}),
|
||||
createReactInlineContentSpec: () => ({ render: () => null }),
|
||||
BlockNoteViewRaw: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="blocknote-view">
|
||||
@@ -303,6 +368,7 @@ vi.mock('@blocknote/react', () => ({
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
LinkToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
ComponentsContext: {
|
||||
Provider: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
},
|
||||
@@ -315,8 +381,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', () => ({
|
||||
@@ -332,15 +420,35 @@ vi.mock('./components/tolariaEditorFormatting', () => ({
|
||||
}))
|
||||
|
||||
import App from './App'
|
||||
import { useUpdater } from './hooks/useUpdater'
|
||||
import { isTauri } from './mock-tauri'
|
||||
|
||||
const AI_AGENTS_ONBOARDING_DISMISSED_KEY = 'tolaria:ai-agents-onboarding-dismissed'
|
||||
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
|
||||
|
||||
function createMockUpdaterResult(
|
||||
checkForUpdates: () => Promise<{ kind: 'up-to-date' } | { kind: 'available'; version: string; displayVersion: string } | { kind: 'error'; message: string }> = async () => ({ kind: 'up-to-date' }),
|
||||
) {
|
||||
return {
|
||||
status: { state: 'idle' as const },
|
||||
actions: {
|
||||
checkForUpdates,
|
||||
startDownload: vi.fn(),
|
||||
openReleaseNotes: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetMockCommandResults()
|
||||
vi.mocked(invoke).mockImplementation(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args))
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult())
|
||||
localStorage.clear()
|
||||
window.history.replaceState({}, '', '/')
|
||||
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
|
||||
})
|
||||
|
||||
@@ -358,6 +466,31 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the app shell skeleton while the vault note scan is pending', async () => {
|
||||
let resolveListVault: ((value: typeof mockEntries) => void) | null = null
|
||||
const listVaultPromise = new Promise<typeof mockEntries>((resolve) => {
|
||||
resolveListVault = resolve
|
||||
})
|
||||
mockCommandResults.list_vault = () => listVaultPromise
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('vault-loading-skeleton')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('Select a note to start editing')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
resolveListVault?.(mockEntries)
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('vault-loading-skeleton')).not.toBeInTheDocument()
|
||||
expect(screen.getAllByText('Test Project').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows empty state in editor when no note is selected', async () => {
|
||||
render(<App />)
|
||||
await waitFor(() => {
|
||||
@@ -365,10 +498,40 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows keyboard shortcut hints', async () => {
|
||||
it('opens a note window by loading only the requested entry', async () => {
|
||||
const listVault = vi.fn(() => mockEntries)
|
||||
const reloadVaultEntry = vi.fn(({ path }: { path: string }) =>
|
||||
mockEntries.find((entry) => entry.path === path) ?? null,
|
||||
)
|
||||
const getNoteContent = vi.fn(({ path }: { path: string }) => mockAllContent[path] ?? '')
|
||||
mockCommandResults.list_vault = listVault
|
||||
mockCommandResults.reload_vault_entry = reloadVaultEntry
|
||||
mockCommandResults.get_note_content = getNoteContent
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
'/?window=note&path=%2Fvault%2Fproject%2Ftest.md&vault=%2Fvault&title=Test+Project',
|
||||
)
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => expect(reloadVaultEntry).toHaveBeenCalled())
|
||||
expect(reloadVaultEntry).toHaveBeenCalledWith({ path: '/vault/project/test.md', vaultPath: '/vault' })
|
||||
await waitFor(() => expect(getNoteContent).toHaveBeenCalled())
|
||||
expect(getNoteContent).toHaveBeenCalledWith({ path: '/vault/project/test.md', vaultPath: '/vault' })
|
||||
await waitFor(() => expect(window.__laputaTest?.activeTabPath).toBe('/vault/project/test.md'))
|
||||
expect(listVault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows keyboard shortcut hints', async () => {
|
||||
const quickOpenHint = formatShortcutDisplay({ display: '⌘P / ⌘O' })
|
||||
const newNoteHint = formatShortcutDisplay({ display: '⌘N' })
|
||||
const { container } = render(<App />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cmd\+P or Cmd\+O to search/)).toBeInTheDocument()
|
||||
const shortcutHint = Array.from(container.querySelectorAll('span.text-xs.text-muted-foreground'))
|
||||
.find((element) => element.textContent === `${quickOpenHint} to search · ${newNoteHint} to create`)
|
||||
|
||||
expect(shortcutHint).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -385,6 +548,45 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows visible feedback when a manual update check finds an update', async () => {
|
||||
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(async () => ({
|
||||
kind: 'available',
|
||||
version: '2026.4.25',
|
||||
displayVersion: '2026.4.25',
|
||||
})))
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-build-number'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tolaria 2026.4.25 is available')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows visible feedback when a menu-driven update check finds no eligible update', async () => {
|
||||
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(async () => ({ kind: 'up-to-date' })))
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(typeof window.__laputaTest?.dispatchBrowserMenuCommand).toBe('function')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
window.__laputaTest?.dispatchBrowserMenuCommand?.('app-check-for-updates')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No newer stable update is available right now')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the external AI setup dialog from the menu when AI onboarding is active', async () => {
|
||||
localStorage.removeItem(AI_AGENTS_ONBOARDING_DISMISSED_KEY)
|
||||
localStorage.removeItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY)
|
||||
@@ -496,7 +698,7 @@ describe('App', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('vault-loading-skeleton')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Vault not found')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
@@ -654,7 +856,7 @@ describe('App', () => {
|
||||
|
||||
render(<App />)
|
||||
|
||||
const noteListContainer = await screen.findByTestId('note-list-container')
|
||||
const noteListContainer = await screen.findByTestId('note-list-container', {}, { timeout: 5000 })
|
||||
const getHeader = () => getHeaderForNoteList(noteListContainer)
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -740,6 +942,44 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('auto-advances to the next inbox item after organizing when the setting is enabled', async () => {
|
||||
configureNeighborhoodVault()
|
||||
mockCommandResults.get_settings = {
|
||||
auto_pull_interval_minutes: null,
|
||||
auto_advance_inbox_after_organize: true,
|
||||
telemetry_consent: true,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
render(<App />)
|
||||
|
||||
const noteListContainer = await screen.findByTestId('note-list-container')
|
||||
await waitFor(() => {
|
||||
expect(getHeaderForNoteList(noteListContainer)).toHaveTextContent('Inbox')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(noteListContainer).getByText('Alpha'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Set note as organized' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Set note as organized' }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.__laputaTest?.activeTabPath).toBe('/vault/beta.md')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders status bar', async () => {
|
||||
render(<App />)
|
||||
// StatusBar should be present
|
||||
@@ -775,6 +1015,32 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the Git setup dialog when switching to a Git-enabled vault', async () => {
|
||||
mockCommandResults.load_vault_list = {
|
||||
vaults: [
|
||||
{ label: 'Missing Git', path: '/work' },
|
||||
{ label: 'Git Vault', path: '/vault-2' },
|
||||
],
|
||||
active_vault: '/work',
|
||||
hidden_defaults: [],
|
||||
}
|
||||
mockCommandResults.is_git_repo = ({ vaultPath }: { vaultPath?: string } = {}) => vaultPath === '/vault-2'
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByText('Enable Git for this vault?')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-vault-trigger'))
|
||||
fireEvent.click(screen.getByTestId('vault-menu-item-Git Vault'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Git Vault')
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Enable Git for this vault?')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('Cmd+1 hides sidebar and note list (editor-only mode)', async () => {
|
||||
render(<App />)
|
||||
await waitFor(() => {
|
||||
|
||||
342
src/App.tsx
342
src/App.tsx
@@ -16,12 +16,12 @@ import { StatusBar } from './components/StatusBar'
|
||||
import { SettingsPanel } from './components/SettingsPanel'
|
||||
import { CloneVaultModal } from './components/CloneVaultModal'
|
||||
import { WelcomeScreen } from './components/WelcomeScreen'
|
||||
import { AppLoadingSkeleton } from './components/AppLoadingSkeleton'
|
||||
import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt'
|
||||
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
|
||||
import { FeedbackDialog } from './components/FeedbackDialog'
|
||||
import { McpSetupDialog } from './components/McpSetupDialog'
|
||||
import { NoteRetargetingDialogs } from './components/note-retargeting/NoteRetargetingDialogs'
|
||||
import { NoteRetargetingProvider } from './components/note-retargeting/noteRetargetingContext'
|
||||
import { useTelemetry } from './hooks/useTelemetry'
|
||||
import { useMcpStatus } from './hooks/useMcpStatus'
|
||||
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
|
||||
@@ -31,11 +31,14 @@ 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'
|
||||
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
|
||||
import { useViewMode, type ViewMode } from './hooks/useViewMode'
|
||||
import { useNoteLayout } from './hooks/useNoteLayout'
|
||||
import { useEntryActions } from './hooks/useEntryActions'
|
||||
import { useAppCommands } from './hooks/useAppCommands'
|
||||
import { triggerCommitEntryAction } from './utils/commitEntryAction'
|
||||
@@ -62,6 +65,7 @@ import { useAiActivity } from './hooks/useAiActivity'
|
||||
import { useBulkActions } from './hooks/useBulkActions'
|
||||
import { useDeleteActions } from './hooks/useDeleteActions'
|
||||
import { useFolderActions } from './hooks/useFolderActions'
|
||||
import { useFileActions } from './hooks/useFileActions'
|
||||
import { useLayoutPanels } from './hooks/useLayoutPanels'
|
||||
import { useConflictFlow } from './hooks/useConflictFlow'
|
||||
import { useAppSave } from './hooks/useAppSave'
|
||||
@@ -80,13 +84,21 @@ import { initializeNoteProperties } from './utils/initializeNoteProperties'
|
||||
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
||||
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
|
||||
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, findNoteWindowEntry, type NoteWindowParams } from './utils/windowMode'
|
||||
import { GitRequiredModal } from './components/GitRequiredModal'
|
||||
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, type NoteWindowParams } from './utils/windowMode'
|
||||
import { GitSetupDialog } from './components/GitRequiredModal'
|
||||
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
|
||||
import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents'
|
||||
import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands'
|
||||
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import {
|
||||
SYSTEM_UI_LANGUAGE,
|
||||
getBrowserLanguagePreferences,
|
||||
resolveEffectiveLocale,
|
||||
serializeUiLanguagePreference,
|
||||
type UiLanguagePreference,
|
||||
} from './lib/i18n'
|
||||
import { normalizeReleaseChannel } from './lib/releaseChannel'
|
||||
import {
|
||||
buildVaultAiGuidanceRefreshKey,
|
||||
} from './lib/vaultAiGuidance'
|
||||
@@ -120,6 +132,12 @@ declare global {
|
||||
|
||||
const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION
|
||||
|
||||
function getNextVisibleInboxEntry(entries: VaultEntry[], currentPath: string): VaultEntry | null {
|
||||
const currentIndex = entries.findIndex((entry) => entry.path === currentPath)
|
||||
if (currentIndex < 0) return null
|
||||
return entries[currentIndex + 1] ?? null
|
||||
}
|
||||
|
||||
function shouldPreferOnboardingVaultPath(
|
||||
onboardingState: { status: string; vaultPath?: string },
|
||||
vaults: Array<{ path: string }>,
|
||||
@@ -130,26 +148,26 @@ function shouldPreferOnboardingVaultPath(
|
||||
&& !vaults.some((vault) => vault.path === onboardingState.vaultPath)
|
||||
}
|
||||
|
||||
async function resolveNoteWindowEntry(
|
||||
noteWindowParams: NoteWindowParams,
|
||||
entries: VaultEntry[],
|
||||
): Promise<VaultEntry | undefined> {
|
||||
const fallbackEntry = () =>
|
||||
findNoteWindowEntry(entries, noteWindowParams)
|
||||
|
||||
if (!isTauri()) {
|
||||
return fallbackEntry()
|
||||
}
|
||||
|
||||
async function resolveNoteWindowEntry(noteWindowParams: NoteWindowParams): Promise<VaultEntry | undefined> {
|
||||
for (const path of getNoteWindowPathCandidates(noteWindowParams)) {
|
||||
try {
|
||||
return await invoke<VaultEntry>('reload_vault_entry', { path })
|
||||
const request = { path, vaultPath: noteWindowParams.vaultPath }
|
||||
const entry = isTauri()
|
||||
? await invoke<VaultEntry | null>('reload_vault_entry', request)
|
||||
: await mockInvoke<VaultEntry | null>('reload_vault_entry', request)
|
||||
if (entry) return entry
|
||||
} catch {
|
||||
// Try the next normalized candidate before falling back to the scanned entries.
|
||||
// Try the next normalized candidate before reporting the note as unavailable.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackEntry()
|
||||
async function loadNoteWindowContent(path: string, vaultPath: string): Promise<string> {
|
||||
const request = { path, vaultPath }
|
||||
if (!isTauri()) return mockInvoke<string>('get_note_content', request)
|
||||
|
||||
await invoke('sync_vault_asset_scope_for_window', { vaultPath })
|
||||
return invoke<string>('get_note_content', request)
|
||||
}
|
||||
|
||||
function createPulseDeletedNoteEntry(fullPath: string, relativePath: string): DeletedNoteEntry {
|
||||
@@ -245,7 +263,11 @@ function App() {
|
||||
// called on user interaction, never during render (refs inside the hook
|
||||
// guarantee the latest closure is always used).
|
||||
const vaultSwitcher = useVaultSwitcher({
|
||||
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
|
||||
onSwitch: () => {
|
||||
if (noteWindowParams) return
|
||||
handleSetSelection(DEFAULT_SELECTION)
|
||||
notes.closeAllTabs()
|
||||
},
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
const {
|
||||
@@ -298,25 +320,55 @@ function App() {
|
||||
? onboarding.state.vaultPath
|
||||
: vaultSwitcher.vaultPath
|
||||
)
|
||||
// Git repo check: 'checking' | 'required' | 'ready'
|
||||
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
|
||||
// Git repo check: 'checking' | 'missing' | 'ready'
|
||||
const [gitRepoState, setGitRepoState] = useState<'checking' | 'missing' | 'ready'>('checking')
|
||||
const [showGitSetupDialog, setShowGitSetupDialog] = useState(false)
|
||||
const dismissedGitSetupPathRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!resolvedPath) return
|
||||
setGitRepoState('checking')
|
||||
const check = isTauri()
|
||||
? invoke<boolean>('is_git_repo', { vaultPath: resolvedPath })
|
||||
: Promise.resolve(true) // browser mock: assume git
|
||||
: mockInvoke<boolean>('is_git_repo', { vaultPath: resolvedPath })
|
||||
check
|
||||
.then(isGit => setGitRepoState(isGit ? 'ready' : 'required'))
|
||||
.then(isGit => setGitRepoState(isGit ? 'ready' : 'missing'))
|
||||
.catch(() => setGitRepoState('ready')) // fail open
|
||||
}, [resolvedPath])
|
||||
|
||||
const handleInitGitRepo = useCallback(async () => {
|
||||
if (isTauri()) await invoke('init_git_repo', { vaultPath: resolvedPath })
|
||||
setGitRepoState('ready')
|
||||
useEffect(() => {
|
||||
if (noteWindowParams || gitRepoState !== 'missing' || !resolvedPath) return
|
||||
if (dismissedGitSetupPathRef.current === resolvedPath) return
|
||||
setShowGitSetupDialog(true)
|
||||
}, [gitRepoState, noteWindowParams, resolvedPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (gitRepoState === 'missing') return
|
||||
setShowGitSetupDialog(false)
|
||||
}, [gitRepoState])
|
||||
|
||||
const openGitSetupDialog = useCallback(() => {
|
||||
if (gitRepoState !== 'missing') return
|
||||
setShowGitSetupDialog(true)
|
||||
}, [gitRepoState])
|
||||
|
||||
const dismissGitSetupDialog = useCallback(() => {
|
||||
dismissedGitSetupPathRef.current = resolvedPath
|
||||
setShowGitSetupDialog(false)
|
||||
}, [resolvedPath])
|
||||
|
||||
const vault = useVaultLoader(resolvedPath)
|
||||
const handleInitGitRepo = useCallback(async () => {
|
||||
if (isTauri()) {
|
||||
await invoke('init_git_repo', { vaultPath: resolvedPath })
|
||||
} else {
|
||||
await mockInvoke('init_git_repo', { vaultPath: resolvedPath })
|
||||
}
|
||||
setGitRepoState('ready')
|
||||
dismissedGitSetupPathRef.current = null
|
||||
setShowGitSetupDialog(false)
|
||||
setToastMessage('Git initialized for this vault')
|
||||
}, [resolvedPath, setToastMessage])
|
||||
|
||||
const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath)
|
||||
const {
|
||||
status: vaultAiGuidanceStatus,
|
||||
refresh: refreshVaultAiGuidance,
|
||||
@@ -361,6 +413,27 @@ function App() {
|
||||
})
|
||||
}, [updateConfig, vaultConfig.inbox?.noteListProperties])
|
||||
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
||||
const systemLocale = useMemo(
|
||||
() => resolveEffectiveLocale(SYSTEM_UI_LANGUAGE, getBrowserLanguagePreferences()),
|
||||
[],
|
||||
)
|
||||
const appLocale = useMemo(
|
||||
() => resolveEffectiveLocale(settings.ui_language, [systemLocale]),
|
||||
[settings.ui_language, systemLocale],
|
||||
)
|
||||
const selectedUiLanguage = settings.ui_language ?? SYSTEM_UI_LANGUAGE
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = appLocale
|
||||
}, [appLocale])
|
||||
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 handleSetUiLanguage = useCallback((uiLanguage: UiLanguagePreference) => {
|
||||
void saveSettings({ ...settings, ui_language: serializeUiLanguagePreference(uiLanguage) })
|
||||
}, [saveSettings, settings])
|
||||
const aiAgentPreferences = useAiAgentPreferences({
|
||||
settings,
|
||||
saveSettings,
|
||||
@@ -378,6 +451,14 @@ function App() {
|
||||
}, [vault.entries.length, gitRepoState, resolvedPath])
|
||||
const { mcpStatus, connectMcp, disconnectMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
|
||||
const loadVaultModifiedFiles = vault.loadModifiedFiles
|
||||
const refreshGitRemoteStatus = gitRemoteStatus.refreshRemoteStatus
|
||||
|
||||
useEffect(() => {
|
||||
if (gitRepoState !== 'ready') return
|
||||
void loadVaultModifiedFiles()
|
||||
void refreshGitRemoteStatus()
|
||||
}, [gitRepoState, loadVaultModifiedFiles, refreshGitRemoteStatus])
|
||||
|
||||
const openMcpSetupDialog = useCallback(() => {
|
||||
setShowMcpSetupDialog(true)
|
||||
@@ -415,7 +496,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)
|
||||
@@ -470,6 +551,7 @@ function App() {
|
||||
unsavedPaths: vault.unsavedPaths,
|
||||
markContentPending: (path, content) => appSave.contentChangeRef.current(path, content),
|
||||
onNewNotePersisted: vault.loadModifiedFiles,
|
||||
onTypeStateChanged: async () => { await vault.reloadVault() },
|
||||
replaceEntry: vault.replaceEntry,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath),
|
||||
@@ -480,6 +562,10 @@ function App() {
|
||||
closeAllTabs,
|
||||
openTabWithContent,
|
||||
} = notes
|
||||
const noteWindowActionsRef = useRef({ handleSelectNote, openTabWithContent })
|
||||
useEffect(() => {
|
||||
noteWindowActionsRef.current = { handleSelectNote, openTabWithContent }
|
||||
}, [handleSelectNote, openTabWithContent])
|
||||
const handlePulledVaultUpdate = useCallback(async (updatedFiles: string[]) => {
|
||||
await refreshPulledVaultState({
|
||||
activeTabPath: notes.activeTabPath,
|
||||
@@ -503,6 +589,7 @@ function App() {
|
||||
vault.unsavedPaths,
|
||||
])
|
||||
const autoSync = useAutoSync({
|
||||
enabled: gitRepoState === 'ready',
|
||||
vaultPath: resolvedPath,
|
||||
intervalMinutes: settings.auto_pull_interval_minutes,
|
||||
onVaultUpdated: handlePulledVaultUpdate,
|
||||
@@ -512,33 +599,37 @@ function App() {
|
||||
},
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
const pulseCommitDiffRequestIdRef = useRef(0)
|
||||
const [pulseCommitDiffRequest, setPulseCommitDiffRequest] = useState<CommitDiffRequest | null>(null)
|
||||
const pendingDiffRequestIdRef = useRef(0)
|
||||
const [pendingDiffRequest, setPendingDiffRequest] = useState<CommitDiffRequest | null>(null)
|
||||
|
||||
// Note window: auto-open the note from URL params once vault entries load
|
||||
// Note window: auto-open the note from URL params without scanning the whole vault.
|
||||
const noteWindowOpenedRef = useRef(false)
|
||||
const noteWindowMissingPathRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!noteWindowParams || noteWindowOpenedRef.current) return
|
||||
let cancelled = false
|
||||
|
||||
void resolveNoteWindowEntry(noteWindowParams, vault.entries).then((entry) => {
|
||||
if (cancelled || noteWindowOpenedRef.current) return
|
||||
void resolveNoteWindowEntry(noteWindowParams).then(async (entry) => {
|
||||
if (noteWindowOpenedRef.current) return
|
||||
if (entry) {
|
||||
noteWindowOpenedRef.current = true
|
||||
noteWindowMissingPathRef.current = null
|
||||
void handleSelectNote(entry)
|
||||
try {
|
||||
const content = await loadNoteWindowContent(entry.path, noteWindowParams.vaultPath)
|
||||
if (noteWindowOpenedRef.current) return
|
||||
noteWindowOpenedRef.current = true
|
||||
noteWindowMissingPathRef.current = null
|
||||
noteWindowActionsRef.current.openTabWithContent(entry, content)
|
||||
} catch {
|
||||
if (noteWindowOpenedRef.current) return
|
||||
noteWindowOpenedRef.current = true
|
||||
noteWindowMissingPathRef.current = null
|
||||
void noteWindowActionsRef.current.handleSelectNote(entry)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (noteWindowMissingPathRef.current === noteWindowParams.notePath) return
|
||||
noteWindowMissingPathRef.current = noteWindowParams.notePath
|
||||
setToastMessage(`Could not open "${noteWindowParams.noteTitle}" in this window`)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [handleSelectNote, noteWindowParams, setToastMessage, vault.entries])
|
||||
}, [noteWindowParams, setToastMessage])
|
||||
|
||||
// Note window: update window title when active note changes
|
||||
useEffect(() => {
|
||||
@@ -548,7 +639,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)
|
||||
@@ -574,17 +665,17 @@ function App() {
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
})
|
||||
|
||||
const queuePulseCommitDiff = useCallback((path: string, commitHash: string) => {
|
||||
pulseCommitDiffRequestIdRef.current += 1
|
||||
setPulseCommitDiffRequest({
|
||||
requestId: pulseCommitDiffRequestIdRef.current,
|
||||
const queuePendingDiff = useCallback((path: string, commitHash?: string) => {
|
||||
pendingDiffRequestIdRef.current += 1
|
||||
setPendingDiffRequest({
|
||||
requestId: pendingDiffRequestIdRef.current,
|
||||
path,
|
||||
commitHash,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePulseCommitDiffHandled = useCallback((requestId: number) => {
|
||||
setPulseCommitDiffRequest((current) =>
|
||||
const handlePendingDiffHandled = useCallback((requestId: number) => {
|
||||
setPendingDiffRequest((current) =>
|
||||
current?.requestId === requestId ? null : current,
|
||||
)
|
||||
}, [])
|
||||
@@ -595,7 +686,7 @@ function App() {
|
||||
|
||||
if (commitHash) {
|
||||
const targetPath = entry?.path ?? fullPath
|
||||
queuePulseCommitDiff(targetPath, commitHash)
|
||||
queuePendingDiff(targetPath, commitHash)
|
||||
if (entry) {
|
||||
void handleSelectNote(entry)
|
||||
} else {
|
||||
@@ -607,7 +698,7 @@ function App() {
|
||||
if (entry) {
|
||||
void handleSelectNote(entry)
|
||||
}
|
||||
}, [entriesByPath, resolvedPath, queuePulseCommitDiff, handleSelectNote, openTabWithContent])
|
||||
}, [entriesByPath, resolvedPath, queuePendingDiff, handleSelectNote, openTabWithContent])
|
||||
|
||||
const handleOpenFavorite = useCallback(async (entry: VaultEntry) => {
|
||||
await handleReplaceActiveTab(entry)
|
||||
@@ -734,6 +825,11 @@ function App() {
|
||||
reloadFolders: vault.reloadFolders,
|
||||
setToastMessage,
|
||||
})
|
||||
const fileActions = useFileActions({
|
||||
selection: effectiveSelection,
|
||||
setToastMessage,
|
||||
vaultPath: resolvedPath,
|
||||
})
|
||||
|
||||
const handleRemoveNoteIconCommand = useCallback(() => {
|
||||
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
|
||||
@@ -786,11 +882,18 @@ function App() {
|
||||
}
|
||||
notes.openTabWithContent(entry, previewContent)
|
||||
if (hasDiff) {
|
||||
setTimeout(() => diffToggleRef.current(), 50)
|
||||
queuePendingDiff(entry.path)
|
||||
} else {
|
||||
setToastMessage('Content not available (untracked)')
|
||||
}
|
||||
}, [vault, notes, setToastMessage])
|
||||
}, [vault, notes, queuePendingDiff, setToastMessage])
|
||||
|
||||
const handleReplaceActiveTabWithQueuedDiff = useCallback((entry: VaultEntry) => {
|
||||
notes.handleReplaceActiveTab(entry)
|
||||
if (effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'changes') {
|
||||
queuePendingDiff(entry.path)
|
||||
}
|
||||
}, [effectiveSelection, notes, queuePendingDiff])
|
||||
|
||||
const commitFlow = useCommitFlow({
|
||||
savePending: appSave.savePending,
|
||||
@@ -801,7 +904,8 @@ function App() {
|
||||
vaultPath: resolvedPath,
|
||||
})
|
||||
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
|
||||
const isGitVault = !vault.modifiedFilesError
|
||||
const isGitVault = gitRepoState !== 'missing'
|
||||
const shouldShowGitSetupDialog = !noteWindowParams && gitRepoState === 'missing' && showGitSetupDialog
|
||||
const modifiedFilesSignature = useMemo(
|
||||
() => vault.modifiedFiles.map((file) => `${file.relativePath}:${file.status}`).sort().join('|'),
|
||||
[vault.modifiedFiles],
|
||||
@@ -875,6 +979,7 @@ function App() {
|
||||
useEffect(() => {
|
||||
window.__laputaTest = {
|
||||
...window.__laputaTest,
|
||||
activeTabPath: notes.activeTabPath,
|
||||
seedAutoGitSavedChange,
|
||||
}
|
||||
|
||||
@@ -883,7 +988,7 @@ function App() {
|
||||
delete window.__laputaTest.seedAutoGitSavedChange
|
||||
}
|
||||
}
|
||||
}, [seedAutoGitSavedChange])
|
||||
}, [notes.activeTabPath, seedAutoGitSavedChange])
|
||||
|
||||
const entryActions = useEntryActions({
|
||||
entries: vault.entries, updateEntry: vault.updateEntry,
|
||||
@@ -1007,8 +1112,10 @@ function App() {
|
||||
const rawToggleRef = useRef<() => void>(() => {})
|
||||
// Diff-toggle ref: Editor registers its handleToggleDiff here so the command palette can call it
|
||||
const diffToggleRef = useRef<() => void>(() => {})
|
||||
const findInNoteRef = useRef<((options?: { replace?: boolean }) => void) | null>(null)
|
||||
|
||||
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode(noteWindowParams ? 'editor-only' : undefined)
|
||||
const { noteLayout, toggleNoteLayout } = useNoteLayout()
|
||||
const zoom = useZoom()
|
||||
const buildNumber = useBuildNumber()
|
||||
|
||||
@@ -1025,7 +1132,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) => {
|
||||
@@ -1063,12 +1170,15 @@ function App() {
|
||||
return
|
||||
}
|
||||
const result = await updateActions.checkForUpdates()
|
||||
if (result === 'up-to-date') {
|
||||
setToastMessage("You're on the latest version")
|
||||
} else if (result === 'error') {
|
||||
setToastMessage('Could not check for updates')
|
||||
if (result.kind === 'up-to-date') {
|
||||
const checkedChannel = normalizeReleaseChannel(settings.release_channel)
|
||||
setToastMessage(`No newer ${checkedChannel} update is available right now`)
|
||||
} else if (result.kind === 'available') {
|
||||
setToastMessage(`Tolaria ${result.displayVersion} is available`)
|
||||
} else {
|
||||
setToastMessage(result.message)
|
||||
}
|
||||
}, [updateActions, updateStatus.state, setToastMessage])
|
||||
}, [settings.release_channel, updateActions, updateStatus.state, setToastMessage])
|
||||
|
||||
const handleRepairVault = useCallback(async () => {
|
||||
if (!resolvedPath) return
|
||||
@@ -1185,6 +1295,12 @@ function App() {
|
||||
() => canToggleRichEditor ? () => rawToggleRef.current() : undefined,
|
||||
[canToggleRichEditor],
|
||||
)
|
||||
const findInNoteCommand = useCallback(() => {
|
||||
findInNoteRef.current?.({ replace: false })
|
||||
}, [])
|
||||
const replaceInNoteCommand = useCallback(() => {
|
||||
findInNoteRef.current?.({ replace: true })
|
||||
}, [])
|
||||
const removeActiveVaultCommand = useCallback(() => {
|
||||
vaultSwitcher.removeVault(vaultSwitcher.vaultPath)
|
||||
}, [vaultSwitcher])
|
||||
@@ -1203,7 +1319,26 @@ function App() {
|
||||
const entry = vault.entries.find((candidate) => candidate.path === notes.activeTabPath)
|
||||
return hasNoteIconValue(entry?.icon)
|
||||
}, [notes.activeTabPath, vault.entries])
|
||||
const toggleOrganizedCommand = explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined
|
||||
const handleToggleOrganizedWithInboxAdvance = useCallback(async (path: string) => {
|
||||
const entry = vault.entries.find((candidate) => candidate.path === path)
|
||||
if (!entry) return
|
||||
|
||||
const shouldAutoAdvance = settings.auto_advance_inbox_after_organize === true
|
||||
&& !entry.organized
|
||||
&& notes.activeTabPath === path
|
||||
&& effectiveSelection.kind === 'filter'
|
||||
&& effectiveSelection.filter === 'inbox'
|
||||
const nextVisibleInboxEntry = shouldAutoAdvance
|
||||
? getNextVisibleInboxEntry(visibleNotesRef.current, path)
|
||||
: null
|
||||
|
||||
const organized = await entryActions.handleToggleOrganized(path)
|
||||
|
||||
if (organized && nextVisibleInboxEntry) {
|
||||
void notes.handleSelectNote(nextVisibleInboxEntry)
|
||||
}
|
||||
}, [effectiveSelection, entryActions, notes, settings.auto_advance_inbox_after_organize, vault.entries])
|
||||
const toggleOrganizedCommand = explicitOrganizationEnabled ? handleToggleOrganizedWithInboxAdvance : undefined
|
||||
const canCustomizeNoteListColumns = useMemo(() => (
|
||||
effectiveSelection.kind === 'view'
|
||||
|| (
|
||||
@@ -1226,6 +1361,8 @@ function App() {
|
||||
selection: effectiveSelection,
|
||||
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
|
||||
onSearch: dialogs.openSearch,
|
||||
onFindInNote: findInNoteCommand,
|
||||
onReplaceInNote: activeDeletedFile ? undefined : replaceInNoteCommand,
|
||||
onCreateNote: notes.handleCreateNoteImmediate,
|
||||
onCreateNoteOfType: notes.handleCreateNoteImmediate,
|
||||
onSave: appSave.handleSave,
|
||||
@@ -1234,17 +1371,23 @@ function App() {
|
||||
onDeleteNote: deleteActions.handleDeleteNote,
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: handleCommitPush,
|
||||
isGitVault,
|
||||
onInitializeGit: openGitSetupDialog,
|
||||
onPull: autoSync.triggerSync,
|
||||
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
|
||||
onSetViewMode: handleSetViewMode,
|
||||
onToggleInspector: handleToggleInspector,
|
||||
onToggleDiff: toggleDiffCommand,
|
||||
onToggleRawEditor: toggleRawEditorCommand,
|
||||
noteLayout,
|
||||
onToggleNoteLayout: toggleNoteLayout,
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
zoomLevel: zoom.zoomLevel,
|
||||
onSelect: handleSetSelection,
|
||||
onRenameFolder: folderActions.renameSelectedFolder,
|
||||
onDeleteFolder: folderActions.deleteSelectedFolder,
|
||||
onRevealSelectedFolder: fileActions.revealSelectedFolder,
|
||||
onCopySelectedFolderPath: fileActions.copySelectedFolderPath,
|
||||
showInbox: explicitOrganizationEnabled,
|
||||
onReplaceActiveTab: notes.handleReplaceActiveTab,
|
||||
onSelectNote: notes.handleSelectNote,
|
||||
@@ -1259,6 +1402,10 @@ function App() {
|
||||
onRestoreGettingStarted: cloneGettingStartedVault,
|
||||
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
|
||||
vaultCount: vaultSwitcher.allVaults.length,
|
||||
locale: appLocale,
|
||||
systemLocale,
|
||||
selectedUiLanguage,
|
||||
onSetUiLanguage: handleSetUiLanguage,
|
||||
mcpStatus,
|
||||
onInstallMcp: openMcpSetupDialog,
|
||||
onOpenAiAgents: dialogs.openSettings,
|
||||
@@ -1280,6 +1427,9 @@ function App() {
|
||||
noteListFilter,
|
||||
onSetNoteListFilter: setNoteListFilter,
|
||||
onOpenInNewWindow: handleOpenInNewWindow,
|
||||
onRevealActiveFile: fileActions.revealFile,
|
||||
onCopyActiveFilePath: fileActions.copyFilePath,
|
||||
onOpenActiveFileExternal: fileActions.openExternalFile,
|
||||
onToggleFavorite: entryActions.handleToggleFavorite,
|
||||
onToggleOrganized: toggleOrganizedCommand,
|
||||
onCustomizeNoteListColumns: handleCustomizeNoteListColumns,
|
||||
@@ -1317,9 +1467,9 @@ function App() {
|
||||
&& onboarding.state.vaultPath === vaultSwitcher.vaultPath
|
||||
}, [onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
|
||||
|
||||
// Show loading spinner while checking vault (skip for note windows)
|
||||
// Show loading skeleton while checking vault (skip for note windows)
|
||||
if (!noteWindowParams && onboarding.state.status === 'loading') {
|
||||
return <LoadingView />
|
||||
return <AppLoadingSkeleton />
|
||||
}
|
||||
|
||||
// Show telemetry consent dialog on first launch (skip for note windows).
|
||||
@@ -1363,31 +1513,25 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
// Show git-required modal when vault has no git repo (skip for note windows)
|
||||
if (!noteWindowParams && gitRepoState === 'required' && !showMcpSetupDialog) {
|
||||
// Show loading skeleton while checking git status or scanning vault notes.
|
||||
if (!noteWindowParams && onboarding.state.status === 'ready' && (gitRepoState === 'checking' || vault.isLoading)) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<GitRequiredModal
|
||||
onCreateRepo={handleInitGitRepo}
|
||||
onChooseVault={vaultSwitcher.handleOpenLocalFolder}
|
||||
/>
|
||||
</div>
|
||||
<AppLoadingSkeleton
|
||||
noteListWidth={layout.noteListWidth}
|
||||
showNoteList={noteListVisible}
|
||||
showSidebar={sidebarVisible}
|
||||
sidebarWidth={layout.sidebarWidth}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Show loading spinner while checking git status
|
||||
if (!noteWindowParams && gitRepoState === 'checking' && onboarding.state.status === 'ready') {
|
||||
return <LoadingView />
|
||||
}
|
||||
|
||||
return (
|
||||
<NoteRetargetingProvider value={noteRetargetingUi.contextValue}>
|
||||
<div className="app-shell">
|
||||
<div className="app-shell">
|
||||
<div className="app">
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -1396,9 +1540,9 @@ function App() {
|
||||
<>
|
||||
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
||||
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} locale={appLocale} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
|
||||
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -1412,8 +1556,8 @@ function App() {
|
||||
onNavigateWikilink={notes.handleNavigateWikilink}
|
||||
onLoadDiff={vault.loadDiff}
|
||||
onLoadDiffAtCommit={vault.loadDiffAtCommit}
|
||||
pendingCommitDiffRequest={pulseCommitDiffRequest}
|
||||
onPendingCommitDiffHandled={handlePulseCommitDiffHandled}
|
||||
pendingCommitDiffRequest={pendingDiffRequest}
|
||||
onPendingCommitDiffHandled={handlePendingDiffHandled}
|
||||
getNoteStatus={vault.getNoteStatus}
|
||||
onCreateNote={notes.handleCreateNoteImmediate}
|
||||
inspectorCollapsed={layout.inspectorCollapsed}
|
||||
@@ -1421,6 +1565,7 @@ function App() {
|
||||
inspectorWidth={layout.inspectorWidth}
|
||||
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
|
||||
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
|
||||
onUnsupportedAiPaste={setToastMessage}
|
||||
onInspectorResize={layout.handleInspectorResize}
|
||||
inspectorEntry={activeTab?.entry ?? null}
|
||||
inspectorContent={activeTab?.content ?? null}
|
||||
@@ -1437,14 +1582,20 @@ function App() {
|
||||
noteList={aiNoteList}
|
||||
noteListFilter={aiNoteListFilter}
|
||||
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
|
||||
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized}
|
||||
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : toggleOrganizedCommand}
|
||||
onRevealFile={fileActions.revealFile}
|
||||
onCopyFilePath={fileActions.copyFilePath}
|
||||
onOpenExternalFile={fileActions.openExternalFile}
|
||||
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
|
||||
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
|
||||
onContentChange={handleTrackedContentChange}
|
||||
onSave={handleTrackedSave}
|
||||
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
|
||||
noteLayout={noteLayout}
|
||||
onToggleNoteLayout={toggleNoteLayout}
|
||||
rawToggleRef={rawToggleRef}
|
||||
findInNoteRef={findInNoteRef}
|
||||
diffToggleRef={diffToggleRef}
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
@@ -1458,12 +1609,14 @@ function App() {
|
||||
onKeepMine={conflictFlow.handleKeepMine}
|
||||
onKeepTheirs={conflictFlow.handleKeepTheirs}
|
||||
flushPendingRawContentRef={flushPendingRawContentRef}
|
||||
locale={appLocale}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
|
||||
<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} onInitializeGit={openGitSetupDialog} 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() }} locale={appLocale} />
|
||||
<GitSetupDialog open={shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
@@ -1473,6 +1626,7 @@ function App() {
|
||||
entries={vault.entries}
|
||||
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
|
||||
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
|
||||
locale={appLocale}
|
||||
onClose={dialogs.closeCommandPalette}
|
||||
/>
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
@@ -1506,7 +1660,7 @@ function App() {
|
||||
onCommit={conflictResolver.commitResolution}
|
||||
onClose={conflictFlow.handleCloseConflictResolver}
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
||||
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
|
||||
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onDisconnect={handleDisconnectMcp} />
|
||||
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
|
||||
@@ -1530,8 +1684,7 @@ function App() {
|
||||
onCancel={folderActions.cancelDeleteFolder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</NoteRetargetingProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1573,15 +1726,4 @@ function AiAgentsOnboardingView({
|
||||
)
|
||||
}
|
||||
|
||||
/** Loading spinner view - extracted from main App component */
|
||||
function LoadingView() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--sidebar)' }}>
|
||||
<span style={{ color: 'var(--muted-foreground)', fontSize: 14 }}>Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../utils/propertyTypes'
|
||||
import { StatusPill, StatusDropdown } from './StatusDropdown'
|
||||
import { DISPLAY_MODE_OPTIONS, DISPLAY_MODE_ICONS } from '../utils/propertyTypes'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
|
||||
function parseDateValue(value: string): Date | undefined {
|
||||
const iso = toISODate(value)
|
||||
@@ -39,7 +40,7 @@ function canSubmitProperty({ key, value, displayMode }: { key: string; value: st
|
||||
return displayMode !== 'number' || isValidNumberValue(value)
|
||||
}
|
||||
|
||||
function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
function AddBooleanInput({ value, locale, onChange }: { value: string; locale: AppLocale; onChange: (v: string) => void }) {
|
||||
const boolVal = value.toLowerCase() === 'true'
|
||||
return (
|
||||
<button
|
||||
@@ -47,12 +48,12 @@ function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: str
|
||||
onClick={() => onChange(boolVal ? 'false' : 'true')}
|
||||
data-testid="add-property-boolean-toggle"
|
||||
>
|
||||
{boolVal ? '\u2713 Yes' : '\u2717 No'}
|
||||
{boolVal ? `\u2713 ${translate(locale, 'inspector.properties.yes')}` : `\u2717 ${translate(locale, 'inspector.properties.no')}`}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function AddDateInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
function AddDateInput({ value, locale, onChange }: { value: string; locale: AppLocale; onChange: (v: string) => void }) {
|
||||
const selectedDate = value ? parseDateValue(value) : undefined
|
||||
const formatted = value ? formatDateValue(value) : ''
|
||||
return (
|
||||
@@ -64,7 +65,7 @@ function AddDateInput({ value, onChange }: { value: string; onChange: (v: string
|
||||
>
|
||||
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ' text-foreground'}`}>
|
||||
{formatted || 'Pick a date\u2026'}
|
||||
{formatted || translate(locale, 'inspector.properties.pickDate')}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
@@ -122,15 +123,16 @@ function AddNumberInput({ value, onChange, onKeyDown }: {
|
||||
)
|
||||
}
|
||||
|
||||
function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses }: {
|
||||
function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses, locale }: {
|
||||
displayMode: PropertyDisplayMode; value: string; onChange: (v: string) => void
|
||||
onKeyDown: (e: React.KeyboardEvent) => void; vaultStatuses: string[]
|
||||
locale: AppLocale
|
||||
}) {
|
||||
switch (displayMode) {
|
||||
case 'number':
|
||||
return <AddNumberInput value={value} onChange={onChange} onKeyDown={onKeyDown} />
|
||||
case 'boolean': return <AddBooleanInput value={value} onChange={onChange} />
|
||||
case 'date': return <AddDateInput value={value} onChange={onChange} />
|
||||
case 'boolean': return <AddBooleanInput value={value} locale={locale} onChange={onChange} />
|
||||
case 'date': return <AddDateInput value={value} locale={locale} onChange={onChange} />
|
||||
case 'status': return <AddStatusInput value={value} onChange={onChange} vaultStatuses={vaultStatuses} />
|
||||
case 'tags': return (
|
||||
<Input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
|
||||
@@ -138,16 +140,17 @@ function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultS
|
||||
/>
|
||||
)
|
||||
default: return (
|
||||
<Input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
|
||||
<Input className={ADD_INPUT_CLASS} type="text" placeholder={translate(locale, 'inspector.properties.valuePlaceholder')} value={value}
|
||||
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
|
||||
export function AddPropertyForm({ onAdd, onCancel, vaultStatuses, locale = 'en' }: {
|
||||
onAdd: (key: string, value: string, displayMode: PropertyDisplayMode) => void; onCancel: () => void
|
||||
vaultStatuses: string[]
|
||||
locale?: AppLocale
|
||||
}) {
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [newValue, setNewValue] = useState('')
|
||||
@@ -169,7 +172,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
|
||||
<Input
|
||||
className="h-[26px] w-20 shrink-0 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
|
||||
type="text" placeholder="Property name" value={newKey}
|
||||
type="text" placeholder={translate(locale, 'inspector.properties.propertyName')} value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus
|
||||
/>
|
||||
<Select value={displayMode} onValueChange={(v) => handleModeChange(v as PropertyDisplayMode)}>
|
||||
@@ -193,15 +196,15 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AddPropertyValueInput displayMode={displayMode} value={newValue} onChange={setNewValue} onKeyDown={handleKeyDown} vaultStatuses={vaultStatuses} />
|
||||
<AddPropertyValueInput displayMode={displayMode} value={newValue} onChange={setNewValue} onKeyDown={handleKeyDown} vaultStatuses={vaultStatuses} locale={locale} />
|
||||
<Button
|
||||
size="icon-xs" onClick={() => onAdd(newKey, newValue, displayMode)}
|
||||
disabled={!canSubmit} title="Add property"
|
||||
disabled={!canSubmit} title={translate(locale, 'inspector.properties.addProperty')}
|
||||
data-testid="add-property-confirm"
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="icon-xs" variant="outline" onClick={onCancel} title="Cancel" data-testid="add-property-cancel">
|
||||
<Button size="icon-xs" variant="outline" onClick={onCancel} title={translate(locale, 'common.cancel')} data-testid="add-property-cancel">
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { AiPanel } from './AiPanel'
|
||||
import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt } from '../utils/aiPromptBridge'
|
||||
|
||||
@@ -135,19 +136,31 @@ describe('AiPanel', () => {
|
||||
expect((sendBtn as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows contextual placeholder when active entry exists', () => {
|
||||
it('shows active agent placeholder when active entry exists', () => {
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
|
||||
)
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask about this note...')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask Claude Code')
|
||||
})
|
||||
|
||||
it('shows generic placeholder when no active entry', () => {
|
||||
it('shows active agent placeholder when no active entry', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask the AI agent...')
|
||||
expect(input).toHaveAttribute('aria-placeholder', 'Ask Claude Code')
|
||||
})
|
||||
|
||||
it('uses the selected AI agent in the placeholder', () => {
|
||||
render(
|
||||
<AiPanel
|
||||
onClose={vi.fn()}
|
||||
vaultPath="/tmp/vault"
|
||||
defaultAiAgent="codex"
|
||||
defaultAiAgentReady
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('agent-input')).toHaveAttribute('aria-placeholder', 'Ask Codex')
|
||||
})
|
||||
|
||||
it('auto-focuses input on mount', async () => {
|
||||
@@ -245,4 +258,30 @@ describe('AiPanel', () => {
|
||||
])
|
||||
expect(screen.getByTestId('agent-send')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('surfaces an unsupported image paste notice without locking the composer', () => {
|
||||
const onUnsupportedAiPaste = vi.fn()
|
||||
const entry = makeEntry({ title: 'My Note' })
|
||||
|
||||
render(
|
||||
<AiPanel
|
||||
onClose={vi.fn()}
|
||||
vaultPath="/tmp/vault"
|
||||
activeEntry={entry}
|
||||
entries={[entry]}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.paste(screen.getByTestId('agent-input'), {
|
||||
clipboardData: {
|
||||
getData: vi.fn(() => ''),
|
||||
files: [new File(['image'], 'paste.png', { type: 'image/png' })],
|
||||
items: [{ kind: 'file', type: 'image/png' }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(onUnsupportedAiPaste).toHaveBeenCalledWith(UNSUPPORTED_INLINE_PASTE_MESSAGE)
|
||||
expect(screen.getByTestId('agent-input').textContent).not.toContain('paste.png')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ export type { AiAgentMessage } from '../hooks/useCliAiAgent'
|
||||
interface AiPanelProps {
|
||||
onClose: () => void
|
||||
onOpenNote?: (path: string) => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiAgentReady?: boolean
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
@@ -36,6 +37,7 @@ interface AiPanelViewProps {
|
||||
controller: AiPanelController
|
||||
onClose: () => void
|
||||
onOpenNote?: (path: string) => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiAgentReady?: boolean
|
||||
activeEntry?: VaultEntry | null
|
||||
@@ -46,6 +48,7 @@ export function AiPanelView({
|
||||
controller,
|
||||
onClose,
|
||||
onOpenNote,
|
||||
onUnsupportedAiPaste,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
activeEntry,
|
||||
@@ -86,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',
|
||||
@@ -118,13 +121,12 @@ export function AiPanelView({
|
||||
entries={entries ?? []}
|
||||
agentLabel={agentLabel}
|
||||
agentReady={defaultAiAgentReady}
|
||||
hasContext={hasContext}
|
||||
input={input}
|
||||
inputRef={inputRef}
|
||||
isActive={isActive}
|
||||
legacyCopy={useLegacyAiExperience}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
@@ -133,6 +135,7 @@ export function AiPanelView({
|
||||
export function AiPanel({
|
||||
onClose,
|
||||
onOpenNote,
|
||||
onUnsupportedAiPaste,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
onFileCreated,
|
||||
@@ -167,6 +170,7 @@ export function AiPanel({
|
||||
controller={controller}
|
||||
onClose={onClose}
|
||||
onOpenNote={onOpenNote}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
defaultAiAgent={providedDefaultAiAgent}
|
||||
defaultAiAgentReady={providedDefaultAiAgentReady}
|
||||
activeEntry={activeEntry}
|
||||
|
||||
@@ -35,30 +35,23 @@ interface AiPanelComposerProps {
|
||||
entries: VaultEntry[]
|
||||
agentLabel: string
|
||||
agentReady: boolean
|
||||
hasContext: boolean
|
||||
input: string
|
||||
inputRef: React.RefObject<HTMLDivElement | null>
|
||||
isActive: boolean
|
||||
legacyCopy: boolean
|
||||
onChange: (value: string) => void
|
||||
onSend: (text: string, references: NoteReference[]) => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
}
|
||||
|
||||
function getComposerPlaceholder(
|
||||
agentLabel: string,
|
||||
agentReady: boolean,
|
||||
legacyCopy: boolean,
|
||||
hasContext: boolean,
|
||||
): string {
|
||||
if (!agentReady) {
|
||||
return `${agentLabel} is not installed. Open AI Agents in Settings.`
|
||||
}
|
||||
|
||||
if (legacyCopy) {
|
||||
return hasContext ? 'Ask about this note...' : 'Ask the AI agent...'
|
||||
}
|
||||
|
||||
return hasContext ? `Ask ${agentLabel} about this note...` : `Ask ${agentLabel}...`
|
||||
return `Ask ${agentLabel}`
|
||||
}
|
||||
|
||||
function AiPanelEmptyState({
|
||||
@@ -208,20 +201,19 @@ export function AiPanelComposer({
|
||||
entries,
|
||||
agentLabel,
|
||||
agentReady,
|
||||
hasContext,
|
||||
input,
|
||||
inputRef,
|
||||
isActive,
|
||||
legacyCopy,
|
||||
onChange,
|
||||
onSend,
|
||||
onUnsupportedAiPaste,
|
||||
}: AiPanelComposerProps) {
|
||||
const composerDisabled = isActive || !agentReady
|
||||
const canSend = !composerDisabled && input.trim().length > 0
|
||||
const placeholder = getComposerPlaceholder(agentLabel, agentReady, legacyCopy, hasContext)
|
||||
const placeholder = getComposerPlaceholder(agentLabel, agentReady)
|
||||
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,
|
||||
@@ -240,6 +232,7 @@ export function AiPanelComposer({
|
||||
value={input}
|
||||
onChange={onChange}
|
||||
onSend={onSend}
|
||||
onUnsupportedPaste={onUnsupportedAiPaste}
|
||||
disabled={composerDisabled}
|
||||
placeholder={placeholder}
|
||||
inputRef={inputRef}
|
||||
|
||||
295
src/components/AppLoadingSkeleton.tsx
Normal file
295
src/components/AppLoadingSkeleton.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
AlignJustify,
|
||||
Archive,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Code2,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Inbox,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Star,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SIDEBAR_GROUPS = [
|
||||
{ rows: ['62%', '48%', '58%', '52%'], count: '24px' },
|
||||
{ rows: ['72%'], count: '30px' },
|
||||
{ rows: ['34%', '42%', '38%', '56%', '50%', '44%', '39%', '46%'], count: '26px' },
|
||||
{ rows: ['38%', '52%', '32%'] },
|
||||
]
|
||||
|
||||
const NOTE_ROWS = [
|
||||
{ selected: false, title: '68%', snippet: '84%', chips: ['56px'] },
|
||||
{ selected: true, title: '62%', snippet: '78%', chips: ['64px', '54px', '72px'] },
|
||||
{ selected: false, title: '48%', snippet: '44%', chips: [] },
|
||||
{ selected: false, title: '82%', snippet: '74%', chips: ['68px'] },
|
||||
{ selected: false, title: '70%', snippet: '88%', chips: ['76px', '92px'] },
|
||||
{ selected: false, title: '58%', snippet: '66%', chips: ['64px'] },
|
||||
{ selected: false, title: '76%', snippet: '72%', chips: ['72px'] },
|
||||
]
|
||||
|
||||
const EDITOR_ACTIONS = [Star, CheckCircle2, GitBranch, Code2, AlignJustify, Sparkles, Archive, Trash2, SlidersHorizontal]
|
||||
const STATUS_LEFT = ['70px', '104px', '88px', '116px', '82px']
|
||||
const STATUS_RIGHT = ['44px', '78px', '18px', '18px']
|
||||
|
||||
type AppLoadingSkeletonProps = {
|
||||
noteListWidth?: number
|
||||
showNoteList?: boolean
|
||||
showSidebar?: boolean
|
||||
sidebarWidth?: number
|
||||
}
|
||||
|
||||
function SkeletonBar({ className, style }: { className?: string; style?: CSSProperties }) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-pulse rounded bg-muted', className)}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SkeletonIcon({ icon: Icon, active = false }: { icon: LucideIcon; active?: boolean }) {
|
||||
return (
|
||||
<Icon
|
||||
size={16}
|
||||
strokeWidth={1.9}
|
||||
className={cn(active ? 'text-primary' : 'text-muted-foreground', 'shrink-0 opacity-70')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRow({ icon, width, active = false, countWidth }: {
|
||||
icon: LucideIcon
|
||||
width: string
|
||||
active?: boolean
|
||||
countWidth?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center gap-2 rounded px-4 py-1.5', active && 'bg-primary/10')}
|
||||
style={active ? { boxShadow: 'inset 3px 0 0 var(--primary)' } : undefined}
|
||||
>
|
||||
<SkeletonIcon icon={icon} active={active} />
|
||||
<SkeletonBar className="h-3" style={{ width }} />
|
||||
{countWidth && <SkeletonBar className="h-5 rounded-full" style={{ width: countWidth }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSkeletonGlyph() {
|
||||
return <SkeletonBar className="h-4 w-4 shrink-0 rounded-[4px]" />
|
||||
}
|
||||
|
||||
function SidebarStubRow({ width, countWidth }: {
|
||||
width: string
|
||||
countWidth?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded px-4 py-1.5">
|
||||
<SidebarSkeletonGlyph />
|
||||
<SkeletonBar className="h-3" style={{ width }} />
|
||||
{countWidth && <SkeletonBar className="h-5 rounded-full" style={{ width: countWidth }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupSkeleton({ rows, count }: { rows: string[]; count?: string }) {
|
||||
return (
|
||||
<div className="border-b border-border px-1.5 pb-2">
|
||||
<div className="flex items-center gap-1 px-4 py-2">
|
||||
<ChevronDown size={12} className="text-muted-foreground opacity-60" />
|
||||
<SkeletonBar className="h-2.5" style={{ width: '54px' }} />
|
||||
{count && <SkeletonBar className="ml-auto h-4 rounded-full" style={{ width: count }} />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{rows.map((width, index) => (
|
||||
<SidebarStubRow
|
||||
key={`${width}-${index}`}
|
||||
width={width}
|
||||
countWidth={index > 3 ? '30px' : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSkeleton() {
|
||||
return (
|
||||
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
|
||||
<div className="h-[52px] shrink-0 border-b border-border" />
|
||||
<nav className="flex-1 overflow-hidden py-1">
|
||||
<div className="border-b border-border px-1.5 pb-1">
|
||||
<SidebarRow icon={Inbox} width="48%" active countWidth="30px" />
|
||||
<SidebarRow icon={FileText} width="54%" countWidth="38px" />
|
||||
<SidebarRow icon={Archive} width="42%" countWidth="32px" />
|
||||
</div>
|
||||
{SIDEBAR_GROUPS.map((group, index) => (
|
||||
<SidebarGroupSkeleton key={index} rows={group.rows} count={group.count} />
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteRowSkeleton({ selected, title, snippet, chips }: {
|
||||
selected: boolean
|
||||
title: string
|
||||
snippet: string
|
||||
chips: string[]
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn('relative border-b border-border px-5 py-4', selected && 'bg-primary/5')}
|
||||
style={selected ? { boxShadow: 'inset 3px 0 0 var(--accent-green)' } : undefined}
|
||||
>
|
||||
<SkeletonBar className="absolute right-4 top-4 h-3.5 w-3.5 rounded-sm" />
|
||||
<SkeletonBar className="mb-3 h-3.5" style={{ width: title }} />
|
||||
<div className="space-y-1.5">
|
||||
<SkeletonBar className="h-2.5" style={{ width: snippet }} />
|
||||
<SkeletonBar className="h-2.5" style={{ width: '58%' }} />
|
||||
</div>
|
||||
{chips.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{chips.map((width) => (
|
||||
<SkeletonBar key={width} className="h-5 rounded" style={{ width }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 grid grid-cols-[1fr_auto] gap-3">
|
||||
<SkeletonBar className="h-2.5" style={{ width: '38px' }} />
|
||||
<SkeletonBar className="h-2.5" style={{ width: '72px' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteListSkeleton() {
|
||||
return (
|
||||
<section className="flex h-full flex-col overflow-hidden border-r border-border bg-card text-foreground">
|
||||
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4">
|
||||
<SkeletonBar className="h-4" style={{ width: '78px' }} />
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<SkeletonBar className="h-2.5" style={{ width: '58px' }} />
|
||||
<Search size={16} />
|
||||
<SlidersHorizontal size={16} />
|
||||
<Plus size={16} />
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{NOTE_ROWS.map((row, index) => (
|
||||
<NoteRowSkeleton key={index} {...row} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EditorSkeleton() {
|
||||
return (
|
||||
<main className="flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonBar className="h-3" style={{ width: '44px' }} />
|
||||
<SkeletonBar className="h-3" style={{ width: '150px' }} />
|
||||
</div>
|
||||
<div className="flex items-center gap-5 text-muted-foreground">
|
||||
{EDITOR_ACTIONS.map((Icon, index) => (
|
||||
<Icon key={index} size={16} strokeWidth={1.8} className="opacity-65" />
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
<article className="mx-auto flex w-full max-w-[760px] flex-1 flex-col px-10 py-16">
|
||||
<SkeletonBar className="mb-7 h-9" style={{ width: '58%' }} />
|
||||
<SkeletonBar className="mb-6 h-px w-full rounded-none" />
|
||||
<div className="space-y-4">
|
||||
<SkeletonBar className="h-4" style={{ width: '52%' }} />
|
||||
<SkeletonBar className="h-4" style={{ width: '92%' }} />
|
||||
<SkeletonBar className="h-4" style={{ width: '82%' }} />
|
||||
<SkeletonBar className="h-4" style={{ width: '88%' }} />
|
||||
<SkeletonBar className="h-4" style={{ width: '74%' }} />
|
||||
</div>
|
||||
<SkeletonBar className="my-10 h-px w-full rounded-none" />
|
||||
<div className="space-y-4">
|
||||
<SkeletonBar className="h-4" style={{ width: '22%' }} />
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div key={index} className="flex items-center gap-4 pl-4">
|
||||
<SkeletonBar className="h-2.5 w-2.5 rounded-full bg-primary/70" />
|
||||
<SkeletonBar className="h-4" style={{ width: index === 1 ? '54%' : '44%' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusSkeleton() {
|
||||
return (
|
||||
<footer
|
||||
className="flex shrink-0 items-center justify-between border-t border-border bg-sidebar px-2 text-muted-foreground"
|
||||
style={{ height: 30 }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{STATUS_LEFT.map((width, index) => (
|
||||
<SkeletonBar key={`${width}-${index}`} className="h-3" style={{ width }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{STATUS_RIGHT.map((width, index) => (
|
||||
<SkeletonBar key={`${width}-${index}`} className="h-3" style={{ width }} />
|
||||
))}
|
||||
<Settings size={14} className="opacity-60" />
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppLoadingSkeleton({
|
||||
noteListWidth = 350,
|
||||
showNoteList = true,
|
||||
showSidebar = true,
|
||||
sidebarWidth = 250,
|
||||
}: AppLoadingSkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className="app-shell"
|
||||
data-testid="vault-loading-skeleton"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="sr-only">Loading vault</span>
|
||||
<div className="app" aria-hidden="true">
|
||||
{showSidebar && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: sidebarWidth }}>
|
||||
<SidebarSkeleton />
|
||||
</div>
|
||||
<div className="w-px shrink-0 bg-border" />
|
||||
</>
|
||||
)}
|
||||
{showNoteList && (
|
||||
<>
|
||||
<div className="app__note-list" style={{ width: noteListWidth }}>
|
||||
<NoteListSkeleton />
|
||||
</div>
|
||||
<div className="w-px shrink-0 bg-border" />
|
||||
</>
|
||||
)}
|
||||
<div className="app__editor">
|
||||
<EditorSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
<StatusSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Archive, ArrowUUpLeft } from '@phosphor-icons/react'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
|
||||
interface ArchivedNoteBannerProps {
|
||||
onUnarchive: () => void
|
||||
locale?: AppLocale
|
||||
}
|
||||
|
||||
export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
|
||||
export function ArchivedNoteBanner({ onUnarchive, locale = 'en' }: ArchivedNoteBannerProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="archived-note-banner"
|
||||
@@ -21,7 +23,7 @@ export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
|
||||
}}
|
||||
>
|
||||
<Archive size={13} weight="bold" />
|
||||
<span>Archived</span>
|
||||
<span>{translate(locale, 'editor.banner.archived')}</span>
|
||||
<button
|
||||
data-testid="unarchive-btn"
|
||||
onClick={onUnarchive}
|
||||
@@ -38,10 +40,10 @@ export function ArchivedNoteBanner({ onUnarchive }: ArchivedNoteBannerProps) {
|
||||
color: 'var(--muted-foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Unarchive"
|
||||
title={translate(locale, 'editor.banner.unarchive')}
|
||||
>
|
||||
<ArrowUUpLeft size={12} />
|
||||
Unarchive
|
||||
{translate(locale, 'editor.banner.unarchive')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
@@ -134,10 +135,34 @@ describe('BreadcrumbBar — archive/unarchive', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — file actions', () => {
|
||||
it('reveals the current file from the breadcrumb toolbar', () => {
|
||||
const onRevealFile = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRevealFile={onRevealFile} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reveal in Finder' }))
|
||||
|
||||
expect(onRevealFile).toHaveBeenCalledWith('/vault/note/test.md')
|
||||
})
|
||||
|
||||
it('copies the current file path from the breadcrumb toolbar', () => {
|
||||
const onCopyFilePath = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onCopyFilePath={onCopyFilePath} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy file path' }))
|
||||
|
||||
expect(onCopyFilePath).toHaveBeenCalledWith('/vault/note/test.md')
|
||||
})
|
||||
})
|
||||
|
||||
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', () => {
|
||||
@@ -301,3 +326,26 @@ describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
expect(screen.getByRole('button', { name: 'Open the raw editor' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — note layout toggle', () => {
|
||||
it('shows the left-align layout action while centered', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="centered" onToggleNoteLayout={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Switch to left-aligned note layout' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the centered layout action while left-aligned', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="left" onToggleNoteLayout={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Switch to centered note layout' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onToggleNoteLayout when the layout button is clicked', () => {
|
||||
const onToggleNoteLayout = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="centered" onToggleNoteLayout={onToggleNoteLayout} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch to left-aligned note layout' }))
|
||||
|
||||
expect(onToggleNoteLayout).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { NoteLayout, VaultEntry } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
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'
|
||||
@@ -13,9 +15,13 @@ import {
|
||||
Trash,
|
||||
Archive,
|
||||
ArrowUUpLeft,
|
||||
ClipboardText,
|
||||
FolderOpen,
|
||||
Star,
|
||||
CheckCircle,
|
||||
ArrowsClockwise,
|
||||
TextAlignCenter,
|
||||
TextAlignLeft,
|
||||
} from '@phosphor-icons/react'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { slugify } from '../hooks/useNoteCreation'
|
||||
@@ -38,12 +44,17 @@ interface BreadcrumbBarProps {
|
||||
onToggleInspector?: () => void
|
||||
onToggleFavorite?: () => void
|
||||
onToggleOrganized?: () => void
|
||||
onRevealFile?: (path: string) => void
|
||||
onCopyFilePath?: (path: string) => void
|
||||
onDelete?: () => void
|
||||
onArchive?: () => void
|
||||
onUnarchive?: () => void
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
|
||||
barRef?: React.Ref<HTMLDivElement>
|
||||
locale?: AppLocale
|
||||
}
|
||||
|
||||
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
@@ -130,78 +141,182 @@ 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
|
||||
}
|
||||
|
||||
interface TranslatedToggleIconActionProps extends Omit<ToggleIconActionProps, 'activeLabel' | 'inactiveLabel'> {
|
||||
activeLabelKey: Parameters<typeof translate>[1]
|
||||
inactiveLabelKey: Parameters<typeof translate>[1]
|
||||
locale?: AppLocale
|
||||
}
|
||||
|
||||
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',
|
||||
function TranslatedToggleIconAction({
|
||||
activeLabelKey,
|
||||
inactiveLabelKey,
|
||||
locale = 'en',
|
||||
...props
|
||||
}: TranslatedToggleIconActionProps) {
|
||||
return (
|
||||
<ToggleIconAction
|
||||
{...props}
|
||||
activeLabel={translate(locale, activeLabelKey)}
|
||||
inactiveLabel={translate(locale, inactiveLabelKey)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const TOGGLE_ACTION_CONFIGS = {
|
||||
raw: {
|
||||
activeClassName: 'text-foreground',
|
||||
activeLabelKey: 'editor.toolbar.rawReturn',
|
||||
inactiveLabelKey: 'editor.toolbar.rawOpen',
|
||||
shortcut: '⌘\\',
|
||||
renderIcon: () => <Code size={16} className={BREADCRUMB_ICON_CLASS} />,
|
||||
},
|
||||
favorite: {
|
||||
activeClassName: 'text-[var(--accent-yellow)]',
|
||||
activeLabelKey: 'editor.toolbar.removeFavorite',
|
||||
inactiveLabelKey: 'editor.toolbar.addFavorite',
|
||||
shortcut: '⌘D',
|
||||
}
|
||||
renderIcon: (active: boolean) => <Star size={16} weight={active ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />,
|
||||
},
|
||||
organized: {
|
||||
activeClassName: 'text-[var(--accent-green)]',
|
||||
activeLabelKey: 'editor.toolbar.markUnorganized',
|
||||
inactiveLabelKey: 'editor.toolbar.markOrganized',
|
||||
shortcut: '⌘E',
|
||||
renderIcon: (active: boolean) => <CheckCircle size={16} weight={active ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />,
|
||||
},
|
||||
} satisfies Record<string, {
|
||||
activeClassName: string
|
||||
activeLabelKey: Parameters<typeof translate>[1]
|
||||
inactiveLabelKey: Parameters<typeof translate>[1]
|
||||
shortcut: string
|
||||
renderIcon: (active: boolean) => ReactNode
|
||||
}>
|
||||
|
||||
function ConfiguredToggleAction({
|
||||
active,
|
||||
config,
|
||||
locale = 'en',
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
config: (typeof TOGGLE_ACTION_CONFIGS)[keyof typeof TOGGLE_ACTION_CONFIGS]
|
||||
locale?: AppLocale
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<TranslatedToggleIconAction
|
||||
active={active}
|
||||
activeClassName={config.activeClassName}
|
||||
activeLabelKey={config.activeLabelKey}
|
||||
inactiveLabelKey={config.inactiveLabelKey}
|
||||
locale={locale}
|
||||
onClick={onClick}
|
||||
shortcut={formatShortcutDisplay({ display: config.shortcut })}
|
||||
>
|
||||
{config.renderIcon(active)}
|
||||
</TranslatedToggleIconAction>
|
||||
)
|
||||
}
|
||||
|
||||
function RawToggleButton({ rawMode, locale = 'en', onToggleRaw }: { rawMode?: boolean; locale?: AppLocale; onToggleRaw?: () => void }) {
|
||||
return <ConfiguredToggleAction active={!!rawMode} config={TOGGLE_ACTION_CONFIGS.raw} locale={locale} onClick={onToggleRaw} />
|
||||
}
|
||||
|
||||
function NoteLayoutAction({
|
||||
noteLayout = 'centered',
|
||||
locale = 'en',
|
||||
onToggleNoteLayout,
|
||||
}: {
|
||||
noteLayout?: NoteLayout
|
||||
locale?: AppLocale
|
||||
onToggleNoteLayout?: () => void
|
||||
}) {
|
||||
if (!onToggleNoteLayout) return null
|
||||
|
||||
const isLeftAligned = noteLayout === 'left'
|
||||
return (
|
||||
<IconActionButton
|
||||
copy={copy}
|
||||
onClick={onToggleFavorite}
|
||||
className={cn(favorite ? 'text-yellow-500' : 'hover:text-foreground')}
|
||||
copy={{ label: translate(locale, isLeftAligned ? 'editor.toolbar.centerLayout' : 'editor.toolbar.leftLayout') }}
|
||||
onClick={onToggleNoteLayout}
|
||||
className={cn(isLeftAligned ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
<Star size={16} weight={favorite ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
{isLeftAligned
|
||||
? <TextAlignLeft size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
: <TextAlignCenter size={16} className={BREADCRUMB_ICON_CLASS} />}
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function FavoriteAction({ favorite, locale = 'en', onToggleFavorite }: { favorite: boolean; locale?: AppLocale; onToggleFavorite?: () => void }) {
|
||||
return <ConfiguredToggleAction active={favorite} config={TOGGLE_ACTION_CONFIGS.favorite} locale={locale} onClick={onToggleFavorite} />
|
||||
}
|
||||
|
||||
function OrganizedAction({
|
||||
organized,
|
||||
locale = 'en',
|
||||
onToggleOrganized,
|
||||
}: {
|
||||
organized: boolean
|
||||
locale?: AppLocale
|
||||
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}
|
||||
onClick={onToggleOrganized}
|
||||
className={cn(organized ? 'text-green-600' : 'hover:text-foreground')}
|
||||
>
|
||||
<CheckCircle size={16} weight={organized ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
return <ConfiguredToggleAction active={organized} config={TOGGLE_ACTION_CONFIGS.organized} locale={locale} onClick={onToggleOrganized} />
|
||||
}
|
||||
|
||||
function DiffAction({
|
||||
showDiffToggle,
|
||||
diffMode,
|
||||
diffLoading,
|
||||
locale = 'en',
|
||||
onToggleDiff,
|
||||
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'onToggleDiff'>) {
|
||||
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading' | 'locale' | 'onToggleDiff'>) {
|
||||
if (!showDiffToggle) {
|
||||
return (
|
||||
<IconActionButton copy={{ label: 'No diff is available yet' }} style={DISABLED_ICON_STYLE}>
|
||||
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.noDiff') }} style={DISABLED_ICON_STYLE}>
|
||||
<GitBranch size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
const copy: ActionTooltipCopy = diffLoading
|
||||
? { label: 'Loading the diff' }
|
||||
: { label: diffMode ? 'Return to the editor' : 'Show the current diff' }
|
||||
? { label: translate(locale, 'editor.toolbar.loadingDiff') }
|
||||
: { label: translate(locale, diffMode ? 'editor.toolbar.rawReturn' : 'editor.toolbar.showDiff') }
|
||||
return (
|
||||
<IconActionButton
|
||||
copy={copy}
|
||||
@@ -213,57 +328,105 @@ 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',
|
||||
}
|
||||
function AIChatAction({ showAIChat, locale = 'en', onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'locale' | 'onToggleAIChat'>) {
|
||||
return (
|
||||
<IconActionButton
|
||||
copy={copy}
|
||||
<ToggleIconAction
|
||||
active={!!showAIChat}
|
||||
activeClassName="text-primary"
|
||||
activeLabel={translate(locale, 'editor.toolbar.closeAi')}
|
||||
inactiveLabel={translate(locale, 'editor.toolbar.openAi')}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function ArchiveAction({
|
||||
archived,
|
||||
locale = 'en',
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'onArchive' | 'onUnarchive'>) {
|
||||
}: Pick<VaultEntry, 'archived'> & Pick<BreadcrumbBarProps, 'locale' | 'onArchive' | 'onUnarchive'>) {
|
||||
if (archived) {
|
||||
return (
|
||||
<IconActionButton copy={{ label: 'Restore this archived note' }} onClick={onUnarchive} className="hover:text-foreground">
|
||||
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.restoreArchived') }} onClick={onUnarchive} className="hover:text-foreground">
|
||||
<ArrowUUpLeft size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<IconActionButton copy={{ label: 'Archive this note' }} onClick={onArchive} className="hover:text-foreground">
|
||||
<IconActionButton copy={{ label: translate(locale, 'editor.toolbar.archive') }} onClick={onArchive} className="hover:text-foreground">
|
||||
<Archive size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
|
||||
function DeleteAction({ locale = 'en', onDelete }: Pick<BreadcrumbBarProps, 'locale' | 'onDelete'>) {
|
||||
return (
|
||||
<IconActionButton copy={{ label: 'Delete this note', shortcut: '⌘⌫' }} onClick={onDelete} className="hover:text-destructive">
|
||||
<IconActionButton
|
||||
copy={{
|
||||
label: translate(locale, 'editor.toolbar.delete'),
|
||||
shortcut: formatShortcutDisplay({ display: '⌘⌫ / ⌘⌦' }),
|
||||
}}
|
||||
onClick={onDelete}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
<Trash size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function FilePathActions({
|
||||
entry,
|
||||
locale = 'en',
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRevealFile' | 'onCopyFilePath'>) {
|
||||
return (
|
||||
<>
|
||||
{onRevealFile && (
|
||||
<IconActionButton
|
||||
copy={{ label: translate(locale, 'editor.toolbar.revealFile') }}
|
||||
onClick={() => onRevealFile(entry.path)}
|
||||
className="hover:text-foreground"
|
||||
testId="breadcrumb-reveal-file"
|
||||
>
|
||||
<FolderOpen size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)}
|
||||
{onCopyFilePath && (
|
||||
<IconActionButton
|
||||
copy={{ label: translate(locale, 'editor.toolbar.copyFilePath') }}
|
||||
onClick={() => onCopyFilePath(entry.path)}
|
||||
className="hover:text-foreground"
|
||||
testId="breadcrumb-copy-file-path"
|
||||
>
|
||||
<ClipboardText size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function InspectorAction({
|
||||
inspectorCollapsed,
|
||||
locale = 'en',
|
||||
onToggleInspector,
|
||||
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'onToggleInspector'>) {
|
||||
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'locale' | '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: translate(locale, 'editor.toolbar.openProperties'),
|
||||
shortcut: formatShortcutDisplay({ display: '⌘⇧I' }),
|
||||
}}
|
||||
onClick={onToggleInspector}
|
||||
className="hover:text-foreground"
|
||||
tooltipAlign="end"
|
||||
>
|
||||
<SlidersHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
@@ -284,12 +447,14 @@ function deriveSyncStem(entry: VaultEntry): string | null {
|
||||
function FilenameInput({
|
||||
inputRef,
|
||||
draftStem,
|
||||
locale = 'en',
|
||||
onDraftStemChange,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
draftStem: string
|
||||
locale?: AppLocale
|
||||
onDraftStemChange: (nextValue: string) => void
|
||||
onBlur: () => void
|
||||
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
|
||||
@@ -303,7 +468,7 @@ function FilenameInput({
|
||||
onKeyDown={onKeyDown}
|
||||
className="h-7 w-[180px] text-sm"
|
||||
data-testid="breadcrumb-filename-input"
|
||||
aria-label="Rename filename"
|
||||
aria-label={translate(locale, 'editor.filename.rename')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -311,10 +476,12 @@ function FilenameInput({
|
||||
function FilenameTrigger({
|
||||
entry,
|
||||
filenameStem,
|
||||
locale = 'en',
|
||||
onStartEditing,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
filenameStem: string
|
||||
locale?: AppLocale
|
||||
onStartEditing: () => void
|
||||
}) {
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
@@ -332,7 +499,7 @@ function FilenameTrigger({
|
||||
onDoubleClick={onStartEditing}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="breadcrumb-filename-trigger"
|
||||
aria-label={`Filename ${filenameStem}. Press Enter to rename`}
|
||||
aria-label={translate(locale, 'editor.filename.trigger', { filename: filenameStem })}
|
||||
>
|
||||
<NoteTitleIcon icon={entry.icon} size={15} testId="breadcrumb-note-icon" />
|
||||
<span className="truncate">{filenameStem}</span>
|
||||
@@ -343,15 +510,17 @@ function FilenameTrigger({
|
||||
function SyncFilenameButton({
|
||||
entryPath,
|
||||
syncStem,
|
||||
locale = 'en',
|
||||
onRenameFilename,
|
||||
}: {
|
||||
entryPath: string
|
||||
syncStem: string | null
|
||||
locale?: AppLocale
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
}) {
|
||||
if (!syncStem || !onRenameFilename) return null
|
||||
return (
|
||||
<ActionTooltip copy={{ label: 'Rename the file to match the title' }} side="bottom">
|
||||
<ActionTooltip copy={{ label: translate(locale, 'editor.filename.renameToTitle') }} side="bottom">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -359,7 +528,7 @@ function SyncFilenameButton({
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onRenameFilename(entryPath, syncStem)}
|
||||
data-testid="breadcrumb-sync-button"
|
||||
aria-label="Rename the file to match the title"
|
||||
aria-label={translate(locale, 'editor.filename.renameToTitle')}
|
||||
>
|
||||
<ArrowsClockwise size={14} />
|
||||
</Button>
|
||||
@@ -371,24 +540,26 @@ function FilenameDisplay({
|
||||
entry,
|
||||
filenameStem,
|
||||
syncStem,
|
||||
locale,
|
||||
onRenameFilename,
|
||||
onStartEditing,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
filenameStem: string
|
||||
syncStem: string | null
|
||||
locale?: AppLocale
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
onStartEditing: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<FilenameTrigger entry={entry} filenameStem={filenameStem} onStartEditing={onStartEditing} />
|
||||
<SyncFilenameButton entryPath={entry.path} syncStem={syncStem} onRenameFilename={onRenameFilename} />
|
||||
<FilenameTrigger entry={entry} filenameStem={filenameStem} locale={locale} onStartEditing={onStartEditing} />
|
||||
<SyncFilenameButton entryPath={entry.path} syncStem={syncStem} locale={locale} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
|
||||
function FilenameCrumb({ entry, locale = 'en', onRenameFilename }: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRenameFilename'>) {
|
||||
const filenameStem = useMemo(() => entry.filename.replace(/\.md$/, ''), [entry.filename])
|
||||
const syncStem = useMemo(() => deriveSyncStem(entry), [entry])
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
@@ -424,6 +595,7 @@ function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'en
|
||||
<FilenameInput
|
||||
inputRef={inputRef}
|
||||
draftStem={draftStem}
|
||||
locale={locale}
|
||||
onDraftStemChange={setDraftStem}
|
||||
onBlur={submitRename}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
@@ -436,6 +608,7 @@ function FilenameCrumb({ entry, onRenameFilename }: Pick<BreadcrumbBarProps, 'en
|
||||
entry={entry}
|
||||
filenameStem={filenameStem}
|
||||
syncStem={syncStem}
|
||||
locale={locale}
|
||||
onRenameFilename={onRenameFilename}
|
||||
onStartEditing={startEditing}
|
||||
/>
|
||||
@@ -451,46 +624,55 @@ function BreadcrumbActions({
|
||||
rawMode,
|
||||
onToggleRaw,
|
||||
forceRawMode,
|
||||
noteLayout,
|
||||
onToggleNoteLayout,
|
||||
showAIChat,
|
||||
onToggleAIChat,
|
||||
inspectorCollapsed,
|
||||
onToggleInspector,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
onDelete,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
locale = 'en',
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount' | 'barRef' | 'onRenameFilename'>) {
|
||||
return (
|
||||
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
|
||||
<FavoriteAction favorite={entry.favorite} onToggleFavorite={onToggleFavorite} />
|
||||
<OrganizedAction organized={entry.organized} onToggleOrganized={onToggleOrganized} />
|
||||
<FavoriteAction favorite={entry.favorite} locale={locale} onToggleFavorite={onToggleFavorite} />
|
||||
<OrganizedAction organized={entry.organized} locale={locale} onToggleOrganized={onToggleOrganized} />
|
||||
<DiffAction
|
||||
showDiffToggle={showDiffToggle}
|
||||
diffMode={diffMode}
|
||||
diffLoading={diffLoading}
|
||||
onToggleDiff={onToggleDiff}
|
||||
locale={locale}
|
||||
/>
|
||||
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
|
||||
<AIChatAction showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} />
|
||||
<ArchiveAction archived={entry.archived} onArchive={onArchive} onUnarchive={onUnarchive} />
|
||||
<DeleteAction onDelete={onDelete} />
|
||||
<InspectorAction inspectorCollapsed={inspectorCollapsed} onToggleInspector={onToggleInspector} />
|
||||
{!forceRawMode && <RawToggleButton rawMode={rawMode} locale={locale} onToggleRaw={onToggleRaw} />}
|
||||
<NoteLayoutAction noteLayout={noteLayout} locale={locale} onToggleNoteLayout={onToggleNoteLayout} />
|
||||
<AIChatAction showAIChat={showAIChat} locale={locale} onToggleAIChat={onToggleAIChat} />
|
||||
<FilePathActions entry={entry} locale={locale} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} />
|
||||
<ArchiveAction archived={entry.archived} locale={locale} onArchive={onArchive} onUnarchive={onUnarchive} />
|
||||
<DeleteAction locale={locale} onDelete={onDelete} />
|
||||
<InspectorAction inspectorCollapsed={inspectorCollapsed} locale={locale} onToggleInspector={onToggleInspector} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbTitle({
|
||||
entry,
|
||||
locale,
|
||||
onRenameFilename,
|
||||
}: Pick<BreadcrumbBarProps, 'entry' | 'onRenameFilename'>) {
|
||||
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRenameFilename'>) {
|
||||
const typeLabel = entry.isA ?? 'Note'
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
|
||||
<span className="shrink-0">{typeLabel}</span>
|
||||
<span className="shrink-0 text-border">›</span>
|
||||
<div className="flex min-w-0 items-center gap-1 truncate">
|
||||
<FilenameCrumb entry={entry} onRenameFilename={onRenameFilename} />
|
||||
<FilenameCrumb entry={entry} locale={locale} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -499,6 +681,7 @@ function BreadcrumbTitle({
|
||||
export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
entry,
|
||||
barRef,
|
||||
locale = 'en',
|
||||
onRenameFilename,
|
||||
...actionProps
|
||||
}: BreadcrumbBarProps) {
|
||||
@@ -520,14 +703,14 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
}}
|
||||
>
|
||||
<div className="breadcrumb-bar__title min-w-0">
|
||||
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
|
||||
<BreadcrumbTitle entry={entry} locale={locale} onRenameFilename={onRenameFilename} />
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-tauri-drag-region
|
||||
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
|
||||
/>
|
||||
<BreadcrumbActions entry={entry} {...actionProps} />
|
||||
<BreadcrumbActions entry={entry} locale={locale} {...actionProps} />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -36,6 +36,14 @@ describe('CloneVaultModal', () => {
|
||||
expect(screen.getByTestId('clone-vault-path')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('focuses the repository field when the modal opens', async () => {
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('clone-repo-url')).toHaveFocus()
|
||||
})
|
||||
})
|
||||
|
||||
it('suggests a vault path from the repository URL', () => {
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
@@ -46,7 +54,7 @@ describe('CloneVaultModal', () => {
|
||||
expect(screen.getByTestId('clone-vault-path')).toHaveValue('~/Vaults/my-vault')
|
||||
})
|
||||
|
||||
it('calls clone_repo and reports the cloned vault on submit', async () => {
|
||||
it('calls clone_git_repo and reports the cloned vault on submit', async () => {
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('clone-repo-url'), {
|
||||
@@ -55,7 +63,7 @@ describe('CloneVaultModal', () => {
|
||||
fireEvent.click(screen.getByTestId('clone-vault-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('clone_repo', {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('clone_git_repo', {
|
||||
url: 'git@github.com:user/my-vault.git',
|
||||
localPath: '~/Vaults/my-vault',
|
||||
})
|
||||
@@ -82,4 +90,58 @@ describe('CloneVaultModal', () => {
|
||||
expect(screen.getByTestId('clone-vault-error')).toHaveTextContent('Clone failed: Error: Permission denied')
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps progress visible while the clone request is pending', async () => {
|
||||
let resolveClone: ((value: string) => void) | null = null
|
||||
mockInvokeFn.mockReturnValueOnce(new Promise((resolve) => {
|
||||
resolveClone = resolve
|
||||
}))
|
||||
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('clone-repo-url'), {
|
||||
target: { value: 'git@github.com:user/my-vault.git' },
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('clone-vault-submit'))
|
||||
|
||||
expect(screen.getByTestId('clone-vault-submit')).toHaveTextContent('Cloning...')
|
||||
expect(screen.getByTestId('clone-vault-submit')).toBeDisabled()
|
||||
|
||||
resolveClone?.('Cloned successfully')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onVaultCloned).toHaveBeenCalledWith('~/Vaults/my-vault', 'my-vault')
|
||||
})
|
||||
})
|
||||
|
||||
it('submits with Enter and blocks overlapping clone attempts while pending', async () => {
|
||||
let resolveClone: ((value: string) => void) | null = null
|
||||
mockInvokeFn.mockReturnValueOnce(new Promise((resolve) => {
|
||||
resolveClone = resolve
|
||||
}))
|
||||
|
||||
render(<CloneVaultModal open={true} onClose={onClose} onVaultCloned={onVaultCloned} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('clone-repo-url'), {
|
||||
target: { value: 'git@github.com:user/my-vault.git' },
|
||||
})
|
||||
|
||||
fireEvent.submit(screen.getByTestId('clone-vault-form'))
|
||||
fireEvent.submit(screen.getByTestId('clone-vault-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('clone-repo-url')).toBeDisabled()
|
||||
expect(screen.getByTestId('clone-vault-path')).toBeDisabled()
|
||||
expect(screen.getByTestId('clone-vault-cancel')).toBeDisabled()
|
||||
expect(screen.getByTestId('clone-vault-submit')).toHaveTextContent('Cloning...')
|
||||
|
||||
resolveClone?.('Cloned successfully')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onVaultCloned).toHaveBeenCalledWith('~/Vaults/my-vault', 'my-vault')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { type ChangeEvent, type FormEvent, useCallback, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,6 +13,13 @@ import { Input } from '@/components/ui/input'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
|
||||
type CloneStatus = 'idle' | 'cloning' | 'error'
|
||||
type CloneAttemptResult =
|
||||
| { ok: true }
|
||||
| { ok: false; errorMessage: string }
|
||||
type CloneRequest = Record<string, unknown> & {
|
||||
url: string
|
||||
localPath: string
|
||||
}
|
||||
|
||||
interface CloneVaultModalProps {
|
||||
open: boolean
|
||||
@@ -25,10 +32,11 @@ interface CloneVaultFormState {
|
||||
localPath: string
|
||||
cloneStatus: CloneStatus
|
||||
cloneError: string | null
|
||||
isCloning: boolean
|
||||
isCloneDisabled: boolean
|
||||
handleClose: () => void
|
||||
handleRepoUrlChange: (value: string) => void
|
||||
handleLocalPathChange: (value: string) => void
|
||||
handleRepoUrlChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
handleLocalPathChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
handleClone: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -36,20 +44,20 @@ function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
|
||||
}
|
||||
|
||||
function repoNameFromUrl(url: string): string {
|
||||
const trimmed = url.trim().replace(/\/+$/g, '')
|
||||
function repoNameFromUrl(request: Pick<CloneRequest, 'url'>): string {
|
||||
const trimmed = request.url.trim().replace(/\/+$/g, '')
|
||||
if (!trimmed) return ''
|
||||
const segment = trimmed.split(/[/:]/).pop() ?? ''
|
||||
return segment.replace(/\.git$/i, '')
|
||||
}
|
||||
|
||||
function suggestedPathFromUrl(url: string): string {
|
||||
const repoName = repoNameFromUrl(url)
|
||||
function suggestedPathFromUrl(request: Pick<CloneRequest, 'url'>): string {
|
||||
const repoName = repoNameFromUrl(request)
|
||||
return repoName ? `~/Vaults/${repoName}` : ''
|
||||
}
|
||||
|
||||
function labelFromPath(path: string): string {
|
||||
const trimmed = path.trim().replace(/\/+$/g, '')
|
||||
function labelFromPath(request: Pick<CloneRequest, 'localPath'>): string {
|
||||
const trimmed = request.localPath.trim().replace(/\/+$/g, '')
|
||||
return trimmed.split('/').pop() || 'Vault'
|
||||
}
|
||||
|
||||
@@ -57,12 +65,22 @@ function shouldSyncSuggestedPath(localPath: string, pathDirty: boolean, previous
|
||||
return !pathDirty || !localPath.trim() || localPath === previousSuggestedPath
|
||||
}
|
||||
|
||||
async function attemptClone(request: CloneRequest): Promise<CloneAttemptResult> {
|
||||
try {
|
||||
await tauriCall<string>('clone_git_repo', request)
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, errorMessage: `Clone failed: ${String(error)}` }
|
||||
}
|
||||
}
|
||||
|
||||
function useCloneVaultForm(onClose: () => void, onVaultCloned: (path: string, label: string) => void): CloneVaultFormState {
|
||||
const [repoUrl, setRepoUrl] = useState('')
|
||||
const [localPath, setLocalPath] = useState('')
|
||||
const [pathDirty, setPathDirty] = useState(false)
|
||||
const [cloneStatus, setCloneStatus] = useState<CloneStatus>('idle')
|
||||
const [cloneError, setCloneError] = useState<string | null>(null)
|
||||
const cloneInFlightRef = useRef(false)
|
||||
const previousSuggestedPathRef = useRef('')
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
@@ -75,15 +93,17 @@ function useCloneVaultForm(onClose: () => void, onVaultCloned: (path: string, la
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (cloneInFlightRef.current) return
|
||||
resetState()
|
||||
onClose()
|
||||
}, [onClose, resetState])
|
||||
|
||||
const handleRepoUrlChange = useCallback((value: string) => {
|
||||
const handleRepoUrlChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
setRepoUrl(value)
|
||||
setCloneError(null)
|
||||
|
||||
const nextSuggestedPath = suggestedPathFromUrl(value)
|
||||
const nextSuggestedPath = suggestedPathFromUrl({ url: value })
|
||||
const previousSuggestedPath = previousSuggestedPathRef.current
|
||||
|
||||
if (shouldSyncSuggestedPath(localPath, pathDirty, previousSuggestedPath)) {
|
||||
@@ -93,7 +113,8 @@ function useCloneVaultForm(onClose: () => void, onVaultCloned: (path: string, la
|
||||
previousSuggestedPathRef.current = nextSuggestedPath
|
||||
}, [localPath, pathDirty])
|
||||
|
||||
const handleLocalPathChange = useCallback((value: string) => {
|
||||
const handleLocalPathChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
setPathDirty(true)
|
||||
setLocalPath(value)
|
||||
setCloneError(null)
|
||||
@@ -102,27 +123,34 @@ function useCloneVaultForm(onClose: () => void, onVaultCloned: (path: string, la
|
||||
const handleClone = useCallback(async () => {
|
||||
const trimmedUrl = repoUrl.trim()
|
||||
const trimmedPath = localPath.trim()
|
||||
if (!trimmedUrl || !trimmedPath) return
|
||||
const request = { url: trimmedUrl, localPath: trimmedPath }
|
||||
if (!request.url || !request.localPath || cloneInFlightRef.current) return
|
||||
|
||||
cloneInFlightRef.current = true
|
||||
setCloneStatus('cloning')
|
||||
setCloneError(null)
|
||||
const result = await attemptClone(request)
|
||||
cloneInFlightRef.current = false
|
||||
|
||||
try {
|
||||
await tauriCall<string>('clone_repo', { url: trimmedUrl, localPath: trimmedPath })
|
||||
onVaultCloned(trimmedPath, labelFromPath(trimmedPath))
|
||||
if (result.ok) {
|
||||
onVaultCloned(request.localPath, labelFromPath({ localPath: request.localPath }))
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
setCloneStatus('error')
|
||||
setCloneError(`Clone failed: ${String(error)}`)
|
||||
return
|
||||
}
|
||||
|
||||
setCloneStatus('error')
|
||||
setCloneError(result.errorMessage)
|
||||
}, [handleClose, localPath, onVaultCloned, repoUrl])
|
||||
|
||||
const isCloning = cloneStatus === 'cloning'
|
||||
|
||||
return {
|
||||
repoUrl,
|
||||
localPath,
|
||||
cloneStatus,
|
||||
cloneError,
|
||||
isCloneDisabled: !repoUrl.trim() || !localPath.trim() || cloneStatus === 'cloning',
|
||||
isCloning,
|
||||
isCloneDisabled: !repoUrl.trim() || !localPath.trim() || isCloning,
|
||||
handleClose,
|
||||
handleRepoUrlChange,
|
||||
handleLocalPathChange,
|
||||
@@ -136,6 +164,7 @@ export function CloneVaultModal({ open, onClose, onVaultCloned }: CloneVaultModa
|
||||
localPath,
|
||||
cloneStatus,
|
||||
cloneError,
|
||||
isCloning,
|
||||
isCloneDisabled,
|
||||
handleClose,
|
||||
handleRepoUrlChange,
|
||||
@@ -143,8 +172,12 @@ export function CloneVaultModal({ open, onClose, onVaultCloned }: CloneVaultModa
|
||||
handleClone,
|
||||
} = useCloneVaultForm(onClose, onVaultCloned)
|
||||
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||
if (!isOpen) handleClose()
|
||||
}, [handleClose])
|
||||
if (!isOpen && !isCloning) handleClose()
|
||||
}, [handleClose, isCloning])
|
||||
const handleSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
void handleClone()
|
||||
}, [handleClone])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
@@ -157,14 +190,15 @@ export function CloneVaultModal({ open, onClose, onVaultCloned }: CloneVaultModa
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<form className="flex flex-col gap-4 py-2" onSubmit={handleSubmit} data-testid="clone-vault-form" aria-busy={isCloning}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground" htmlFor="clone-repo-url">Repository URL</label>
|
||||
<Input
|
||||
id="clone-repo-url"
|
||||
placeholder="git@host:owner/repo.git or https://host/owner/repo.git"
|
||||
value={repoUrl}
|
||||
onChange={(event) => handleRepoUrlChange(event.target.value)}
|
||||
disabled={isCloning}
|
||||
onChange={handleRepoUrlChange}
|
||||
data-testid="clone-repo-url"
|
||||
/>
|
||||
</div>
|
||||
@@ -175,29 +209,41 @@ export function CloneVaultModal({ open, onClose, onVaultCloned }: CloneVaultModa
|
||||
id="clone-vault-path"
|
||||
placeholder="~/Vaults/my-vault"
|
||||
value={localPath}
|
||||
onChange={(event) => handleLocalPathChange(event.target.value)}
|
||||
disabled={isCloning}
|
||||
onChange={handleLocalPathChange}
|
||||
data-testid="clone-vault-path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
SSH keys, the git credential manager, `gh auth`, and other system git auth methods all work.
|
||||
{isCloning
|
||||
? 'Cloning repository… Tolaria will open the vault when git finishes.'
|
||||
: 'SSH keys, the git credential manager, `gh auth`, and other system git auth methods all work.'}
|
||||
</p>
|
||||
|
||||
{cloneError && (
|
||||
<p className="text-xs text-destructive" data-testid="clone-vault-error">{cloneError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-end sm:justify-end">
|
||||
<Button
|
||||
onClick={handleClone}
|
||||
disabled={isCloneDisabled}
|
||||
data-testid="clone-vault-submit"
|
||||
>
|
||||
{cloneStatus === 'cloning' ? 'Cloning...' : 'Clone & Open'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<DialogFooter className="flex-row items-center justify-end sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isCloning}
|
||||
data-testid="clone-vault-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCloneDisabled}
|
||||
data-testid="clone-vault-submit"
|
||||
>
|
||||
{cloneStatus === 'cloning' ? 'Cloning...' : 'Clone & Open'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,45 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge'
|
||||
import { readSelectionRange } from './inlineWikilinkDom'
|
||||
import { CommandPalette } from './CommandPalette'
|
||||
import type { CommandAction } from '../hooks/useCommandRegistry'
|
||||
|
||||
type NativeDropPayload = {
|
||||
type: string
|
||||
paths: string[]
|
||||
position: { x: number; y: number }
|
||||
}
|
||||
type NativeDropHandler = (event: { payload: NativeDropPayload }) => void
|
||||
const nativeDropState = vi.hoisted(() => ({
|
||||
tauriMode: false,
|
||||
handlers: {} as Record<string, NativeDropHandler[] | undefined>,
|
||||
}))
|
||||
|
||||
// jsdom doesn't implement scrollIntoView
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => nativeDropState.tauriMode,
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/api/window', () => ({
|
||||
getCurrentWindow: () => ({
|
||||
onDragDropEvent: vi.fn((handler: NativeDropHandler) => {
|
||||
nativeDropState.handlers['native-drag-drop'] = [
|
||||
...(nativeDropState.handlers['native-drag-drop'] ?? []),
|
||||
handler,
|
||||
]
|
||||
return Promise.resolve(() => {
|
||||
const handlers = nativeDropState.handlers['native-drag-drop']?.filter((candidate) => candidate !== handler) ?? []
|
||||
if (handlers.length > 0) nativeDropState.handlers['native-drag-drop'] = handlers
|
||||
else delete nativeDropState.handlers['native-drag-drop']
|
||||
})
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/aiPromptBridge', () => ({
|
||||
queueAiPrompt: vi.fn(),
|
||||
requestOpenAiChat: vi.fn(),
|
||||
@@ -86,13 +117,60 @@ function updateAiInput(text: string) {
|
||||
return editor
|
||||
}
|
||||
|
||||
function resetNativeDropState() {
|
||||
nativeDropState.tauriMode = false
|
||||
for (const eventName of Object.keys(nativeDropState.handlers)) {
|
||||
delete nativeDropState.handlers[eventName]
|
||||
}
|
||||
}
|
||||
|
||||
function mockElementRect(element: HTMLElement) {
|
||||
Object.defineProperty(element, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 400,
|
||||
bottom: 48,
|
||||
width: 400,
|
||||
height: 48,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function emitNativePathDrop(paths: string[]) {
|
||||
const handlers = nativeDropState.handlers['native-drag-drop']
|
||||
if (!handlers || handlers.length === 0) throw new Error('No native drop handler registered')
|
||||
for (const handler of handlers) {
|
||||
handler({
|
||||
payload: {
|
||||
type: 'drop',
|
||||
paths,
|
||||
position: { x: 20, y: 20 },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForNativePathDropListener() {
|
||||
await waitFor(() => {
|
||||
expect(nativeDropState.handlers['native-drag-drop']?.length).toBeGreaterThan(0)
|
||||
})
|
||||
}
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetNativeDropState()
|
||||
})
|
||||
|
||||
afterEach(resetNativeDropState)
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(
|
||||
<CommandPalette open={false} commands={commands} onClose={onClose} />,
|
||||
@@ -105,6 +183,16 @@ describe('CommandPalette', () => {
|
||||
expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opts the command input out of spellcheck and text correction', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
const input = screen.getByPlaceholderText('Type a command...')
|
||||
|
||||
expect(input).toHaveAttribute('spellcheck', 'false')
|
||||
expect(input).toHaveAttribute('autocorrect', 'off')
|
||||
expect(input).toHaveAttribute('autocapitalize', 'off')
|
||||
expect(input).toHaveAttribute('autocomplete', 'off')
|
||||
})
|
||||
|
||||
it('shows all enabled commands grouped by category', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
expect(screen.getByText('Search Notes')).toBeInTheDocument()
|
||||
@@ -155,6 +243,15 @@ describe('CommandPalette', () => {
|
||||
expect(screen.getByText('No matching commands')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('localizes command palette chrome', () => {
|
||||
render(<CommandPalette open={true} commands={commands} locale="zh-Hans" onClose={onClose} />)
|
||||
const input = screen.getByPlaceholderText('输入命令...')
|
||||
fireEvent.change(input, { target: { value: 'zzzzzzz' } })
|
||||
|
||||
expect(screen.getByText('没有匹配的命令')).toBeInTheDocument()
|
||||
expect(screen.getByText('↑↓ 导航')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClose when pressing Escape', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
@@ -181,6 +278,35 @@ describe('CommandPalette', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a short query keyboard-selectable after ArrowDown and Enter', () => {
|
||||
const changeNoteType = makeCommand({
|
||||
id: 'change-note-type',
|
||||
label: 'Change Note Type…',
|
||||
group: 'Note',
|
||||
})
|
||||
|
||||
render(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
commands={[
|
||||
changeNoteType,
|
||||
makeCommand({ id: 'open-settings', label: 'Open Settings', group: 'Settings' }),
|
||||
]}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'ch' } })
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
|
||||
const selectedRow = screen.getByText('Change Note Type…').closest('[data-selected]')
|
||||
expect(selectedRow).toHaveAttribute('data-selected', 'true')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
expect(changeNoteType.execute).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not go below the last item', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
@@ -236,6 +362,24 @@ describe('CommandPalette', () => {
|
||||
expect(screen.queryByText('Search Notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('inserts Tauri native folder drops into the command query input', async () => {
|
||||
nativeDropState.tauriMode = true
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('Type a command...') as HTMLInputElement
|
||||
mockElementRect(input)
|
||||
input.focus()
|
||||
await waitForNativePathDropListener()
|
||||
|
||||
act(() => {
|
||||
emitNativePathDrop(['/Users/test/Projects'])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(input).toHaveValue('/Users/test/Projects')
|
||||
})
|
||||
})
|
||||
|
||||
it('focuses the AI editor immediately when the leading space triggers AI mode', () => {
|
||||
render(<CommandPalette open={true} commands={commands} entries={entries} onClose={onClose} />)
|
||||
|
||||
|
||||
@@ -6,7 +6,13 @@ 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 { localizeCommandGroup } from '../hooks/commands/localizeCommands'
|
||||
import { rememberFeedbackDialogOpener } from '../lib/feedbackDialogOpener'
|
||||
import { createTranslator, type AppLocale } from '../lib/i18n'
|
||||
import { formatDroppedPathList } from './inlineWikilinkDropText'
|
||||
import { CommandPaletteAiMode } from './CommandPaletteAiMode'
|
||||
import { Input } from './ui/input'
|
||||
import { useNativePathDrop } from './useNativePathDrop'
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
@@ -15,6 +21,7 @@ interface CommandPaletteProps {
|
||||
claudeCodeReady?: boolean
|
||||
aiAgentReady?: boolean
|
||||
aiAgentLabel?: string
|
||||
locale?: AppLocale
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -105,22 +112,66 @@ 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 inputSelectionRange(input: HTMLInputElement, fallbackIndex: number) {
|
||||
const start = input.selectionStart ?? fallbackIndex
|
||||
const end = input.selectionEnd ?? start
|
||||
return {
|
||||
start: Math.max(0, start),
|
||||
end: Math.max(start, end),
|
||||
}
|
||||
}
|
||||
|
||||
function CommandPaletteInput({
|
||||
inputRef,
|
||||
query,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>
|
||||
query: string
|
||||
onChange: (value: string) => void
|
||||
placeholder: string
|
||||
}) {
|
||||
const insertNativePathDrop = (paths: string[]) => {
|
||||
const droppedPathText = formatDroppedPathList(paths)
|
||||
const input = inputRef.current
|
||||
if (!droppedPathText || !input) return
|
||||
|
||||
const { start, end } = inputSelectionRange(input, query.length)
|
||||
const nextValue = `${query.slice(0, start)}${droppedPathText}${query.slice(end)}`
|
||||
const nextCursor = start + droppedPathText.length
|
||||
|
||||
onChange(nextValue)
|
||||
window.requestAnimationFrame(() => {
|
||||
input.focus()
|
||||
input.setSelectionRange(nextCursor, nextCursor)
|
||||
})
|
||||
}
|
||||
|
||||
useNativePathDrop({
|
||||
targetRef: inputRef,
|
||||
onPathDrop: insertNativePathDrop,
|
||||
})
|
||||
|
||||
return (
|
||||
<input
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
className="h-auto rounded-none border-x-0 border-t-0 border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground shadow-none transition-none outline-none placeholder:text-muted-foreground focus-visible:border-border focus-visible:ring-0 md:text-[15px]"
|
||||
type="text"
|
||||
placeholder="Type a command..."
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
spellCheck={false}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)
|
||||
@@ -130,12 +181,16 @@ function CommandPaletteResults({
|
||||
groups,
|
||||
selectedIndex,
|
||||
listRef,
|
||||
emptyText,
|
||||
locale,
|
||||
onHover,
|
||||
onSelect,
|
||||
}: {
|
||||
groups: { group: CommandGroup; items: CommandAction[] }[]
|
||||
selectedIndex: number
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
emptyText: string
|
||||
locale: AppLocale
|
||||
onHover: (index: number) => void
|
||||
onSelect: (command: CommandAction) => void
|
||||
}) {
|
||||
@@ -145,7 +200,7 @@ function CommandPaletteResults({
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto py-1" ref={listRef}>
|
||||
<div className="px-4 py-6 text-center text-[13px] text-muted-foreground">
|
||||
No matching commands
|
||||
{emptyText}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -169,7 +224,7 @@ function CommandPaletteResults({
|
||||
return (
|
||||
<div key={group}>
|
||||
<div className="px-4 pb-1 pt-2 text-[11px] font-medium text-muted-foreground">
|
||||
{group}
|
||||
{localizeCommandGroup(group, locale)}
|
||||
</div>
|
||||
{items.map((command, index) => {
|
||||
const globalIndex = startIndex + index
|
||||
@@ -193,15 +248,23 @@ function CommandPaletteResults({
|
||||
function CommandPaletteFooter({
|
||||
aiMode,
|
||||
aiAgentLabel = 'Claude Code',
|
||||
footerText,
|
||||
}: {
|
||||
aiMode: boolean
|
||||
aiAgentLabel?: string
|
||||
footerText: {
|
||||
aiMode: string
|
||||
navigate: string
|
||||
select: string
|
||||
send: string
|
||||
close: string
|
||||
}
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>{aiMode ? `${aiAgentLabel} mode` : '↑↓ navigate'}</span>
|
||||
<span>{aiMode ? '↵ send' : '↵ select'}</span>
|
||||
<span>esc close</span>
|
||||
<span>{aiMode ? footerText.aiMode.replace('{agent}', aiAgentLabel) : footerText.navigate}</span>
|
||||
<span>{aiMode ? footerText.send : footerText.select}</span>
|
||||
<span>{footerText.close}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -217,6 +280,7 @@ function OpenCommandPalette({
|
||||
claudeCodeReady = true,
|
||||
aiAgentReady,
|
||||
aiAgentLabel = 'Claude Code',
|
||||
locale = 'en',
|
||||
onClose,
|
||||
}: Omit<CommandPaletteProps, 'open'>) {
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -228,6 +292,14 @@ function OpenCommandPalette({
|
||||
const aiMode = aiValue.startsWith(' ')
|
||||
const resolvedAiAgentReady = aiAgentReady ?? claudeCodeReady
|
||||
const { groups, flatList } = usePaletteResults(commands, query)
|
||||
const t = createTranslator(locale)
|
||||
const footerText = {
|
||||
aiMode: t('command.aiMode', { agent: '{agent}' }),
|
||||
navigate: t('command.footerNavigate'),
|
||||
select: t('command.footerSelect'),
|
||||
send: t('command.footerSend'),
|
||||
close: t('command.footerClose'),
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const target = aiMode ? aiInputRef.current : inputRef.current
|
||||
@@ -274,6 +346,7 @@ function OpenCommandPalette({
|
||||
event.preventDefault()
|
||||
const command = flatList[selectedIndex]
|
||||
if (!command) return
|
||||
rememberCommandOpener(command, inputRef.current)
|
||||
onClose()
|
||||
command.execute()
|
||||
}
|
||||
@@ -306,6 +379,7 @@ function OpenCommandPalette({
|
||||
}
|
||||
|
||||
const handleSelectCommand = (command: CommandAction) => {
|
||||
rememberCommandOpener(command, inputRef.current)
|
||||
onClose()
|
||||
command.execute()
|
||||
}
|
||||
@@ -348,15 +422,22 @@ function OpenCommandPalette({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<CommandPaletteInput inputRef={inputRef} query={query} onChange={handleQueryChange} />
|
||||
<CommandPaletteInput
|
||||
inputRef={inputRef}
|
||||
query={query}
|
||||
placeholder={t('command.palettePlaceholder')}
|
||||
onChange={handleQueryChange}
|
||||
/>
|
||||
<CommandPaletteResults
|
||||
groups={groups}
|
||||
selectedIndex={selectedIndex}
|
||||
listRef={listRef}
|
||||
emptyText={t('command.noMatches')}
|
||||
locale={locale}
|
||||
onHover={setSelectedIndex}
|
||||
onSelect={handleSelectCommand}
|
||||
/>
|
||||
<CommandPaletteFooter aiMode={false} aiAgentLabel={aiAgentLabel} />
|
||||
<CommandPaletteFooter aiMode={false} aiAgentLabel={aiAgentLabel} footerText={footerText} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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,81 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { ConfirmDeleteDialog } from './ConfirmDeleteDialog'
|
||||
|
||||
describe('ConfirmDeleteDialog', () => {
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
title: 'Delete permanently?',
|
||||
message: 'This cannot be undone.',
|
||||
}
|
||||
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
function renderDialog(overrides: Partial<React.ComponentProps<typeof ConfirmDeleteDialog>> = {}) {
|
||||
return render(
|
||||
<ConfirmDeleteDialog
|
||||
{...defaultProps}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
{...overrides}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ConfirmDeleteDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders with title and message', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
renderDialog()
|
||||
expect(screen.getByText('Delete permanently?')).toBeInTheDocument()
|
||||
expect(screen.getByText('This cannot be undone.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm when delete button clicked', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
renderDialog()
|
||||
fireEvent.click(screen.getByTestId('confirm-delete-btn'))
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('focuses the destructive action when the dialog opens', async () => {
|
||||
renderDialog()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-delete-btn')).toHaveFocus()
|
||||
})
|
||||
})
|
||||
|
||||
it('submits the destructive action when Enter is pressed', () => {
|
||||
renderDialog()
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('confirm-delete-dialog'), { key: 'Enter' })
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not submit twice when Enter repeats', () => {
|
||||
renderDialog()
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('confirm-delete-dialog'), { key: 'Enter' })
|
||||
fireEvent.keyDown(screen.getByTestId('confirm-delete-dialog'), { key: 'Enter', repeat: true })
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('treats Enter as the primary confirm shortcut even from the cancel button', () => {
|
||||
renderDialog()
|
||||
|
||||
fireEvent.keyDown(screen.getByText('Cancel'), { key: 'Enter' })
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button clicked', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
renderDialog()
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not render when open is false', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={false}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
renderDialog({ open: false })
|
||||
expect(screen.queryByText('Delete permanently?')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses custom confirm label when provided', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Empty Trash?"
|
||||
message="Delete all notes?"
|
||||
confirmLabel="Empty Trash"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
renderDialog({
|
||||
title: 'Empty Trash?',
|
||||
message: 'Delete all notes?',
|
||||
confirmLabel: 'Empty Trash',
|
||||
})
|
||||
expect(screen.getByText('Empty Trash')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo } from 'react'
|
||||
import { memo, useCallback, useEffect, useRef } from 'react'
|
||||
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import { Trash } from '@phosphor-icons/react'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,6 +20,48 @@ interface ConfirmDeleteDialogProps {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function focusConfirmButton(confirmButton: HTMLButtonElement | null) {
|
||||
confirmButton?.focus()
|
||||
}
|
||||
|
||||
function focusPrimaryDeleteButton(root: ParentNode | null) {
|
||||
focusConfirmButton(root?.querySelector<HTMLButtonElement>('[data-confirm-delete-primary="true"]') ?? null)
|
||||
}
|
||||
|
||||
function scheduleConfirmButtonFocus(root: ParentNode | null) {
|
||||
const focusDelays = [0, 50, 150]
|
||||
return focusDelays.map((delay) => (
|
||||
window.setTimeout(() => {
|
||||
focusPrimaryDeleteButton(root)
|
||||
}, delay)
|
||||
))
|
||||
}
|
||||
|
||||
type ConfirmShortcutEvent = Pick<
|
||||
KeyboardEvent,
|
||||
'key' | 'defaultPrevented' | 'repeat' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'
|
||||
> & {
|
||||
isComposing?: boolean
|
||||
nativeEvent?: Pick<KeyboardEvent, 'isComposing'>
|
||||
}
|
||||
|
||||
function isComposingEvent(event: ConfirmShortcutEvent) {
|
||||
return event.isComposing || event.nativeEvent?.isComposing === true
|
||||
}
|
||||
|
||||
function isConfirmShortcut(event: ConfirmShortcutEvent) {
|
||||
return (
|
||||
event.key === 'Enter'
|
||||
&& !event.defaultPrevented
|
||||
&& !event.repeat
|
||||
&& !event.metaKey
|
||||
&& !event.ctrlKey
|
||||
&& !event.altKey
|
||||
&& !event.shiftKey
|
||||
&& !isComposingEvent(event)
|
||||
)
|
||||
}
|
||||
|
||||
export const ConfirmDeleteDialog = memo(function ConfirmDeleteDialog({
|
||||
open,
|
||||
title,
|
||||
@@ -27,9 +70,88 @@ export const ConfirmDeleteDialog = memo(function ConfirmDeleteDialog({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDeleteDialogProps) {
|
||||
const confirmingRef = useRef(false)
|
||||
const cancelFocusIsIntentionalRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
confirmingRef.current = false
|
||||
cancelFocusIsIntentionalRef.current = false
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
focusPrimaryDeleteButton(document)
|
||||
const timeoutIds = scheduleConfirmButtonFocus(document)
|
||||
|
||||
return () => {
|
||||
timeoutIds.forEach((timeoutId) => {
|
||||
window.clearTimeout(timeoutId)
|
||||
})
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (confirmingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
confirmingRef.current = true
|
||||
onConfirm()
|
||||
}, [onConfirm])
|
||||
|
||||
const handleSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
handleConfirm()
|
||||
}, [handleConfirm])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isConfirmShortcut(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
handleConfirm()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleWindowKeyDown, true)
|
||||
}
|
||||
}, [handleConfirm, open])
|
||||
|
||||
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isConfirmShortcut(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
handleConfirm()
|
||||
}, [handleConfirm])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onCancel() }}>
|
||||
<DialogContent showCloseButton={false} data-testid="confirm-delete-dialog">
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
data-testid="confirm-delete-dialog"
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDownCapture={(event) => {
|
||||
if (event.key === 'Tab') {
|
||||
cancelFocusIsIntentionalRef.current = true
|
||||
}
|
||||
}}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
const dialogRoot = event.currentTarget as ParentNode | null
|
||||
focusPrimaryDeleteButton(dialogRoot)
|
||||
scheduleConfirmButtonFocus(dialogRoot)
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash size={18} className="text-destructive" />
|
||||
@@ -37,12 +159,38 @@ export const ConfirmDeleteDialog = memo(function ConfirmDeleteDialog({
|
||||
</DialogTitle>
|
||||
<DialogDescription>{message}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} data-testid="confirm-delete-btn">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
data-testid="confirm-delete-btn"
|
||||
data-confirm-delete-primary="true"
|
||||
autoFocus
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
onFocus={() => {
|
||||
if (cancelFocusIsIntentionalRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
focusPrimaryDeleteButton(document)
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
cancelFocusIsIntentionalRef.current = true
|
||||
}}
|
||||
data-confirm-delete-cancel="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
|
||||
interface ConflictNoteBannerProps {
|
||||
onKeepMine: () => void
|
||||
onKeepTheirs: () => void
|
||||
locale?: AppLocale
|
||||
}
|
||||
|
||||
export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBannerProps) {
|
||||
export function ConflictNoteBanner({ onKeepMine, onKeepTheirs, locale = 'en' }: ConflictNoteBannerProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="conflict-note-banner"
|
||||
@@ -22,7 +24,7 @@ export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBan
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={13} />
|
||||
<span>This note has a merge conflict</span>
|
||||
<span>{translate(locale, 'editor.banner.conflict')}</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
data-testid="conflict-keep-mine-btn"
|
||||
@@ -39,9 +41,9 @@ export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBan
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Keep my local version"
|
||||
title={translate(locale, 'editor.banner.keepMineTooltip')}
|
||||
>
|
||||
Keep mine
|
||||
{translate(locale, 'editor.banner.keepMine')}
|
||||
</button>
|
||||
<button
|
||||
data-testid="conflict-keep-theirs-btn"
|
||||
@@ -58,9 +60,9 @@ export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBan
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Keep the remote version"
|
||||
title={translate(locale, 'editor.banner.keepTheirsTooltip')}
|
||||
>
|
||||
Keep theirs
|
||||
{translate(locale, 'editor.banner.keepTheirs')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -205,6 +205,19 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByText('some-team')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('localizes UI actions without translating stored property names', () => {
|
||||
renderPanel({
|
||||
frontmatter: { Status: 'Active', 'Belongs to': 'some-team' },
|
||||
onAddProperty,
|
||||
locale: 'zh-Hans',
|
||||
})
|
||||
|
||||
expect(screen.getByText('Type')).toBeInTheDocument()
|
||||
expect(screen.getByText('Status')).toBeInTheDocument()
|
||||
expect(screen.getByText('Belongs to')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '添加属性' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides custom field with wikilink value from Properties', () => {
|
||||
renderPanel({ frontmatter: { Mentor: '[[person/luca]]' } })
|
||||
// Mentor contains a wikilink → shown in Relationships, not Properties
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
PROPERTY_PANEL_ROW_STYLE,
|
||||
} from './propertyPanelLayout'
|
||||
import { humanizePropertyKey } from '../utils/propertyLabels'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { canonicalSystemMetadataKey, hasSystemMetadataKey } from '../utils/systemMetadata'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
|
||||
@@ -32,7 +33,7 @@ export function containsWikilinks(value: FrontmatterValue): boolean {
|
||||
|
||||
const PROPERTY_ROW_CLASS_NAME = 'group/prop grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary'
|
||||
|
||||
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
|
||||
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, locale, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
|
||||
propKey: string; value: FrontmatterValue; editingKey: string | null
|
||||
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
|
||||
vaultStatuses: string[]; vaultTags: string[]
|
||||
@@ -40,6 +41,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
onSaveList: (key: string, items: string[]) => void
|
||||
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
|
||||
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
|
||||
locale: AppLocale
|
||||
}) {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.target !== e.currentTarget) {
|
||||
@@ -57,7 +59,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
|
||||
<span className="min-w-0 flex-1 truncate">{humanizePropertyKey(propKey)}</span>
|
||||
{onDelete && (
|
||||
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">×</button>
|
||||
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title={translate(locale, 'inspector.properties.deleteProperty')}>×</button>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
@@ -67,7 +69,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
|
||||
)
|
||||
}
|
||||
|
||||
function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) {
|
||||
function AddPropertyButton({ locale, onClick, disabled }: { locale: AppLocale; onClick: () => void; disabled: boolean }) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -86,7 +88,7 @@ function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disable
|
||||
>
|
||||
<Plus className="size-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 truncate">Add property</span>
|
||||
<span className="min-w-0 truncate">{translate(locale, 'inspector.properties.addProperty')}</span>
|
||||
</span>
|
||||
<span aria-hidden="true" className={PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME} />
|
||||
</Button>
|
||||
@@ -230,6 +232,7 @@ function useSuggestedPropertyActions({
|
||||
export function DynamicPropertiesPanel({
|
||||
entry, frontmatter, entries,
|
||||
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate, onCreateMissingType,
|
||||
locale = 'en',
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
content?: string | null
|
||||
@@ -240,6 +243,7 @@ export function DynamicPropertiesPanel({
|
||||
onAddProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onNavigate?: (target: string) => void
|
||||
onCreateMissingType?: (typeName: string) => boolean | void | Promise<boolean | void>
|
||||
locale?: AppLocale
|
||||
}) {
|
||||
const {
|
||||
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
||||
@@ -279,6 +283,7 @@ export function DynamicPropertiesPanel({
|
||||
onNavigate={onNavigate}
|
||||
missingTypeName={missingTypeName}
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
locale={locale}
|
||||
/>
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<PropertyRow
|
||||
@@ -290,6 +295,7 @@ export function DynamicPropertiesPanel({
|
||||
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
|
||||
onDelete={onDeleteProperty}
|
||||
onDisplayModeChange={handleDisplayModeChange}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
{pendingSuggestedKey && editingKey === pendingSuggestedKey && (
|
||||
@@ -308,6 +314,7 @@ export function DynamicPropertiesPanel({
|
||||
onUpdate={undefined}
|
||||
onDelete={undefined}
|
||||
onDisplayModeChange={handleDisplayModeChange}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
{missingSuggested.map(({ key, label }) => (
|
||||
@@ -320,6 +327,7 @@ export function DynamicPropertiesPanel({
|
||||
))}
|
||||
{!showAddDialog && (
|
||||
<AddPropertyButton
|
||||
locale={locale}
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
disabled={!onAddProperty}
|
||||
/>
|
||||
@@ -330,6 +338,7 @@ export function DynamicPropertiesPanel({
|
||||
onAdd={handleAdd}
|
||||
onCancel={() => setShowAddDialog(false)}
|
||||
vaultStatuses={vaultStatuses}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
@@ -204,6 +204,16 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-content-layout--left .editor-content-wrapper {
|
||||
margin-left: clamp(16px, 6%, 96px);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.raw-editor-codemirror {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* --- Note Icon Area --- */
|
||||
.note-icon-area {
|
||||
display: flex;
|
||||
@@ -246,7 +256,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 +284,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 +307,7 @@
|
||||
}
|
||||
|
||||
.note-icon-menu__item--danger {
|
||||
color: var(--destructive, #ef4444);
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
@@ -322,3 +332,10 @@
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@container editor (max-width: 900px) {
|
||||
.editor-content-layout--left .editor-content-wrapper {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 { beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
|
||||
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
@@ -16,6 +18,11 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
})),
|
||||
})
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
convertFileSrc: vi.fn((path: string) => `asset://localhost/${encodeURIComponent(path)}`),
|
||||
invoke: vi.fn(),
|
||||
}))
|
||||
|
||||
// Hoisted mock editor — available before vi.mock factory runs.
|
||||
// Tests can reconfigure spies (e.g. mockTryParse.mockResolvedValue) before rendering.
|
||||
const mockEditor = vi.hoisted(() => ({
|
||||
@@ -33,6 +40,9 @@ const mockEditor = vi.hoisted(() => ({
|
||||
focus: vi.fn(),
|
||||
setTextCursorPosition: vi.fn(),
|
||||
}))
|
||||
const blockNoteCreation = vi.hoisted(() => ({
|
||||
options: [] as unknown[],
|
||||
}))
|
||||
|
||||
// Mock BlockNote components
|
||||
vi.mock('@blocknote/core', () => ({
|
||||
@@ -58,9 +68,14 @@ const capturedGetItemsByTrigger: Record<string, (query: string) => Promise<any[]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
|
||||
let capturedGetItems: ((query: string) => Promise<any[]>) | null = null
|
||||
vi.mock('@blocknote/react', () => ({
|
||||
createReactBlockSpec: () => () => ({}),
|
||||
createReactInlineContentSpec: () => ({ render: () => null }),
|
||||
useCreateBlockNote: () => mockEditor,
|
||||
useCreateBlockNote: (options: unknown) => {
|
||||
blockNoteCreation.options.push(options)
|
||||
return mockEditor
|
||||
},
|
||||
FormattingToolbar: ({ children }: PropsWithChildren) => <>{children}</>,
|
||||
LinkToolbar: ({ children }: PropsWithChildren) => <>{children}</>,
|
||||
getFormattingToolbarItems: () => [],
|
||||
getDefaultReactSlashMenuItems: () => [],
|
||||
ComponentsContext: {
|
||||
@@ -68,10 +83,18 @@ vi.mock('@blocknote/react', () => ({
|
||||
},
|
||||
BlockNoteViewRaw: ({ children, editable }: PropsWithChildren<{ editable?: boolean }>) => (
|
||||
<div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>
|
||||
<div
|
||||
contentEditable={editable !== false}
|
||||
data-testid="blocknote-editable"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{children}
|
||||
</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 +102,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', () => ({
|
||||
@@ -180,10 +222,19 @@ function renderEditor(overrides: Partial<EditorComponentProps> = {}) {
|
||||
}
|
||||
|
||||
describe('Editor', () => {
|
||||
beforeEach(() => {
|
||||
blockNoteCreation.options = []
|
||||
})
|
||||
|
||||
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', () => {
|
||||
@@ -209,6 +260,124 @@ describe('Editor', () => {
|
||||
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an in-app image preview for binary image tabs', () => {
|
||||
const imageEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
path: '/vault/assets/photo.png',
|
||||
filename: 'photo.png',
|
||||
title: 'photo.png',
|
||||
fileKind: 'binary',
|
||||
}
|
||||
|
||||
renderEditor({
|
||||
tabs: [{ entry: imageEntry, content: '' }],
|
||||
activeTabPath: imageEntry.path,
|
||||
entries: [imageEntry],
|
||||
})
|
||||
|
||||
const preview = screen.getByTestId('file-preview')
|
||||
expect(preview).toHaveAttribute('tabindex', '0')
|
||||
expect(screen.getByRole('img', { name: 'photo.png' })).toHaveAttribute(
|
||||
'src',
|
||||
'asset://localhost/%2Fvault%2Fassets%2Fphoto.png',
|
||||
)
|
||||
expect(screen.queryByTestId('blocknote-view')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a graceful fallback when an image preview fails to render', () => {
|
||||
const imageEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
path: '/vault/assets/broken.png',
|
||||
filename: 'broken.png',
|
||||
title: 'broken.png',
|
||||
fileKind: 'binary',
|
||||
}
|
||||
|
||||
renderEditor({
|
||||
tabs: [{ entry: imageEntry, content: '' }],
|
||||
activeTabPath: imageEntry.path,
|
||||
entries: [imageEntry],
|
||||
})
|
||||
|
||||
fireEvent.error(screen.getByRole('img', { name: 'broken.png' }))
|
||||
|
||||
expect(screen.getByTestId('file-preview-fallback')).toHaveTextContent('Image preview failed')
|
||||
expect(screen.getByRole('button', { name: 'Open in default app' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an explicit unsupported-file fallback for non-image binary tabs', () => {
|
||||
const binaryEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
path: '/vault/assets/archive.zip',
|
||||
filename: 'archive.zip',
|
||||
title: 'archive.zip',
|
||||
fileKind: 'binary',
|
||||
}
|
||||
|
||||
renderEditor({
|
||||
tabs: [{ entry: binaryEntry, content: '' }],
|
||||
activeTabPath: binaryEntry.path,
|
||||
entries: [binaryEntry],
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('file-preview-fallback')).toHaveTextContent('Preview unavailable')
|
||||
expect(screen.getByText('ZIP file')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('moves focus back to the note list when Escape is pressed on the file preview', () => {
|
||||
const imageEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
path: '/vault/assets/photo.png',
|
||||
filename: 'photo.png',
|
||||
title: 'photo.png',
|
||||
fileKind: 'binary',
|
||||
}
|
||||
|
||||
render(
|
||||
<>
|
||||
<div data-testid="note-list-container" tabIndex={0} />
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[{ entry: imageEntry, content: '' }]}
|
||||
activeTabPath={imageEntry.path}
|
||||
entries={[imageEntry]}
|
||||
/>
|
||||
</>,
|
||||
)
|
||||
|
||||
const preview = screen.getByTestId('file-preview')
|
||||
preview.focus()
|
||||
fireEvent.keyDown(preview, { key: 'Escape' })
|
||||
|
||||
expect(screen.getByTestId('note-list-container')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('passes the runtime CSP style nonce into BlockNote and TipTap', () => {
|
||||
renderEditor({
|
||||
tabs: [mockTab],
|
||||
activeTabPath: mockEntry.path,
|
||||
})
|
||||
|
||||
expect(blockNoteCreation.options.at(-1)).toMatchObject({
|
||||
_tiptapOptions: {
|
||||
injectNonce: RUNTIME_STYLE_NONCE,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('disables native text assistance on the rich editor editable surface', () => {
|
||||
renderEditor({
|
||||
tabs: [mockTab],
|
||||
activeTabPath: mockEntry.path,
|
||||
})
|
||||
|
||||
const editable = screen.getByTestId('blocknote-editable')
|
||||
expect(editable).toHaveAttribute('spellcheck', 'false')
|
||||
expect(editable).toHaveAttribute('autocorrect', 'off')
|
||||
expect(editable).toHaveAttribute('autocomplete', 'off')
|
||||
expect(editable).toHaveAttribute('autocapitalize', 'off')
|
||||
})
|
||||
|
||||
it('renders breadcrumb bar with action buttons', () => {
|
||||
renderEditor({
|
||||
tabs: [mockTab],
|
||||
@@ -537,6 +706,53 @@ describe('raw-mode sync content guards', () => {
|
||||
expect(rawLatestContentRef.current).toBe(result)
|
||||
})
|
||||
|
||||
it('serializes rich math nodes back to Markdown source when entering raw mode', () => {
|
||||
const rawLatestContentRef = { current: null as string | null }
|
||||
const originalDocument = mockEditor.document
|
||||
const originalSerializer = mockEditor.blocksToMarkdownLossy.getMockImplementation()
|
||||
|
||||
try {
|
||||
mockEditor.document = [
|
||||
{
|
||||
id: 'math-inline',
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Inline ', styles: {} },
|
||||
{ type: 'mathInline', props: { latex: 'E=mc^2' } },
|
||||
],
|
||||
props: {},
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'math-block',
|
||||
type: 'mathBlock',
|
||||
props: { latex: '\\int_0^1 x\\,dx' },
|
||||
children: [],
|
||||
},
|
||||
]
|
||||
mockEditor.blocksToMarkdownLossy.mockImplementation((blocks: unknown[]) => (
|
||||
(blocks as Array<{ content?: Array<{ text?: string }> }>)
|
||||
.map((block) => block.content?.map((item) => item.text ?? '').join('') ?? '')
|
||||
.join('\n\n')
|
||||
))
|
||||
|
||||
const result = syncActiveTabIntoRawBuffer({
|
||||
editor: mockEditor as never,
|
||||
activeTabPath: mockEntry.path,
|
||||
activeTabContent: mockContent,
|
||||
rawLatestContentRef,
|
||||
})
|
||||
|
||||
expect(result).toBe(
|
||||
'---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\nInline $E=mc^2$\n\n$$\n\\int_0^1 x\\,dx\n$$\n',
|
||||
)
|
||||
expect(rawLatestContentRef.current).toBe(result)
|
||||
} finally {
|
||||
mockEditor.document = originalDocument
|
||||
mockEditor.blocksToMarkdownLossy.mockImplementation(originalSerializer)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not emit a content change when leaving raw mode without user edits', () => {
|
||||
const onContentChange = vi.fn()
|
||||
const normalizedContent = '---\ntitle: Test Project\nis_a: Project\nStatus: Active\n---\n# Test Project\n\nThis is a test note with some words to count.\n'
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { useRef, useEffect, useCallback, memo } from 'react'
|
||||
import { useRef, useEffect, useCallback, memo, useState } from 'react'
|
||||
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
|
||||
import { useCreateBlockNote } from '@blocknote/react'
|
||||
import '@blocknote/mantine/style.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { uploadImageFile } from '../hooks/useImageDrop'
|
||||
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
|
||||
import type { VaultEntry, GitCommit, NoteStatus } from '../types'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
|
||||
import type { VaultEntry, GitCommit, NoteLayout, NoteStatus } from '../types'
|
||||
import type { NoteListItem } from '../utils/ai-context'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
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 { FilePreview } from './FilePreview'
|
||||
import { schema } from './editorSchema'
|
||||
import type { RawEditorFindRequest } from './RawEditorFindBar'
|
||||
import {
|
||||
applyPendingRawExitContent,
|
||||
resolvePendingRawExitContent,
|
||||
@@ -21,6 +27,7 @@ import {
|
||||
} from './editorRawModeSync'
|
||||
import { useRawModeWithFlush } from './useRawModeWithFlush'
|
||||
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
|
||||
import { createMathInputExtension } from './mathInputExtension'
|
||||
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
|
||||
import './Editor.css'
|
||||
import './EditorTheme.css'
|
||||
@@ -35,6 +42,7 @@ interface EditorProps {
|
||||
activeTabPath: string | null
|
||||
entries: VaultEntry[]
|
||||
onNavigateWikilink: (target: string) => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
onLoadDiff?: (path: string) => Promise<string>
|
||||
onLoadDiffAtCommit?: (path: string, commitHash: string) => Promise<string>
|
||||
pendingCommitDiffRequest?: CommitDiffRequest | null
|
||||
@@ -63,6 +71,9 @@ interface EditorProps {
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onRevealFile?: (path: string) => void
|
||||
onCopyFilePath?: (path: string) => void
|
||||
onOpenExternalFile?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
@@ -70,6 +81,8 @@ interface EditorProps {
|
||||
onSave?: () => void
|
||||
/** Called when the user explicitly renames the filename from the breadcrumb. */
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
canGoBack?: boolean
|
||||
canGoForward?: boolean
|
||||
onGoBack?: () => void
|
||||
@@ -77,6 +90,8 @@ interface EditorProps {
|
||||
leftPanelsCollapsed?: boolean
|
||||
/** Mutable ref that Editor registers its raw-mode toggle into, for command palette access. */
|
||||
rawToggleRef?: React.MutableRefObject<() => void>
|
||||
/** Mutable ref that Editor registers editor find commands into, for shortcuts and menus. */
|
||||
findInNoteRef?: React.MutableRefObject<((options?: { replace?: boolean }) => void) | null>
|
||||
/** Mutable ref that Editor registers its diff-mode toggle into, for command palette access. */
|
||||
diffToggleRef?: React.MutableRefObject<() => void>
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
@@ -90,6 +105,7 @@ interface EditorProps {
|
||||
onKeepTheirs?: (path: string) => void
|
||||
/** Registers a hook that flushes the raw editor buffer into app state before external actions. */
|
||||
flushPendingRawContentRef?: React.MutableRefObject<((path: string) => void) | null>
|
||||
locale?: AppLocale
|
||||
}
|
||||
|
||||
function useEditorModeExclusion({
|
||||
@@ -123,9 +139,11 @@ function useEditorModeExclusion({
|
||||
return { handleToggleDiffExclusive, handleToggleRawExclusive }
|
||||
}
|
||||
|
||||
function EditorEmptyState() {
|
||||
function EditorEmptyState({ locale = 'en' }: { locale?: AppLocale }) {
|
||||
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">
|
||||
@@ -138,8 +156,8 @@ function EditorEmptyState() {
|
||||
style={{ height: breadcrumbBarHeight }}
|
||||
/>
|
||||
<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>
|
||||
<p className="m-0 text-[15px]">{translate(locale, 'editor.empty.selectNote')}</p>
|
||||
<span className="text-xs text-muted-foreground">{translate(locale, 'editor.empty.shortcuts', { quickOpen: quickOpenShortcut, newNote: newNoteShortcut })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -170,7 +188,8 @@ function useEditorSetup({
|
||||
const editor = useCreateBlockNote({
|
||||
schema,
|
||||
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
|
||||
extensions: [createArrowLigaturesExtension()],
|
||||
_tiptapOptions: { injectNonce: RUNTIME_STYLE_NONCE },
|
||||
extensions: [createArrowLigaturesExtension(), createMathInputExtension()],
|
||||
})
|
||||
useFilenameAutolinkGuard(editor)
|
||||
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
|
||||
@@ -263,6 +282,43 @@ function useRegisterRawContentFlush({
|
||||
}, [flushPendingRawContent, flushPendingRawContentRef])
|
||||
}
|
||||
|
||||
function useEditorFindCommand({
|
||||
activeTab,
|
||||
findInNoteRef,
|
||||
handleToggleRawExclusive,
|
||||
rawMode,
|
||||
}: {
|
||||
activeTab: Tab | null
|
||||
findInNoteRef?: EditorProps['findInNoteRef']
|
||||
handleToggleRawExclusive: () => void
|
||||
rawMode: boolean
|
||||
}): RawEditorFindRequest | null {
|
||||
const [findRequest, setFindRequest] = useState<RawEditorFindRequest | null>(null)
|
||||
const handleFindInNote = useCallback((options: { replace?: boolean } = {}) => {
|
||||
if (!activeTab || activeTab.entry.fileKind === 'binary') return
|
||||
if (!rawMode) handleToggleRawExclusive()
|
||||
|
||||
setFindRequest((current) => ({
|
||||
id: (current?.id ?? 0) + 1,
|
||||
path: activeTab.entry.path,
|
||||
replace: options.replace === true,
|
||||
}))
|
||||
}, [activeTab, handleToggleRawExclusive, rawMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!findInNoteRef) return
|
||||
|
||||
findInNoteRef.current = handleFindInNote
|
||||
return () => {
|
||||
if (findInNoteRef.current === handleFindInNote) {
|
||||
findInNoteRef.current = null
|
||||
}
|
||||
}
|
||||
}, [findInNoteRef, handleFindInNote])
|
||||
|
||||
return findRequest
|
||||
}
|
||||
|
||||
function EditorLayout({
|
||||
tabs,
|
||||
activeTab,
|
||||
@@ -287,13 +343,19 @@ function EditorLayout({
|
||||
handleEditorChange,
|
||||
onToggleFavorite,
|
||||
onToggleOrganized,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
onOpenExternalFile,
|
||||
onDeleteNote,
|
||||
onArchiveNote,
|
||||
onUnarchiveNote,
|
||||
vaultPath,
|
||||
rawModeContent,
|
||||
findRequest,
|
||||
rawLatestContentRef,
|
||||
onRenameFilename,
|
||||
noteLayout,
|
||||
onToggleNoteLayout,
|
||||
isConflicted,
|
||||
onKeepMine,
|
||||
onKeepTheirs,
|
||||
@@ -316,6 +378,8 @@ function EditorLayout({
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
onUnsupportedAiPaste,
|
||||
locale,
|
||||
}: {
|
||||
tabs: Tab[]
|
||||
activeTab: Tab | null
|
||||
@@ -340,13 +404,19 @@ function EditorLayout({
|
||||
handleEditorChange: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onToggleOrganized?: (path: string) => void
|
||||
onRevealFile?: (path: string) => void
|
||||
onCopyFilePath?: (path: string) => void
|
||||
onOpenExternalFile?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
vaultPath?: string
|
||||
rawModeContent: string | null
|
||||
findRequest?: RawEditorFindRequest | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
noteLayout?: NoteLayout
|
||||
onToggleNoteLayout?: () => void
|
||||
isConflicted?: boolean
|
||||
onKeepMine?: (path: string) => void
|
||||
onKeepTheirs?: (path: string) => void
|
||||
@@ -369,13 +439,26 @@ function EditorLayout({
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
locale?: AppLocale
|
||||
}) {
|
||||
const activeBinaryTab = activeTab?.entry.fileKind === 'binary' ? activeTab : null
|
||||
|
||||
return (
|
||||
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{tabs.length === 0
|
||||
? <EditorEmptyState />
|
||||
: <EditorContent
|
||||
? <EditorEmptyState locale={locale} />
|
||||
: activeBinaryTab
|
||||
? (
|
||||
<FilePreview
|
||||
entry={activeBinaryTab.entry}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onOpenExternalFile={onOpenExternalFile}
|
||||
onRevealFile={onRevealFile}
|
||||
/>
|
||||
)
|
||||
: <EditorContent
|
||||
activeTab={activeTab}
|
||||
isLoadingNewTab={isLoadingNewTab}
|
||||
entries={entries}
|
||||
@@ -398,16 +481,22 @@ function EditorLayout({
|
||||
onEditorChange={handleEditorChange}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleOrganized={onToggleOrganized}
|
||||
onRevealFile={onRevealFile}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
rawModeContent={rawModeContent}
|
||||
findRequest={findRequest}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onRenameFilename={onRenameFilename}
|
||||
noteLayout={noteLayout}
|
||||
onToggleNoteLayout={onToggleNoteLayout}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
locale={locale}
|
||||
/>
|
||||
}
|
||||
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
|
||||
@@ -417,6 +506,7 @@ function EditorLayout({
|
||||
inspectorWidth={inspectorWidth}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
inspectorEntry={inspectorEntry}
|
||||
inspectorContent={inspectorContent}
|
||||
entries={entries}
|
||||
@@ -439,6 +529,7 @@ function EditorLayout({
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -451,16 +542,20 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
getNoteStatus,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
|
||||
onUnsupportedAiPaste,
|
||||
onInspectorResize,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
|
||||
onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onRenameFilename,
|
||||
noteLayout, onToggleNoteLayout,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
flushPendingRawContentRef,
|
||||
flushPendingRawContentRef, findInNoteRef,
|
||||
locale,
|
||||
} = props
|
||||
|
||||
const {
|
||||
@@ -478,6 +573,13 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
|
||||
})
|
||||
const findRequest = useEditorFindCommand({
|
||||
activeTab,
|
||||
findInNoteRef,
|
||||
handleToggleRawExclusive,
|
||||
rawMode,
|
||||
})
|
||||
|
||||
useRegisterRawContentFlush({
|
||||
activeTab,
|
||||
rawLatestContentRef,
|
||||
@@ -511,13 +613,19 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
handleEditorChange={handleEditorChange}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleOrganized={onToggleOrganized}
|
||||
onRevealFile={onRevealFile}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onOpenExternalFile={onOpenExternalFile}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
rawModeContent={rawModeContent}
|
||||
findRequest={findRequest}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onRenameFilename={onRenameFilename}
|
||||
noteLayout={noteLayout}
|
||||
onToggleNoteLayout={onToggleNoteLayout}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
@@ -525,6 +633,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
inspectorWidth={inspectorWidth}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
inspectorEntry={inspectorEntry}
|
||||
inspectorContent={inspectorContent}
|
||||
gitHistory={gitHistory}
|
||||
@@ -540,6 +649,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import type { NoteListItem } from '../utils/ai-context'
|
||||
import { Inspector, type FrontmatterValue } from './Inspector'
|
||||
@@ -13,6 +14,7 @@ interface EditorRightPanelProps {
|
||||
inspectorWidth: number
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiAgentReady?: boolean
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
inspectorEntry: VaultEntry | null
|
||||
inspectorContent: string | null
|
||||
entries: VaultEntry[]
|
||||
@@ -35,16 +37,19 @@ interface EditorRightPanelProps {
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
locale?: AppLocale
|
||||
}
|
||||
|
||||
export function EditorRightPanel({
|
||||
showAIChat, inspectorCollapsed, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
|
||||
onUnsupportedAiPaste,
|
||||
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
|
||||
noteList, noteListFilter,
|
||||
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties, onToggleRawEditor, onOpenNote,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
locale,
|
||||
}: EditorRightPanelProps) {
|
||||
const aiPanelController = useAiPanelController({
|
||||
vaultPath,
|
||||
@@ -81,6 +86,7 @@ export function EditorRightPanel({
|
||||
controller={aiPanelController}
|
||||
onClose={() => onToggleAIChat?.()}
|
||||
onOpenNote={onOpenNote}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
activeEntry={inspectorEntry}
|
||||
@@ -114,6 +120,7 @@ export function EditorRightPanel({
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onToggleRawEditor={onToggleRawEditor}
|
||||
locale={locale}
|
||||
/>
|
||||
</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 {
|
||||
@@ -240,6 +240,34 @@
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .math {
|
||||
color: var(--colors-text);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .math--inline {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
vertical-align: -0.15em;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .math-block-shell {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .math--block {
|
||||
display: block;
|
||||
min-width: max-content;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .math .katex-error {
|
||||
color: var(--destructive) !important;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.editor__blocknote-container[data-follow-links] .bn-editor a,
|
||||
.editor__blocknote-container[data-follow-links] .wikilink {
|
||||
cursor: pointer;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user