feat: add windows desktop release support
This commit is contained in:
165
.github/workflows/release-stable.yml
vendored
165
.github/workflows/release-stable.yml
vendored
@@ -336,12 +336,122 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-windows:
|
||||
name: Build (windows-x86_64)
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\.cargo\registry
|
||||
~\.cargo\git
|
||||
src-tauri\target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.version.outputs.version }}"
|
||||
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
|
||||
$tauri.version = $version
|
||||
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
|
||||
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
|
||||
|
||||
- name: Validate Windows release env
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::$name is required to build signed Windows updater artifacts."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build Tauri app (Windows bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
|
||||
- name: Validate Windows bundles
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no installable NSIS or MSI bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Windows bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 3: Publish GitHub Release
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: GitHub Release (stable)
|
||||
needs: [version, build, build-linux]
|
||||
needs: [version, build, build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -369,7 +479,7 @@ jobs:
|
||||
echo "---"
|
||||
echo "**Stable release — manually promoted from \`main\`**"
|
||||
echo ""
|
||||
echo "**macOS requires Apple Silicon (M1/M2/M3). Linux packages target x86_64.**"
|
||||
echo "**Includes macOS (Apple Silicon), Windows x64, and Linux x64 bundles**"
|
||||
echo ""
|
||||
echo "*Built from \`$(git rev-parse --short ${{ needs.version.outputs.tag }})\` on $(date -u +%Y-%m-%d)*"
|
||||
} > release_notes.md
|
||||
@@ -382,13 +492,34 @@ jobs:
|
||||
REPO_NAME="${REPO#*/}"
|
||||
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename)
|
||||
LINUX_SIG=$(cat linux-x86_64-bundles/*.AppImage.tar.gz.sig)
|
||||
LINUX_TARBALL=$(ls linux-x86_64-bundles/*.AppImage.tar.gz | xargs basename)
|
||||
LINUX_APPIMAGE=$(ls linux-x86_64-bundles/*.AppImage | xargs basename)
|
||||
LINUX_DEB=$(ls linux-x86_64-bundles/*.deb | xargs basename)
|
||||
find_required() {
|
||||
for pattern in "$@"; do
|
||||
set -- $pattern
|
||||
if [ -e "$1" ]; then
|
||||
printf '%s\n' "$1"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig")
|
||||
ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}"
|
||||
ARM_SIG=$(cat "$ARM_SIG_FILE")
|
||||
ARM_TARBALL=$(basename "$ARM_UPDATER_FILE")
|
||||
ARM_DMG=$(basename "$(find_required "dmg-aarch64/*.dmg")")
|
||||
|
||||
LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig")
|
||||
LINUX_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")")
|
||||
|
||||
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_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")")
|
||||
|
||||
cat > stable-latest.json << EOF
|
||||
{
|
||||
@@ -403,9 +534,13 @@ jobs:
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"signature": "${LINUX_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_TARBALL}",
|
||||
"appimage_url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_APPIMAGE}",
|
||||
"deb_url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_DEB}"
|
||||
"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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,6 +563,12 @@ jobs:
|
||||
linux-x86_64-bundles/*.AppImage
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
||||
windows-x86_64-bundles/*.exe
|
||||
windows-x86_64-bundles/*.exe.sig
|
||||
windows-x86_64-bundles/*.msi
|
||||
windows-x86_64-bundles/*.msi.sig
|
||||
windows-x86_64-bundles/*.zip
|
||||
windows-x86_64-bundles/*.zip.sig
|
||||
stable-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
149
.github/workflows/release.yml
vendored
149
.github/workflows/release.yml
vendored
@@ -303,13 +303,121 @@ jobs:
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
|
||||
retention-days: 1
|
||||
|
||||
build-windows:
|
||||
name: Build (windows-x86_64)
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\.cargo\registry
|
||||
~\.cargo\git
|
||||
src-tauri\target
|
||||
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Set version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.version.outputs.version }}"
|
||||
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
|
||||
$tauri.version = $version
|
||||
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
|
||||
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
|
||||
|
||||
- name: Validate Windows release env
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
run: |
|
||||
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
|
||||
if [ -z "${!name}" ]; then
|
||||
echo "::error::$name is required to build signed Windows updater artifacts."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build Tauri app (Windows bundles)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
|
||||
- name: Validate Windows bundles
|
||||
shell: bash
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
installers=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
)
|
||||
signatures=(
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
|
||||
)
|
||||
if [ ${#installers[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no installable NSIS or MSI bundle."
|
||||
exit 1
|
||||
fi
|
||||
if [ ${#signatures[@]} -eq 0 ]; then
|
||||
echo "::error::Windows build produced no updater signature (.sig) artifact."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload Windows bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-x86_64-bundles
|
||||
path: |
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
|
||||
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
|
||||
retention-days: 1
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 3: Publish GitHub Release
|
||||
# No lipo/re-signing — use the per-arch artifacts directly
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: GitHub Release (alpha)
|
||||
needs: [version, build]
|
||||
needs: [version, build, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -357,7 +465,7 @@ jobs:
|
||||
echo "---"
|
||||
echo "**Alpha build — updated on every push to \`main\`**"
|
||||
echo ""
|
||||
echo "**Requires Apple Silicon (M1/M2/M3)**"
|
||||
echo "**Includes macOS (Apple Silicon) and Windows x64 bundles**"
|
||||
echo ""
|
||||
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
|
||||
} > release_notes.md
|
||||
@@ -370,8 +478,27 @@ jobs:
|
||||
REPO_NAME="${REPO#*/}"
|
||||
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
find_required() {
|
||||
for pattern in "$@"; do
|
||||
set -- $pattern
|
||||
if [ -e "$1" ]; then
|
||||
printf '%s\n' "$1"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig")
|
||||
ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}"
|
||||
ARM_SIG=$(cat "$ARM_SIG_FILE")
|
||||
ARM_UPDATER=$(basename "$ARM_UPDATER_FILE")
|
||||
|
||||
WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig")
|
||||
WINDOWS_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")")
|
||||
|
||||
cat > alpha-latest.json << EOF
|
||||
{
|
||||
@@ -381,7 +508,13 @@ jobs:
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
"signature": "${ARM_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}"
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": "${WINDOWS_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_DOWNLOAD}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,6 +532,12 @@ jobs:
|
||||
files: |
|
||||
updater-aarch64/*.app.tar.gz
|
||||
updater-aarch64/*.app.tar.gz.sig
|
||||
windows-x86_64-bundles/*.exe
|
||||
windows-x86_64-bundles/*.exe.sig
|
||||
windows-x86_64-bundles/*.msi
|
||||
windows-x86_64-bundles/*.msi.sig
|
||||
windows-x86_64-bundles/*.zip
|
||||
windows-x86_64-bundles/*.zip.sig
|
||||
alpha-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -240,6 +240,7 @@ Tolaria separates **display title** from the file identifier:
|
||||
- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem.
|
||||
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
|
||||
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions stage crash-safe note renames through a hidden `.tolaria-rename-txn/` transaction directory, recover unfinished renames on the next vault scan, update wikilinks across the vault, and surface any failed backlink rewrites instead of silently reporting partial success. The editor body remains the title editing surface.
|
||||
- **Portable filename validation** (`vault/filename_rules.rs`): note filenames, folder names, and custom view filenames all reject Windows-reserved device names, invalid characters, and trailing dot/space suffixes so a vault created on macOS/Linux still clones and syncs cleanly on Windows.
|
||||
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
|
||||
|
||||
### Title Surface (UI)
|
||||
@@ -692,5 +693,5 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
|
||||
|
||||
### CI/CD
|
||||
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon artifacts, and Linux x86_64 `.deb` / AppImage artifacts.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon artifacts, Windows x64 installers/updater bundles, and Linux x86_64 `.deb` / AppImage artifacts.
|
||||
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.
|
||||
|
||||
@@ -592,6 +592,7 @@ 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 |
|
||||
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
|
||||
@@ -782,6 +783,7 @@ Selection-dependent actions are wired through the command palette and the native
|
||||
Shortcut routing is explicit:
|
||||
|
||||
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
|
||||
- `formatShortcutDisplay()` derives platform-accurate visible shortcut labels (`⌘` on macOS, `Ctrl` on Windows/Linux) from that same manifest so menus, tooltips, and command-palette copy stay aligned with real accelerators
|
||||
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
|
||||
- macOS browser-reserved chords such as `Cmd+O` and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
|
||||
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same command IDs for native menu clicks, accelerators, and custom titlebar menu actions
|
||||
@@ -806,6 +808,9 @@ push to main
|
||||
→ build job:
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app
|
||||
→ upload signed .app.tar.gz + .sig updater artifacts
|
||||
→ build-windows job:
|
||||
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
|
||||
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
|
||||
→ release job:
|
||||
→ generate alpha-latest.json
|
||||
→ publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N
|
||||
@@ -827,13 +832,16 @@ push stable-vYYYY.M.D tag
|
||||
→ upload signed .app.tar.gz + .sig and .dmg artifacts
|
||||
→ build-linux job:
|
||||
→ pnpm install, stamp version, tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
|
||||
→ upload .deb, .AppImage, and signed AppImage updater tarball artifacts
|
||||
→ 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 macOS and Linux updater URLs plus manual download URLs
|
||||
→ generate stable-latest.json with macOS, Linux, and Windows updater URLs plus platform-specific manual download URLs
|
||||
→ publish GitHub release Tolaria YYYY.M.D
|
||||
→ pages job:
|
||||
→ publish stable/latest.json
|
||||
→ publish stable/download/ and download/ as permanent redirect URLs for the latest stable DMG
|
||||
→ publish stable/download/ and download/ as permanent redirect URLs for the latest stable platform installer
|
||||
→ preserve alpha/latest.json
|
||||
→ deploy to gh-pages
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
@@ -134,3 +134,5 @@ proposed → active → superseded
|
||||
| [0076](0076-note-retargeting-separates-type-and-folder-moves.md) | Note retargeting separates type changes from folder moves | active |
|
||||
| [0077](0077-concurrent-safe-vault-cache-replacement.md) | Concurrent-safe vault cache replacement | active |
|
||||
| [0078](0078-scoped-unsigned-fallback-for-app-managed-git-commits.md) | Scoped unsigned fallback for app-managed git commits | active |
|
||||
| [0079](0079-linux-window-chrome-and-menu-reuse.md) | Linux window chrome and menu reuse | active |
|
||||
| [0080](0080-cross-platform-desktop-release-artifacts-and-portable-vault-names.md) | Cross-platform desktop release artifacts and portable vault names | active |
|
||||
|
||||
@@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path'
|
||||
|
||||
import {
|
||||
buildStableDownloadRedirectPage,
|
||||
resolveStableDmgUrl,
|
||||
resolveStableDownloadTargets,
|
||||
} from '../src/utils/releaseDownloadPage'
|
||||
|
||||
function getArg(flag: string): string {
|
||||
@@ -30,8 +30,8 @@ const releasesJsonPath = resolve(getArg('--releases-json'))
|
||||
const outputFilePath = resolve(getArg('--output-file'))
|
||||
const latestPayload = readLatestReleasePayload(latestJsonPath)
|
||||
const releasesPayload = readLatestReleasePayload(releasesJsonPath)
|
||||
const dmgUrl = resolveStableDmgUrl(latestPayload, releasesPayload)
|
||||
const html = buildStableDownloadRedirectPage(dmgUrl)
|
||||
const downloads = resolveStableDownloadTargets(latestPayload, releasesPayload)
|
||||
const html = buildStableDownloadRedirectPage(downloads)
|
||||
|
||||
mkdirSync(dirname(outputFilePath), { recursive: true })
|
||||
writeFileSync(outputFilePath, html)
|
||||
|
||||
@@ -31,6 +31,47 @@ mod tests {
|
||||
Some(vault_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
fn assert_note_write_rejects_escape<T: std::fmt::Debug>(
|
||||
action: impl FnOnce(std::path::PathBuf, String, Option<std::path::PathBuf>) -> Result<T, String>,
|
||||
) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path();
|
||||
let escape_path = vault_path.join("../outside.md");
|
||||
|
||||
let err = action(
|
||||
escape_path,
|
||||
"# Outside\n".to_string(),
|
||||
vault_path_arg(vault_path),
|
||||
)
|
||||
.expect_err("expected traversal write to be rejected");
|
||||
|
||||
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
|
||||
}
|
||||
|
||||
fn sample_view_definition() -> ViewDefinition {
|
||||
ViewDefinition {
|
||||
name: "Inbox".to_string(),
|
||||
icon: None,
|
||||
color: None,
|
||||
sort: None,
|
||||
list_properties_display: vec![],
|
||||
filters: crate::vault::FilterGroup::All(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_save_view_cmd_rejects_invalid_filename(filename: &str) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let err = save_view_cmd(
|
||||
dir.path().to_string_lossy().to_string(),
|
||||
filename.to_string(),
|
||||
sample_view_definition(),
|
||||
)
|
||||
.expect_err("expected invalid filename to be rejected");
|
||||
|
||||
assert_eq!(err, INVALID_VIEW_FILENAME_ERROR);
|
||||
}
|
||||
|
||||
fn temp_note(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let note = dir.path().join("note.md");
|
||||
@@ -125,34 +166,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_save_note_content_rejects_traversal_outside_active_vault() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path();
|
||||
let escape_path = vault_path.join("../outside.md");
|
||||
|
||||
let err = save_note_content(
|
||||
escape_path,
|
||||
"# Outside\n".to_string(),
|
||||
vault_path_arg(vault_path),
|
||||
)
|
||||
.expect_err("expected traversal write to be rejected");
|
||||
|
||||
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
|
||||
assert_note_write_rejects_escape(save_note_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_note_content_rejects_traversal_outside_active_vault() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path();
|
||||
let escape_path = vault_path.join("../outside.md");
|
||||
|
||||
let err = create_note_content(
|
||||
escape_path,
|
||||
"# Outside\n".to_string(),
|
||||
vault_path_arg(vault_path),
|
||||
)
|
||||
.expect_err("expected traversal create to be rejected");
|
||||
|
||||
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
|
||||
assert_note_write_rejects_escape(create_note_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -166,25 +185,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_view_cmd_rejects_nested_filename() {
|
||||
fn test_create_vault_folder_rejects_windows_invalid_names() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let definition = ViewDefinition {
|
||||
name: "Inbox".to_string(),
|
||||
icon: None,
|
||||
color: None,
|
||||
sort: None,
|
||||
list_properties_display: vec![],
|
||||
filters: crate::vault::FilterGroup::All(vec![]),
|
||||
};
|
||||
|
||||
let err = save_view_cmd(
|
||||
dir.path().to_string_lossy().to_string(),
|
||||
"../escape.yml".to_string(),
|
||||
definition,
|
||||
)
|
||||
.expect_err("expected nested filename to be rejected");
|
||||
let err = create_vault_folder(dir.path().into(), "con".into())
|
||||
.expect_err("expected Windows-invalid folder name to be rejected");
|
||||
|
||||
assert_eq!(err, INVALID_VIEW_FILENAME_ERROR);
|
||||
assert_eq!(err, "Invalid folder name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_view_cmd_rejects_nested_filename() {
|
||||
assert_save_view_cmd_rejects_invalid_filename("../escape.yml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_view_cmd_rejects_windows_invalid_filename() {
|
||||
assert_save_view_cmd_rejects_invalid_filename("con.yml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::commands::expand_tilde;
|
||||
use crate::vault::filename_rules::validate_view_filename_stem;
|
||||
use crate::vault_list;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
@@ -199,7 +200,11 @@ pub(crate) fn validate_view_filename(filename: &str) -> Result<(), String> {
|
||||
let path = Path::new(filename);
|
||||
let mut components = path.components();
|
||||
match (components.next(), components.next()) {
|
||||
(Some(Component::Normal(_)), None) => Ok(()),
|
||||
(Some(Component::Normal(value)), None) => {
|
||||
let stem = value.to_string_lossy();
|
||||
let stem = stem.strip_suffix(".yml").unwrap_or(&stem);
|
||||
validate_view_filename_stem(stem)
|
||||
}
|
||||
_ => Err(INVALID_VIEW_FILENAME_ERROR.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::commands::expand_tilde;
|
||||
use crate::vault::filename_rules::validate_folder_name;
|
||||
use crate::vault::{self, FolderNode, VaultEntry};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -113,6 +114,7 @@ pub fn create_vault_folder(vault_path: PathBuf, folder_name: PathBuf) -> Result<
|
||||
let raw_vault_path = vault_path.to_string_lossy();
|
||||
with_boundary(Some(raw_vault_path.as_ref()), |boundary| {
|
||||
let folder_name = folder_name.to_string_lossy();
|
||||
validate_folder_name(folder_name.as_ref())?;
|
||||
let folder_path = boundary.child_path(folder_name.as_ref())?;
|
||||
ensure_missing_folder(&folder_path, folder_name.as_ref())?;
|
||||
std::fs::create_dir_all(&folder_path)
|
||||
|
||||
@@ -17,12 +17,12 @@ pub fn rename_note(
|
||||
&vault_path,
|
||||
&old_path,
|
||||
|requested_root, validated_path| {
|
||||
vault::rename_note(
|
||||
requested_root,
|
||||
validated_path,
|
||||
&new_title,
|
||||
old_title.as_deref(),
|
||||
)
|
||||
vault::rename_note(vault::RenameNoteRequest {
|
||||
vault_path: requested_root,
|
||||
old_path: validated_path,
|
||||
new_title: &new_title,
|
||||
old_title_hint: old_title.as_deref(),
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -37,7 +37,11 @@ pub fn rename_note_filename(
|
||||
&vault_path,
|
||||
&old_path,
|
||||
|requested_root, validated_path| {
|
||||
vault::rename_note_filename(requested_root, validated_path, &new_filename_stem)
|
||||
vault::rename_note_filename(vault::RenameNoteFilenameRequest {
|
||||
vault_path: requested_root,
|
||||
old_path: validated_path,
|
||||
new_filename_stem: &new_filename_stem,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -67,11 +71,11 @@ pub fn move_note_to_folder(
|
||||
if !validated_folder.is_dir() {
|
||||
return Err(format!("Folder does not exist: {}", trimmed_folder_path));
|
||||
}
|
||||
vault::move_note_to_folder(
|
||||
requested_root,
|
||||
validated_path,
|
||||
validated_folder_path,
|
||||
)
|
||||
vault::move_note_to_folder(vault::MoveNoteToFolderRequest {
|
||||
vault_path: requested_root,
|
||||
old_path: validated_path,
|
||||
destination_folder_path: validated_folder_path,
|
||||
})
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -87,7 +91,10 @@ pub fn auto_rename_untitled(
|
||||
&vault_path,
|
||||
¬e_path,
|
||||
|requested_root, validated_path| {
|
||||
vault::auto_rename_untitled(requested_root, validated_path)
|
||||
vault::auto_rename_untitled(vault::AutoRenameUntitledRequest {
|
||||
vault_path: requested_root,
|
||||
note_path: validated_path,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -95,7 +102,7 @@ pub fn auto_rename_untitled(
|
||||
#[tauri::command]
|
||||
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::detect_renames(&vault_path)
|
||||
vault::detect_renames(Path::new(vault_path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -104,5 +111,5 @@ pub fn update_wikilinks_for_renames(
|
||||
renames: Vec<DetectedRename>,
|
||||
) -> Result<usize, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::update_wikilinks_for_renames(&vault_path, &renames)
|
||||
vault::update_wikilinks_for_renames(Path::new(vault_path.as_ref()), &renames)
|
||||
}
|
||||
|
||||
@@ -156,6 +156,11 @@ fn build_app_menu(app: &App) -> MenuResult {
|
||||
}
|
||||
|
||||
fn build_file_menu(app: &App) -> MenuResult {
|
||||
let quick_open_alias_label = if cfg!(target_os = "macos") {
|
||||
"Quick Open (Cmd+O)"
|
||||
} else {
|
||||
"Quick Open (Ctrl+O)"
|
||||
};
|
||||
let new_note = MenuItemBuilder::new("New Note")
|
||||
.id(FILE_NEW_NOTE)
|
||||
.accelerator("CmdOrCtrl+N")
|
||||
@@ -167,7 +172,7 @@ fn build_file_menu(app: &App) -> MenuResult {
|
||||
.id(FILE_QUICK_OPEN)
|
||||
.accelerator("CmdOrCtrl+P")
|
||||
.build(app)?;
|
||||
let quick_open_alias = MenuItemBuilder::new("Quick Open (Cmd+O)")
|
||||
let quick_open_alias = MenuItemBuilder::new(quick_open_alias_label)
|
||||
.id(FILE_QUICK_OPEN_ALIAS)
|
||||
.accelerator("CmdOrCtrl+O")
|
||||
.build(app)?;
|
||||
|
||||
101
src-tauri/src/vault/filename_rules.rs
Normal file
101
src-tauri/src/vault/filename_rules.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
const WINDOWS_RESERVED_DEVICE_NAMES: &[&str] = &[
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
||||
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
|
||||
pub(crate) fn validate_filename_stem(stem: &str) -> Result<(), String> {
|
||||
validate_portable_name_segment(stem, "Invalid filename")
|
||||
}
|
||||
|
||||
pub(crate) fn validate_folder_name(name: &str) -> Result<(), String> {
|
||||
validate_portable_name_segment(name, "Invalid folder name")
|
||||
}
|
||||
|
||||
pub(crate) fn validate_view_filename_stem(stem: &str) -> Result<(), String> {
|
||||
validate_portable_name_segment(stem, "Invalid view filename")
|
||||
}
|
||||
|
||||
fn validate_portable_name_segment(value: &str, message: &str) -> Result<(), String> {
|
||||
if is_invalid_portable_name_segment(value) {
|
||||
return Err(message.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_invalid_portable_name_segment(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() || matches!(trimmed, "." | "..") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed.ends_with('.') || trimmed.ends_with(' ') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if trimmed.chars().any(is_invalid_portable_name_char) {
|
||||
return true;
|
||||
}
|
||||
|
||||
is_windows_reserved_device_name(trimmed)
|
||||
}
|
||||
|
||||
fn is_invalid_portable_name_char(ch: char) -> bool {
|
||||
ch.is_control() || matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*')
|
||||
}
|
||||
|
||||
fn is_windows_reserved_device_name(value: &str) -> bool {
|
||||
let candidate = value
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or(value)
|
||||
.to_ascii_uppercase();
|
||||
WINDOWS_RESERVED_DEVICE_NAMES
|
||||
.iter()
|
||||
.any(|reserved| candidate == *reserved)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{validate_filename_stem, validate_folder_name, validate_view_filename_stem};
|
||||
|
||||
#[test]
|
||||
fn accepts_portable_names() {
|
||||
assert_eq!(validate_filename_stem("meeting-notes"), Ok(()));
|
||||
assert_eq!(validate_filename_stem("draft.v2"), Ok(()));
|
||||
assert_eq!(validate_folder_name("Projects"), Ok(()));
|
||||
assert_eq!(validate_view_filename_stem("active-projects"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_reserved_windows_device_names() {
|
||||
assert_eq!(
|
||||
validate_filename_stem("con"),
|
||||
Err("Invalid filename".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_folder_name("Lpt1"),
|
||||
Err("Invalid folder name".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_view_filename_stem("aux"),
|
||||
Err("Invalid view filename".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_windows_invalid_characters_and_suffixes() {
|
||||
assert_eq!(
|
||||
validate_filename_stem("quarterly:plan"),
|
||||
Err("Invalid filename".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_folder_name("Research?"),
|
||||
Err("Invalid folder name".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
validate_view_filename_stem("overview. "),
|
||||
Err("Invalid view filename".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use super::filename_rules::validate_folder_name;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FolderRenameResult {
|
||||
pub old_path: String,
|
||||
@@ -13,9 +15,7 @@ fn normalize_folder_name(next_name: &str) -> Result<String, String> {
|
||||
if trimmed.is_empty() {
|
||||
return Err("Folder name cannot be empty".to_string());
|
||||
}
|
||||
if trimmed == "." || trimmed == ".." || trimmed.contains('/') || trimmed.contains('\\') {
|
||||
return Err("Invalid folder name".to_string());
|
||||
}
|
||||
validate_folder_name(trimmed)?;
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
@@ -160,6 +160,16 @@ mod tests {
|
||||
assert_eq!(error, "Invalid folder name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_folder_rejects_windows_invalid_names() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
make_folder(&dir, "projects");
|
||||
|
||||
let error = rename_folder(dir.path(), "projects", "LPT1").unwrap_err();
|
||||
|
||||
assert_eq!(error, "Invalid folder name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_folder_removes_nested_contents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -2,6 +2,7 @@ mod cache;
|
||||
mod config_seed;
|
||||
mod entry;
|
||||
mod file;
|
||||
pub(crate) mod filename_rules;
|
||||
mod folders;
|
||||
mod frontmatter;
|
||||
mod getting_started;
|
||||
@@ -27,7 +28,8 @@ pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
pub use rename::{
|
||||
auto_rename_untitled, detect_renames, move_note_to_folder, rename_note, rename_note_filename,
|
||||
update_wikilinks_for_renames, DetectedRename, RenameResult,
|
||||
update_wikilinks_for_renames, AutoRenameUntitledRequest, DetectedRename,
|
||||
MoveNoteToFolderRequest, RenameNoteFilenameRequest, RenameNoteRequest, RenameResult,
|
||||
};
|
||||
pub use title_sync::{sync_title_on_open, SyncAction};
|
||||
pub use trash::{batch_delete_notes, delete_note};
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::path::Path;
|
||||
use tempfile::NamedTempFile;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use super::filename_rules::validate_filename_stem;
|
||||
use super::rename_transaction::RenameWorkspace;
|
||||
use crate::frontmatter::{update_frontmatter_content, FrontmatterValue};
|
||||
|
||||
@@ -20,6 +21,34 @@ pub struct RenameResult {
|
||||
pub failed_updates: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RenameNoteRequest<'a> {
|
||||
pub vault_path: &'a str,
|
||||
pub old_path: &'a str,
|
||||
pub new_title: &'a str,
|
||||
pub old_title_hint: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct RenameNoteFilenameRequest<'a> {
|
||||
pub vault_path: &'a str,
|
||||
pub old_path: &'a str,
|
||||
pub new_filename_stem: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct MoveNoteToFolderRequest<'a> {
|
||||
pub vault_path: &'a str,
|
||||
pub old_path: &'a str,
|
||||
pub destination_folder_path: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct AutoRenameUntitledRequest<'a> {
|
||||
pub vault_path: &'a str,
|
||||
pub note_path: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct WikilinkUpdateSummary {
|
||||
updated_files: usize,
|
||||
@@ -176,12 +205,13 @@ fn update_note_title_in_content(content: &str, new_title: &str) -> String {
|
||||
}
|
||||
|
||||
/// Strip vault prefix and .md suffix to get the relative path stem (e.g., "project/weekly-review").
|
||||
fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
|
||||
abs_path
|
||||
.strip_prefix(vault_prefix)
|
||||
.unwrap_or(abs_path)
|
||||
fn to_path_stem(path: &Path, vault_root: &Path) -> String {
|
||||
let relative = path.strip_prefix(vault_root).unwrap_or(path);
|
||||
let normalized = relative.to_string_lossy().replace('\\', "/");
|
||||
normalized
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(abs_path)
|
||||
.unwrap_or(&normalized)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
|
||||
@@ -197,8 +227,7 @@ fn persist_staged_note(staged: NamedTempFile, target_path: &Path) -> Result<(),
|
||||
|
||||
fn finalize_rename(vault: &Path, old_targets: &[&str], new_file: &Path) -> RenameResult {
|
||||
let new_path = new_file.to_string_lossy().to_string();
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let new_path_stem = to_path_stem(&new_path, &vault_prefix).to_string();
|
||||
let new_path_stem = to_path_stem(new_file, vault);
|
||||
let summary = update_wikilinks_in_vault(vault, old_targets, &new_path_stem, new_file);
|
||||
RenameResult {
|
||||
new_path,
|
||||
@@ -213,25 +242,19 @@ fn normalize_filename_stem(new_filename_stem: &str) -> Result<String, String> {
|
||||
if stem.is_empty() {
|
||||
return Err("New filename cannot be empty".to_string());
|
||||
}
|
||||
if is_invalid_filename_stem(stem) {
|
||||
return Err("Invalid filename".to_string());
|
||||
}
|
||||
validate_filename_stem(stem)?;
|
||||
Ok(stem.to_string())
|
||||
}
|
||||
|
||||
fn is_invalid_filename_stem(stem: &str) -> bool {
|
||||
stem == "." || stem == ".." || stem.contains('/') || stem.contains('\\')
|
||||
}
|
||||
|
||||
struct LoadedNote {
|
||||
content: String,
|
||||
filename: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
fn unchanged_result(path: &str) -> RenameResult {
|
||||
fn unchanged_result(path: &Path) -> RenameResult {
|
||||
RenameResult {
|
||||
new_path: path.to_string(),
|
||||
new_path: path.to_string_lossy().to_string(),
|
||||
updated_files: 0,
|
||||
failed_updates: 0,
|
||||
}
|
||||
@@ -245,20 +268,19 @@ fn validate_new_title(new_title: &str) -> Result<&str, String> {
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
fn ensure_existing_note(old_file: &Path, old_path: &str) -> Result<(), String> {
|
||||
fn ensure_existing_note(old_file: &Path) -> Result<(), String> {
|
||||
if old_file.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(format!("File does not exist: {}", old_path))
|
||||
Err(format!("File does not exist: {}", old_file.display()))
|
||||
}
|
||||
|
||||
fn load_note_for_title_rename(
|
||||
old_file: &Path,
|
||||
old_path: &str,
|
||||
old_title_hint: Option<&str>,
|
||||
) -> Result<LoadedNote, String> {
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let content = fs::read_to_string(old_file)
|
||||
.map_err(|e| format!("Failed to read {}: {}", old_file.display(), e))?;
|
||||
let filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
@@ -277,10 +299,9 @@ fn persist_title_only_update(
|
||||
workspace: &RenameWorkspace,
|
||||
old_file: &Path,
|
||||
updated_content: &str,
|
||||
old_path: &str,
|
||||
) -> Result<RenameResult, String> {
|
||||
persist_staged_note(workspace.stage_note_content(updated_content)?, old_file)?;
|
||||
Ok(unchanged_result(old_path))
|
||||
Ok(unchanged_result(old_file))
|
||||
}
|
||||
|
||||
/// Rename a note: update its frontmatter title, rename the file, and update wiki links across the vault.
|
||||
@@ -288,19 +309,14 @@ fn persist_title_only_update(
|
||||
/// When `old_title_hint` is provided it is used instead of extracting the title from
|
||||
/// the file's frontmatter/filename. This is needed when the caller has already saved
|
||||
/// updated content to disk before triggering the rename.
|
||||
pub fn rename_note(
|
||||
vault_path: &str,
|
||||
old_path: &str,
|
||||
new_title: &str,
|
||||
old_title_hint: Option<&str>,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
pub fn rename_note(request: RenameNoteRequest<'_>) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(request.vault_path);
|
||||
let old_file = Path::new(request.old_path);
|
||||
|
||||
recover_pending_rename_transactions(vault)?;
|
||||
ensure_existing_note(old_file, old_path)?;
|
||||
let new_title = validate_new_title(new_title)?;
|
||||
let loaded = load_note_for_title_rename(old_file, old_path, old_title_hint)?;
|
||||
ensure_existing_note(old_file)?;
|
||||
let new_title = validate_new_title(request.new_title)?;
|
||||
let loaded = load_note_for_title_rename(old_file, request.old_title_hint)?;
|
||||
|
||||
// Check both title and filename: even if the title in content matches,
|
||||
// the filename may still be stale (e.g. "untitled-note.md" after user changed H1).
|
||||
@@ -309,7 +325,7 @@ pub fn rename_note(
|
||||
let filename_matches = loaded.filename == expected_filename;
|
||||
|
||||
if title_unchanged && filename_matches {
|
||||
return Ok(unchanged_result(old_path));
|
||||
return Ok(unchanged_result(old_file));
|
||||
}
|
||||
|
||||
// Update content only if the title actually changed
|
||||
@@ -321,53 +337,50 @@ pub fn rename_note(
|
||||
let workspace = RenameWorkspace::new(vault)?;
|
||||
|
||||
if filename_matches {
|
||||
return persist_title_only_update(&workspace, old_file, &updated_content, old_path);
|
||||
return persist_title_only_update(&workspace, old_file, &updated_content);
|
||||
}
|
||||
|
||||
let parent_dir = old_file
|
||||
.parent()
|
||||
.ok_or("Cannot determine parent directory")?;
|
||||
let committed = workspace
|
||||
.operation(old_path, old_file)
|
||||
.operation(request.old_path, old_file)
|
||||
.rename_with_candidates(
|
||||
workspace.stage_note_content(&updated_content)?,
|
||||
&expected_filename,
|
||||
parent_dir,
|
||||
)?;
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let old_targets = collect_legacy_wikilink_targets(&loaded.title, old_path_stem);
|
||||
let old_path_stem = to_path_stem(old_file, vault);
|
||||
let old_targets = collect_legacy_wikilink_targets(&loaded.title, &old_path_stem);
|
||||
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
|
||||
}
|
||||
|
||||
/// Rename only the file path stem while preserving title/frontmatter content.
|
||||
pub fn rename_note_filename(
|
||||
vault_path: &str,
|
||||
old_path: &str,
|
||||
new_filename_stem: &str,
|
||||
request: RenameNoteFilenameRequest<'_>,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
let vault = Path::new(request.vault_path);
|
||||
let old_file = Path::new(request.old_path);
|
||||
|
||||
recover_pending_rename_transactions(vault)?;
|
||||
|
||||
if !old_file.exists() {
|
||||
return Err(format!("File does not exist: {}", old_path));
|
||||
return Err(format!("File does not exist: {}", old_file.display()));
|
||||
}
|
||||
|
||||
let normalized_stem = normalize_filename_stem(new_filename_stem)?;
|
||||
let normalized_stem = normalize_filename_stem(request.new_filename_stem)?;
|
||||
let old_filename = old_file
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let content = fs::read_to_string(old_file)
|
||||
.map_err(|e| format!("Failed to read {}: {}", request.old_path, e))?;
|
||||
let fm_title = extract_fm_title_value(&content);
|
||||
let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
|
||||
let new_filename = format!("{}.md", normalized_stem);
|
||||
|
||||
if old_filename == new_filename {
|
||||
return Ok(unchanged_result(old_path));
|
||||
return Ok(unchanged_result(old_file));
|
||||
}
|
||||
|
||||
let parent_dir = old_file
|
||||
@@ -376,38 +389,33 @@ pub fn rename_note_filename(
|
||||
let new_file = parent_dir.join(&new_filename);
|
||||
let workspace = RenameWorkspace::new(vault)?;
|
||||
let committed = workspace
|
||||
.operation(old_path, old_file)
|
||||
.operation(request.old_path, old_file)
|
||||
.rename_exact(workspace.stage_note_content(&content)?, &new_file)?;
|
||||
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
|
||||
let old_path_stem = to_path_stem(old_file, vault);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, &old_path_stem);
|
||||
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
|
||||
}
|
||||
|
||||
/// Move a note into a different folder while preserving its filename and content.
|
||||
pub fn move_note_to_folder(
|
||||
vault_path: &str,
|
||||
old_path: &str,
|
||||
destination_folder_path: &str,
|
||||
) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
let destination_dir = Path::new(destination_folder_path);
|
||||
pub fn move_note_to_folder(request: MoveNoteToFolderRequest<'_>) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(request.vault_path);
|
||||
let old_file = Path::new(request.old_path);
|
||||
let destination_dir = Path::new(request.destination_folder_path);
|
||||
|
||||
recover_pending_rename_transactions(vault)?;
|
||||
ensure_existing_note(old_file, old_path)?;
|
||||
ensure_existing_note(old_file)?;
|
||||
|
||||
if !destination_dir.exists() {
|
||||
return Err(format!(
|
||||
"Folder does not exist: {}",
|
||||
destination_folder_path
|
||||
request.destination_folder_path
|
||||
));
|
||||
}
|
||||
if !destination_dir.is_dir() {
|
||||
return Err(format!(
|
||||
"Folder is not a directory: {}",
|
||||
destination_folder_path
|
||||
request.destination_folder_path
|
||||
));
|
||||
}
|
||||
|
||||
@@ -415,24 +423,23 @@ pub fn move_note_to_folder(
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let content =
|
||||
fs::read_to_string(old_file).map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let content = fs::read_to_string(old_file)
|
||||
.map_err(|e| format!("Failed to read {}: {}", request.old_path, e))?;
|
||||
let fm_title = extract_fm_title_value(&content);
|
||||
let old_title = super::extract_title(fm_title.as_deref(), &content, &old_filename);
|
||||
let new_file = destination_dir.join(&old_filename);
|
||||
|
||||
if new_file == old_file {
|
||||
return Ok(unchanged_result(old_path));
|
||||
return Ok(unchanged_result(old_file));
|
||||
}
|
||||
|
||||
let workspace = RenameWorkspace::new(vault)?;
|
||||
let committed = workspace
|
||||
.operation(old_path, old_file)
|
||||
.operation(request.old_path, old_file)
|
||||
.rename_exact(workspace.stage_note_content(&content)?, &new_file)?;
|
||||
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_path_stem);
|
||||
let old_path_stem = to_path_stem(old_file, vault);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, &old_path_stem);
|
||||
Ok(finalize_rename(vault, &old_targets, committed.new_file()))
|
||||
}
|
||||
|
||||
@@ -451,10 +458,9 @@ fn is_untitled_filename(filename: &str) -> bool {
|
||||
/// Returns `Some(RenameResult)` if renamed, `None` if conditions not met.
|
||||
/// This is a ONE-SHOT rename: only fires for untitled-* files with an H1.
|
||||
pub fn auto_rename_untitled(
|
||||
vault_path: &str,
|
||||
note_path: &str,
|
||||
request: AutoRenameUntitledRequest<'_>,
|
||||
) -> Result<Option<RenameResult>, String> {
|
||||
let path = Path::new(note_path);
|
||||
let path = Path::new(request.note_path);
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
@@ -464,15 +470,20 @@ pub fn auto_rename_untitled(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", note_path, e))?;
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", request.note_path, e))?;
|
||||
|
||||
let h1_title = match super::parsing::extract_h1_title(&content) {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let result = rename_note(vault_path, note_path, &h1_title, None)?;
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: request.vault_path,
|
||||
old_path: request.note_path,
|
||||
new_title: &h1_title,
|
||||
old_title_hint: None,
|
||||
})?;
|
||||
Ok(Some(result))
|
||||
}
|
||||
|
||||
@@ -484,8 +495,7 @@ pub struct DetectedRename {
|
||||
}
|
||||
|
||||
/// Detect renamed files by comparing working tree against HEAD using git diff.
|
||||
pub fn detect_renames(vault_path: &str) -> Result<Vec<DetectedRename>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
pub fn detect_renames(vault: &Path) -> Result<Vec<DetectedRename>, String> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"])
|
||||
.current_dir(vault)
|
||||
@@ -521,28 +531,21 @@ pub fn detect_renames(vault_path: &str) -> Result<Vec<DetectedRename>, String> {
|
||||
/// Update wikilinks across the vault for a list of detected renames.
|
||||
/// Returns the total number of files updated.
|
||||
pub fn update_wikilinks_for_renames(
|
||||
vault_path: &str,
|
||||
vault: &Path,
|
||||
renames: &[DetectedRename],
|
||||
) -> Result<usize, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let mut total_updated = 0;
|
||||
|
||||
for rename in renames {
|
||||
let old_stem = rename
|
||||
.old_path
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(&rename.old_path);
|
||||
let new_stem = rename
|
||||
.new_path
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(&rename.new_path);
|
||||
let old_filename_stem = old_stem.split('/').next_back().unwrap_or(old_stem);
|
||||
let old_file = vault.join(&rename.old_path);
|
||||
let new_file = vault.join(&rename.new_path);
|
||||
let old_stem = to_path_stem(&old_file, vault);
|
||||
let new_stem = to_path_stem(&new_file, vault);
|
||||
let old_filename_stem = old_stem.split('/').next_back().unwrap_or(&old_stem);
|
||||
// Build title from filename stem (kebab-case → Title Case)
|
||||
let old_title = super::parsing::slug_to_title(old_filename_stem);
|
||||
// The new file is the exclude target (don't rewrite wikilinks inside the renamed file itself)
|
||||
let new_file = vault.join(&rename.new_path);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, old_stem);
|
||||
let summary = update_wikilinks_in_vault(vault, &old_targets, new_stem, &new_file);
|
||||
let old_targets = collect_legacy_wikilink_targets(&old_title, &old_stem);
|
||||
let summary = update_wikilinks_in_vault(vault, &old_targets, &new_stem, &new_file);
|
||||
total_updated += summary.updated_files;
|
||||
}
|
||||
|
||||
@@ -555,13 +558,13 @@ mod tests {
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_file(dir: &Path, name: &str, content: &str) {
|
||||
fn create_test_file(dir: &Path, name: impl AsRef<Path>, content: impl AsRef<[u8]>) {
|
||||
let file_path = dir.join(name);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
let mut file = fs::File::create(file_path).unwrap();
|
||||
file.write_all(content.as_bytes()).unwrap();
|
||||
file.write_all(content.as_ref()).unwrap();
|
||||
}
|
||||
|
||||
struct RenameTestRequest<'a> {
|
||||
@@ -577,16 +580,60 @@ mod tests {
|
||||
) -> (std::path::PathBuf, RenameResult) {
|
||||
create_test_file(vault, request.path, request.content);
|
||||
let old_path = vault.join(request.path);
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
request.new_title,
|
||||
request.old_title_hint,
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: request.new_title,
|
||||
old_title_hint: request.old_title_hint,
|
||||
})
|
||||
.unwrap();
|
||||
(old_path, result)
|
||||
}
|
||||
|
||||
fn create_current_note(vault: &Path, relative_path: impl AsRef<Path>) -> std::path::PathBuf {
|
||||
let relative_path = relative_path.as_ref();
|
||||
create_test_file(vault, relative_path, "# Current\n");
|
||||
vault.join(relative_path)
|
||||
}
|
||||
|
||||
fn assert_rename_note_filename_error<P>(
|
||||
new_filename_stem: impl AsRef<str>,
|
||||
existing_destination: Option<P>,
|
||||
expected_error: impl AsRef<str>,
|
||||
) where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let current_path = create_current_note(vault, "note/current.md");
|
||||
if let Some(existing_path) = existing_destination {
|
||||
create_test_file(vault, existing_path.as_ref(), "# Existing\n");
|
||||
}
|
||||
|
||||
let result = rename_note_filename(RenameNoteFilenameRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: current_path.to_str().unwrap(),
|
||||
new_filename_stem: new_filename_stem.as_ref(),
|
||||
});
|
||||
|
||||
assert_eq!(result.unwrap_err(), expected_error.as_ref());
|
||||
}
|
||||
|
||||
fn assert_move_note_to_folder_error(expected_error: impl AsRef<str>) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
|
||||
create_test_file(vault, "areas/weekly-review.md", "# Existing\n");
|
||||
|
||||
let result = move_note_to_folder(MoveNoteToFolderRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: vault.join("projects/weekly-review.md").to_str().unwrap(),
|
||||
destination_folder_path: vault.join("areas").to_str().unwrap(),
|
||||
});
|
||||
|
||||
assert_eq!(result.unwrap_err(), expected_error.as_ref());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_title_to_slug() {
|
||||
assert_eq!(title_to_slug("Weekly Review"), "weekly-review");
|
||||
@@ -605,12 +652,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
None,
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "Sprint Retrospective",
|
||||
old_title_hint: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(result.new_path.ends_with("sprint-retrospective.md"));
|
||||
@@ -644,12 +691,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
None,
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "Sprint Retrospective",
|
||||
old_title_hint: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 2);
|
||||
@@ -669,12 +716,12 @@ mod tests {
|
||||
create_test_file(vault, "note/test.md", "# Test\n");
|
||||
|
||||
let old_path = vault.join("note/test.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
" ",
|
||||
None,
|
||||
);
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: " ",
|
||||
old_title_hint: None,
|
||||
});
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -767,12 +814,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/old.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"New Name",
|
||||
None,
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "New Name",
|
||||
old_title_hint: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let content = fs::read_to_string(&result.new_path).unwrap();
|
||||
@@ -785,21 +832,25 @@ mod tests {
|
||||
|
||||
/// Helper: create a note, rename it, assert the rename succeeded and old file is gone.
|
||||
/// Returns the content of the renamed file for further assertions.
|
||||
fn rename_test_note(filename: &str, content: &str, new_title: &str) -> String {
|
||||
fn rename_test_note(
|
||||
filename: impl AsRef<Path>,
|
||||
content: impl AsRef<str>,
|
||||
new_title: impl AsRef<str>,
|
||||
) -> String {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, filename, content);
|
||||
create_test_file(vault, filename.as_ref(), content.as_ref());
|
||||
|
||||
let old_path = vault.join(filename);
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
new_title,
|
||||
None,
|
||||
)
|
||||
let old_path = vault.join(filename.as_ref());
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: new_title.as_ref(),
|
||||
old_title_hint: None,
|
||||
})
|
||||
.expect("rename_note should succeed");
|
||||
|
||||
let expected_slug = title_to_slug(new_title);
|
||||
let expected_slug = title_to_slug(new_title.as_ref());
|
||||
assert!(
|
||||
result.new_path.ends_with(&format!("{}.md", expected_slug)),
|
||||
"new path should end with slug: {}",
|
||||
@@ -869,12 +920,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/untitled-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My New Note",
|
||||
None,
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "My New Note",
|
||||
old_title_hint: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// File should be renamed to match the title slug
|
||||
@@ -910,12 +961,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/untitled-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
None,
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "My Note",
|
||||
old_title_hint: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Should get a suffixed name to avoid collision
|
||||
@@ -946,11 +997,11 @@ mod tests {
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/project-kickoff.md");
|
||||
let result = rename_note_filename(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"manual-name",
|
||||
)
|
||||
let result = rename_note_filename(RenameNoteFilenameRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_filename_stem: "manual-name",
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(result.new_path.ends_with("manual-name.md"));
|
||||
@@ -968,18 +1019,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_filename_rejects_existing_destination() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/current.md", "# Current\n");
|
||||
create_test_file(vault, "note/manual-name.md", "# Existing\n");
|
||||
|
||||
let result = rename_note_filename(
|
||||
vault.to_str().unwrap(),
|
||||
vault.join("note/current.md").to_str().unwrap(),
|
||||
assert_rename_note_filename_error(
|
||||
"manual-name",
|
||||
Some("note/manual-name.md"),
|
||||
"A note with that name already exists",
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(result.unwrap_err(), "A note with that name already exists");
|
||||
#[test]
|
||||
fn test_rename_note_filename_rejects_windows_invalid_names() {
|
||||
assert_rename_note_filename_error("quarterly:plan", None::<&str>, "Invalid filename");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -997,11 +1046,11 @@ mod tests {
|
||||
"Reference [[projects/weekly-review]]\n",
|
||||
);
|
||||
|
||||
let result = move_note_to_folder(
|
||||
vault.to_str().unwrap(),
|
||||
vault.join("projects/weekly-review.md").to_str().unwrap(),
|
||||
vault.join("areas").to_str().unwrap(),
|
||||
)
|
||||
let result = move_note_to_folder(MoveNoteToFolderRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: vault.join("projects/weekly-review.md").to_str().unwrap(),
|
||||
destination_folder_path: vault.join("areas").to_str().unwrap(),
|
||||
})
|
||||
.expect("move should succeed");
|
||||
|
||||
assert!(result.new_path.ends_with("areas/weekly-review.md"));
|
||||
@@ -1020,11 +1069,11 @@ mod tests {
|
||||
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
|
||||
|
||||
let source = vault.join("projects/weekly-review.md");
|
||||
let result = move_note_to_folder(
|
||||
vault.to_str().unwrap(),
|
||||
source.to_str().unwrap(),
|
||||
vault.join("projects").to_str().unwrap(),
|
||||
)
|
||||
let result = move_note_to_folder(MoveNoteToFolderRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: source.to_str().unwrap(),
|
||||
destination_folder_path: vault.join("projects").to_str().unwrap(),
|
||||
})
|
||||
.expect("move should noop");
|
||||
|
||||
assert_eq!(result.new_path, source.to_string_lossy());
|
||||
@@ -1034,18 +1083,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_move_note_to_folder_rejects_existing_destination() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "projects/weekly-review.md", "# Weekly Review\n");
|
||||
create_test_file(vault, "areas/weekly-review.md", "# Existing\n");
|
||||
|
||||
let result = move_note_to_folder(
|
||||
vault.to_str().unwrap(),
|
||||
vault.join("projects/weekly-review.md").to_str().unwrap(),
|
||||
vault.join("areas").to_str().unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(result.unwrap_err(), "A note with that name already exists");
|
||||
assert_move_note_to_folder_error("A note with that name already exists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1073,12 +1111,12 @@ mod tests {
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
// Without old_title_hint, rename_note would see H1 = "Sprint Retrospective" == new_title → noop
|
||||
// With old_title_hint = "Weekly Review", it knows to search for [[Weekly Review]] and replace
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
Some("Weekly Review"),
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "Sprint Retrospective",
|
||||
old_title_hint: Some("Weekly Review"),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 2);
|
||||
@@ -1105,12 +1143,12 @@ mod tests {
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/old.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Brand New Title",
|
||||
None,
|
||||
)
|
||||
let result = rename_note(RenameNoteRequest {
|
||||
vault_path: vault.to_str().unwrap(),
|
||||
old_path: old_path.to_str().unwrap(),
|
||||
new_title: "Brand New Title",
|
||||
old_title_hint: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let content = fs::read_to_string(&result.new_path).unwrap();
|
||||
|
||||
@@ -47,6 +47,11 @@
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "downloadBootstrapper"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"resources/mcp-server/**/*": "mcp-server/"
|
||||
},
|
||||
@@ -63,6 +68,9 @@
|
||||
"endpoints": [
|
||||
"https://refactoringhq.github.io/tolaria/stable/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
},
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE4NkQ5MDI3REVCRkFGNUMKUldSY3I3L2VKNUJ0cU5JRlRZZlp3NGhnU3ZwbkVKeGVvREpmb2sxRVJndHFpVFZPNlArbEE5R1IK"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, render, screen, fireEvent, waitFor, within } from '@testing-librar
|
||||
import type { ReactNode } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { DEFAULT_VAULTS } from './hooks/useVaultSwitcher'
|
||||
import { formatShortcutDisplay } from './hooks/appCommandCatalog'
|
||||
|
||||
// Provide a localStorage mock that supports all methods (jsdom's may be incomplete)
|
||||
const localStorageMock = (() => {
|
||||
@@ -368,9 +369,14 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
it('shows keyboard shortcut hints', async () => {
|
||||
render(<App />)
|
||||
const quickOpenHint = formatShortcutDisplay({ display: '⌘P / ⌘O' })
|
||||
const newNoteHint = formatShortcutDisplay({ display: '⌘N' })
|
||||
const { container } = render(<App />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Cmd\+P or Cmd\+O to search/)).toBeInTheDocument()
|
||||
const shortcutHint = Array.from(container.querySelectorAll('span.text-xs.text-muted-foreground'))
|
||||
.find((element) => element.textContent === `${quickOpenHint} to search · ${newNoteHint} to create`)
|
||||
|
||||
expect(shortcutHint).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const dragRegionMouseDown = vi.fn()
|
||||
@@ -137,7 +138,11 @@ describe('BreadcrumbBar — archive/unarchive', () => {
|
||||
describe('BreadcrumbBar — organized shortcut hint', () => {
|
||||
it('shows Cmd+E on the organized toggle tooltip', async () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleOrganized={vi.fn()} />)
|
||||
await expectTooltip(screen.getByRole('button', { name: 'Set note as organized' }), 'Set note as organized', '⌘E')
|
||||
await expectTooltip(
|
||||
screen.getByRole('button', { name: 'Set note as organized' }),
|
||||
'Set note as organized',
|
||||
formatShortcutDisplay({ display: '⌘E' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the organized toggle when the workflow is disabled', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ActionTooltip, type ActionTooltipCopy } from '@/components/ui/action-tooltip'
|
||||
@@ -130,35 +131,68 @@ function IconActionButton({
|
||||
)
|
||||
}
|
||||
|
||||
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
|
||||
const copy: ActionTooltipCopy = {
|
||||
label: rawMode ? 'Return to the editor' : 'Open the raw editor',
|
||||
shortcut: '⌘\\',
|
||||
}
|
||||
interface ToggleIconActionProps {
|
||||
active: boolean
|
||||
activeClassName: string
|
||||
activeLabel: string
|
||||
children: ReactNode
|
||||
inactiveClassName?: string
|
||||
inactiveLabel: string
|
||||
onClick?: () => void
|
||||
shortcut: string
|
||||
}
|
||||
|
||||
function ToggleIconAction({
|
||||
active,
|
||||
activeClassName,
|
||||
activeLabel,
|
||||
children,
|
||||
inactiveClassName = 'hover:text-foreground',
|
||||
inactiveLabel,
|
||||
onClick,
|
||||
shortcut,
|
||||
}: ToggleIconActionProps) {
|
||||
return (
|
||||
<IconActionButton
|
||||
copy={copy}
|
||||
onClick={onToggleRaw}
|
||||
className={cn(rawMode ? 'text-foreground' : 'hover:text-foreground')}
|
||||
copy={{
|
||||
label: active ? activeLabel : inactiveLabel,
|
||||
shortcut,
|
||||
}}
|
||||
onClick={onClick}
|
||||
className={cn(active ? activeClassName : inactiveClassName)}
|
||||
>
|
||||
<Code size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
{children}
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) {
|
||||
const copy: ActionTooltipCopy = {
|
||||
label: favorite ? 'Remove from favorites' : 'Add to favorites',
|
||||
shortcut: '⌘D',
|
||||
}
|
||||
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
|
||||
return (
|
||||
<IconActionButton
|
||||
copy={copy}
|
||||
<ToggleIconAction
|
||||
active={!!rawMode}
|
||||
activeClassName="text-foreground"
|
||||
activeLabel="Return to the editor"
|
||||
inactiveLabel="Open the raw editor"
|
||||
onClick={onToggleRaw}
|
||||
shortcut={formatShortcutDisplay({ display: '⌘\\' })}
|
||||
>
|
||||
<Code size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</ToggleIconAction>
|
||||
)
|
||||
}
|
||||
|
||||
function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) {
|
||||
return (
|
||||
<ToggleIconAction
|
||||
active={favorite}
|
||||
activeClassName="text-yellow-500"
|
||||
activeLabel="Remove from favorites"
|
||||
inactiveLabel="Add to favorites"
|
||||
onClick={onToggleFavorite}
|
||||
className={cn(favorite ? 'text-yellow-500' : 'hover:text-foreground')}
|
||||
shortcut={formatShortcutDisplay({ display: '⌘D' })}
|
||||
>
|
||||
<Star size={16} weight={favorite ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
</ToggleIconAction>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -170,18 +204,17 @@ function OrganizedAction({
|
||||
onToggleOrganized?: () => void
|
||||
}) {
|
||||
if (!onToggleOrganized) return null
|
||||
const copy: ActionTooltipCopy = {
|
||||
label: organized ? 'Set note as not organized' : 'Set note as organized',
|
||||
shortcut: '⌘E',
|
||||
}
|
||||
return (
|
||||
<IconActionButton
|
||||
copy={copy}
|
||||
<ToggleIconAction
|
||||
active={organized}
|
||||
activeClassName="text-green-600"
|
||||
activeLabel="Set note as not organized"
|
||||
inactiveLabel="Set note as organized"
|
||||
onClick={onToggleOrganized}
|
||||
className={cn(organized ? 'text-green-600' : 'hover:text-foreground')}
|
||||
shortcut={formatShortcutDisplay({ display: '⌘E' })}
|
||||
>
|
||||
<CheckCircle size={16} weight={organized ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
</ToggleIconAction>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -214,18 +247,17 @@ function DiffAction({
|
||||
}
|
||||
|
||||
function AIChatAction({ showAIChat, onToggleAIChat }: Pick<BreadcrumbBarProps, 'showAIChat' | 'onToggleAIChat'>) {
|
||||
const copy: ActionTooltipCopy = {
|
||||
label: showAIChat ? 'Close the AI panel' : 'Open the AI panel',
|
||||
shortcut: '⇧⌘L',
|
||||
}
|
||||
return (
|
||||
<IconActionButton
|
||||
copy={copy}
|
||||
<ToggleIconAction
|
||||
active={!!showAIChat}
|
||||
activeClassName="text-primary"
|
||||
activeLabel="Close the AI panel"
|
||||
inactiveLabel="Open the AI panel"
|
||||
onClick={onToggleAIChat}
|
||||
className={cn(showAIChat ? 'text-primary' : 'hover:text-foreground')}
|
||||
shortcut={formatShortcutDisplay({ display: '⌘⇧L' })}
|
||||
>
|
||||
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
</ToggleIconAction>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -251,7 +283,14 @@ function ArchiveAction({
|
||||
|
||||
function DeleteAction({ onDelete }: Pick<BreadcrumbBarProps, 'onDelete'>) {
|
||||
return (
|
||||
<IconActionButton copy={{ label: 'Delete this note', shortcut: '⌘⌫' }} onClick={onDelete} className="hover:text-destructive">
|
||||
<IconActionButton
|
||||
copy={{
|
||||
label: 'Delete this note',
|
||||
shortcut: formatShortcutDisplay({ display: '⌘⌫ / ⌘⌦' }),
|
||||
}}
|
||||
onClick={onDelete}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
<Trash size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
@@ -263,7 +302,15 @@ function InspectorAction({
|
||||
}: Pick<BreadcrumbBarProps, 'inspectorCollapsed' | 'onToggleInspector'>) {
|
||||
if (!inspectorCollapsed) return null
|
||||
return (
|
||||
<IconActionButton copy={{ label: 'Open the properties panel', shortcut: '⌘⇧I' }} onClick={onToggleInspector} className="hover:text-foreground" tooltipAlign="end">
|
||||
<IconActionButton
|
||||
copy={{
|
||||
label: 'Open the properties panel',
|
||||
shortcut: formatShortcutDisplay({ display: '⌘⇧I' }),
|
||||
}}
|
||||
onClick={onToggleInspector}
|
||||
className="hover:text-foreground"
|
||||
tooltipAlign="end"
|
||||
>
|
||||
<SlidersHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import { render as rtlRender, screen, fireEvent, act } from '@testing-library/react'
|
||||
import type { ComponentProps, PropsWithChildren, ReactElement } from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
@@ -181,9 +182,14 @@ function renderEditor(overrides: Partial<EditorComponentProps> = {}) {
|
||||
|
||||
describe('Editor', () => {
|
||||
it('shows empty state when no tabs are open', () => {
|
||||
renderEditor()
|
||||
const quickOpenHint = formatShortcutDisplay({ display: '⌘P / ⌘O' })
|
||||
const newNoteHint = formatShortcutDisplay({ display: '⌘N' })
|
||||
const { container } = renderEditor()
|
||||
expect(screen.getByText('Select a note to start editing')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Cmd\+P or Cmd\+O to search/)).toBeInTheDocument()
|
||||
const shortcutHint = Array.from(container.querySelectorAll('span.text-xs.text-muted-foreground'))
|
||||
.find((element) => element.textContent === `${quickOpenHint} to search · ${newNoteHint} to create`)
|
||||
|
||||
expect(shortcutHint).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an invisible drag region in the empty state', () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ResizeHandle } from './ResizeHandle'
|
||||
import { useDiffMode, type CommitDiffRequest } from '../hooks/useDiffMode'
|
||||
import { useEditorFocus } from '../hooks/useEditorFocus'
|
||||
import { useDragRegion } from '../hooks/useDragRegion'
|
||||
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
|
||||
import { EditorRightPanel } from './EditorRightPanel'
|
||||
import { EditorContent } from './EditorContent'
|
||||
import { schema } from './editorSchema'
|
||||
@@ -127,6 +128,8 @@ function useEditorModeExclusion({
|
||||
function EditorEmptyState() {
|
||||
const breadcrumbBarHeight = 52
|
||||
const { onMouseDown } = useDragRegion()
|
||||
const quickOpenShortcut = formatShortcutDisplay({ display: '⌘P / ⌘O' })
|
||||
const newNoteShortcut = formatShortcutDisplay({ display: '⌘N' })
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
@@ -140,7 +143,7 @@ function EditorEmptyState() {
|
||||
/>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
|
||||
<p className="m-0 text-[15px]">Select a note to start editing</p>
|
||||
<span className="text-xs text-muted-foreground">Cmd+P or Cmd+O to search · Cmd+N to create</span>
|
||||
<span className="text-xs text-muted-foreground">{quickOpenShortcut} to search · {newNoteShortcut} to create</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -25,12 +25,19 @@ import {
|
||||
import { DISABLED_STYLE, ICON_STYLE, SEP_STYLE } from './styles'
|
||||
import type { VaultOption } from './types'
|
||||
import { VaultMenu } from './VaultMenu'
|
||||
import { formatShortcutDisplay } from '../../hooks/appCommandCatalog'
|
||||
|
||||
const UPDATE_TOOLTIP = { label: 'Check for updates' } as const
|
||||
const ZOOM_RESET_TOOLTIP = { label: 'Reset the zoom level', shortcut: '⌘0' } as const
|
||||
const ZOOM_RESET_TOOLTIP = {
|
||||
label: 'Reset the zoom level',
|
||||
shortcut: formatShortcutDisplay({ display: '⌘0' }),
|
||||
} as const
|
||||
const FEEDBACK_TOOLTIP = { label: 'Contribute to Tolaria' } as const
|
||||
const NOTIFICATIONS_TOOLTIP = { label: 'Notifications are coming soon' } as const
|
||||
const SETTINGS_TOOLTIP = { label: 'Open settings', shortcut: '⌘,' } as const
|
||||
const SETTINGS_TOOLTIP = {
|
||||
label: 'Open settings',
|
||||
shortcut: formatShortcutDisplay({ display: '⌘,' }),
|
||||
} as const
|
||||
|
||||
interface StatusBarPrimarySectionProps {
|
||||
modifiedCount: number
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SidebarFilter } from '../types'
|
||||
import { isMac } from '../utils/platform'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
|
||||
export const APP_COMMAND_IDS = {
|
||||
@@ -460,3 +461,23 @@ export function findShortcutCommandIdForEvent(event: ShortcutEventLike): AppComm
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatShortcutDisplay(
|
||||
shortcut: Pick<AppCommandShortcutDefinition, 'display'>,
|
||||
): string {
|
||||
if (isMac()) return shortcut.display
|
||||
|
||||
return shortcut.display
|
||||
.replaceAll('⌘⇧', 'Ctrl+Shift+')
|
||||
.replaceAll('⌘', 'Ctrl+')
|
||||
.replaceAll('⌫', 'Backspace')
|
||||
.replaceAll('⌦', 'Delete')
|
||||
.replaceAll('←', 'Left')
|
||||
.replaceAll('→', 'Right')
|
||||
.replaceAll('↵', 'Enter')
|
||||
}
|
||||
|
||||
export function getAppCommandShortcutDisplay(id: AppCommandId): string | undefined {
|
||||
const shortcut = APP_COMMAND_DEFINITIONS[id].shortcut
|
||||
return shortcut ? formatShortcutDisplay(shortcut) : undefined
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
|
||||
import type { CommandAction } from './types'
|
||||
import type { SidebarSelection } from '../../types'
|
||||
|
||||
@@ -50,13 +51,13 @@ function buildBaseCommands(config: NavigationCommandsConfig): CommandAction[] {
|
||||
} = config
|
||||
|
||||
return [
|
||||
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P / ⌘O', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
|
||||
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.fileQuickOpen), keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
|
||||
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
|
||||
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
|
||||
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
{ id: 'go-pulse', label: 'Go to History', group: 'Navigation', keywords: ['activity', 'history', 'commits', 'git', 'feed'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'pulse' }) },
|
||||
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: '⌘←', keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
|
||||
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: '⌘→', keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
|
||||
{ id: 'go-back', label: 'Go Back', group: 'Navigation', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewGoBack), keywords: ['previous', 'history', 'back'], enabled: !!canGoBack, execute: () => onGoBack?.() },
|
||||
{ id: 'go-forward', label: 'Go Forward', group: 'Navigation', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewGoForward), keywords: ['next', 'history', 'forward'], enabled: !!canGoForward, execute: () => onGoForward?.() },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
|
||||
import type { CommandAction } from './types'
|
||||
|
||||
interface NoteCommandsConfig {
|
||||
@@ -59,7 +60,7 @@ function buildCoreNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
createNoteCommand({
|
||||
id: 'create-note',
|
||||
label: 'New Note',
|
||||
shortcut: '⌘N',
|
||||
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.fileNewNote),
|
||||
keywords: ['new', 'create', 'add'],
|
||||
enabled: true,
|
||||
execute: config.onCreateNote,
|
||||
@@ -74,7 +75,7 @@ function buildCoreNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
createNoteCommand({
|
||||
id: 'save-note',
|
||||
label: 'Save Note',
|
||||
shortcut: '⌘S',
|
||||
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.fileSave),
|
||||
keywords: ['write'],
|
||||
enabled: config.hasActiveNote,
|
||||
execute: config.onSave,
|
||||
@@ -94,7 +95,7 @@ function buildDestructiveNoteCommands(config: NoteCommandsConfig): CommandAction
|
||||
createNoteCommand({
|
||||
id: 'delete-note',
|
||||
label: 'Delete Note',
|
||||
shortcut: '⌘⌫',
|
||||
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteDelete),
|
||||
keywords: ['delete', 'remove'],
|
||||
enabled: config.hasActiveNote,
|
||||
path: config.activeTabPath,
|
||||
@@ -116,7 +117,7 @@ function buildPinnedNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
createNoteCommand({
|
||||
id: 'toggle-favorite',
|
||||
label: config.isFavorite ? 'Remove from Favorites' : 'Add to Favorites',
|
||||
shortcut: '⌘D',
|
||||
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteToggleFavorite),
|
||||
keywords: ['favorite', 'star', 'bookmark', 'pin'],
|
||||
enabled: config.hasActiveNote && !!config.onToggleFavorite,
|
||||
path: config.activeTabPath,
|
||||
@@ -125,7 +126,7 @@ function buildPinnedNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
createNoteCommand({
|
||||
id: 'toggle-organized',
|
||||
label: config.isOrganized ? 'Mark as Unorganized' : 'Mark as Organized',
|
||||
shortcut: '⌘E',
|
||||
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteToggleOrganized),
|
||||
keywords: ['organized', 'inbox', 'triage', 'done'],
|
||||
enabled: config.hasActiveNote && !!config.onToggleOrganized,
|
||||
path: config.activeTabPath,
|
||||
@@ -192,7 +193,7 @@ function buildPresentationCommands(config: NoteCommandsConfig): CommandAction[]
|
||||
createNoteCommand({
|
||||
id: 'open-in-new-window',
|
||||
label: 'Open in New Window',
|
||||
shortcut: '⌘⇧O',
|
||||
shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.noteOpenInNewWindow),
|
||||
keywords: ['window', 'new', 'detach', 'pop', 'external', 'separate'],
|
||||
enabled: config.hasActiveNote,
|
||||
execute: () => config.onOpenInNewWindow?.(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { formatShortcutDisplay } from '../appCommandCatalog'
|
||||
import { buildSettingsCommands } from './settingsCommands'
|
||||
|
||||
describe('buildSettingsCommands', () => {
|
||||
@@ -25,7 +26,7 @@ describe('buildSettingsCommands', () => {
|
||||
|
||||
expect(commands.find((item) => item.id === 'open-settings')).toMatchObject({
|
||||
label: 'Open Settings',
|
||||
shortcut: '⌘,',
|
||||
shortcut: formatShortcutDisplay({ display: '⌘,' }),
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
|
||||
import type { CommandAction } from './types'
|
||||
import { rememberFeedbackDialogOpener } from '../../lib/feedbackDialogOpener'
|
||||
|
||||
@@ -23,7 +24,7 @@ function buildPrimarySettingsCommands({
|
||||
onCheckForUpdates,
|
||||
}: Pick<SettingsCommandsConfig, 'onOpenSettings' | 'onOpenFeedback' | 'onCheckForUpdates'>): CommandAction[] {
|
||||
return [
|
||||
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
|
||||
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.appSettings), keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
|
||||
{
|
||||
id: 'open-h1-auto-rename-setting',
|
||||
label: 'Open H1 Auto-Rename Setting',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../appCommandCatalog'
|
||||
import type { CommandAction } from './types'
|
||||
import type { ViewMode } from '../useViewMode'
|
||||
import { requestNewAiChat } from '../../utils/aiPromptBridge'
|
||||
@@ -28,18 +29,18 @@ export function buildViewCommands(config: ViewCommandsConfig): CommandAction[] {
|
||||
} = config
|
||||
|
||||
return [
|
||||
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: '⌘⇧I', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewEditorOnly), keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewEditorList), keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewAll), keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleProperties), keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote && !!onToggleRawEditor, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⇧⌘L', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat), keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'new-ai-chat', label: 'New AI chat', group: 'View', keywords: ['ai', 'agent', 'chat', 'assistant', 'new', 'fresh', 'conversation', 'reset'], enabled: true, execute: requestNewAiChat },
|
||||
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
|
||||
{ id: 'customize-note-list-columns', label: noteListColumnsLabel, group: 'View', keywords: ['all notes', 'inbox', 'columns', 'chips', 'properties', 'note list'], enabled: !!(canCustomizeNoteListColumns && onCustomizeNoteListColumns), execute: () => onCustomizeNoteListColumns?.() },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomIn), keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomOut), keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewZoomReset), keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { renderHook } from '@testing-library/react'
|
||||
import { useCommandRegistry, buildTypeCommands, extractVaultTypes, pluralizeType, groupSortKey } from './useCommandRegistry'
|
||||
import type { CommandAction } from './useCommandRegistry'
|
||||
import { NEW_AI_CHAT_EVENT, OPEN_AI_CHAT_EVENT } from '../utils/aiPromptBridge'
|
||||
import { formatShortcutDisplay } from './appCommandCatalog'
|
||||
|
||||
function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -247,7 +248,9 @@ describe('useCommandRegistry', () => {
|
||||
it('shows Cmd+E on toggle organized and removes it from archive note', () => {
|
||||
const config = makeConfig()
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
expect(findCommand(result.current, 'toggle-organized')?.shortcut).toBe('⌘E')
|
||||
expect(findCommand(result.current, 'toggle-organized')?.shortcut).toBe(
|
||||
formatShortcutDisplay({ display: '⌘E' }),
|
||||
)
|
||||
expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -356,7 +359,7 @@ describe('useCommandRegistry', () => {
|
||||
expect(newNoteCommands).toHaveLength(1)
|
||||
expect(newNoteCommands[0]).toMatchObject({
|
||||
id: 'create-note',
|
||||
shortcut: '⌘N',
|
||||
shortcut: formatShortcutDisplay({ display: '⌘N' }),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,87 +1,118 @@
|
||||
import {
|
||||
buildStableDownloadRedirectPage,
|
||||
extractStableDmgUrl,
|
||||
extractStableDmgUrlFromReleases,
|
||||
resolveStableDmgUrl,
|
||||
extractStableDownloadTargets,
|
||||
extractStableDownloadTargetsFromReleases,
|
||||
resolveStableDownloadTargets,
|
||||
} from './releaseDownloadPage'
|
||||
|
||||
describe('extractStableDmgUrl', () => {
|
||||
it('returns the stable dmg url when present', () => {
|
||||
describe('extractStableDownloadTargets', () => {
|
||||
it('returns stable downloads for each supported desktop platform when present', () => {
|
||||
expect(
|
||||
extractStableDmgUrl({
|
||||
extractStableDownloadTargets({
|
||||
platforms: {
|
||||
'darwin-aarch64': {
|
||||
dmg_url: 'https://example.com/Tolaria.dmg',
|
||||
download_url: 'https://example.com/Tolaria.dmg',
|
||||
},
|
||||
'linux-x86_64': {
|
||||
download_url: 'https://example.com/Tolaria.AppImage',
|
||||
},
|
||||
'windows-x86_64': {
|
||||
url: 'https://example.com/Tolaria-setup.exe',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe('https://example.com/Tolaria.dmg')
|
||||
})
|
||||
|
||||
it('returns null when the stable dmg url is missing', () => {
|
||||
expect(
|
||||
extractStableDmgUrl({
|
||||
platforms: {
|
||||
'darwin-aarch64': {
|
||||
url: 'https://example.com/Tolaria.app.tar.gz',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBeNull()
|
||||
).toMatchObject({
|
||||
'darwin-aarch64': {
|
||||
label: 'macOS',
|
||||
url: 'https://example.com/Tolaria.dmg',
|
||||
},
|
||||
'linux-x86_64': {
|
||||
label: 'Linux',
|
||||
url: 'https://example.com/Tolaria.AppImage',
|
||||
},
|
||||
'windows-x86_64': {
|
||||
label: 'Windows',
|
||||
url: 'https://example.com/Tolaria-setup.exe',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildStableDownloadRedirectPage', () => {
|
||||
it('builds a redirect page when a stable dmg exists', () => {
|
||||
const html = buildStableDownloadRedirectPage('https://example.com/Tolaria.dmg')
|
||||
it('builds a redirect page with platform-specific download links', () => {
|
||||
const html = buildStableDownloadRedirectPage({
|
||||
'darwin-aarch64': {
|
||||
buttonLabel: 'Download Tolaria for macOS',
|
||||
label: 'macOS',
|
||||
url: 'https://example.com/Tolaria.dmg',
|
||||
},
|
||||
'windows-x86_64': {
|
||||
buttonLabel: 'Download Tolaria for Windows',
|
||||
label: 'Windows',
|
||||
url: 'https://example.com/Tolaria-setup.exe',
|
||||
},
|
||||
})
|
||||
|
||||
expect(html).toContain('Tolaria Stable Download')
|
||||
expect(html).toContain('window.location.replace("https://example.com/Tolaria.dmg");')
|
||||
expect(html).toContain('Download latest stable DMG')
|
||||
expect(html).toContain('meta http-equiv="refresh"')
|
||||
expect(html).toContain('DOWNLOAD_TARGETS')
|
||||
expect(html).toContain('Download Tolaria for Windows')
|
||||
expect(html).toContain('Download Tolaria for macOS')
|
||||
expect(html).toContain('window.location.replace')
|
||||
})
|
||||
|
||||
it('builds a fallback page when no stable dmg exists yet', () => {
|
||||
const html = buildStableDownloadRedirectPage(null)
|
||||
it('builds a fallback page when no stable downloads exist yet', () => {
|
||||
const html = buildStableDownloadRedirectPage({})
|
||||
|
||||
expect(html).toContain('Tolaria Stable Download Unavailable')
|
||||
expect(html).toContain('View release history')
|
||||
expect(html).toContain('https://refactoringhq.github.io/tolaria/')
|
||||
expect(html).not.toContain('window.location.replace(')
|
||||
expect(html).not.toContain('DOWNLOAD_TARGETS')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveStableDmgUrl', () => {
|
||||
it('falls back to the latest stable github release dmg when latest.json has no dmg url', () => {
|
||||
describe('resolveStableDownloadTargets', () => {
|
||||
it('falls back to stable release assets when latest.json is incomplete', () => {
|
||||
const latestPayload = {
|
||||
platforms: {
|
||||
'darwin-aarch64': {
|
||||
url: 'https://example.com/Tolaria.app.tar.gz',
|
||||
download_url: 'https://example.com/Tolaria.dmg',
|
||||
},
|
||||
},
|
||||
}
|
||||
const releasesPayload = [
|
||||
{
|
||||
prerelease: true,
|
||||
assets: [
|
||||
{
|
||||
name: 'Tolaria-alpha.dmg',
|
||||
browser_download_url: 'https://example.com/alpha.dmg',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
prerelease: false,
|
||||
assets: [
|
||||
{
|
||||
name: 'Tolaria.dmg',
|
||||
browser_download_url: 'https://example.com/stable.dmg',
|
||||
name: 'Tolaria-setup.exe',
|
||||
browser_download_url: 'https://example.com/Tolaria-setup.exe',
|
||||
},
|
||||
{
|
||||
name: 'Tolaria.AppImage',
|
||||
browser_download_url: 'https://example.com/Tolaria.AppImage',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
expect(extractStableDmgUrlFromReleases(releasesPayload)).toBe('https://example.com/stable.dmg')
|
||||
expect(resolveStableDmgUrl(latestPayload, releasesPayload)).toBe('https://example.com/stable.dmg')
|
||||
expect(extractStableDownloadTargetsFromReleases(releasesPayload)).toMatchObject({
|
||||
'linux-x86_64': {
|
||||
url: 'https://example.com/Tolaria.AppImage',
|
||||
},
|
||||
'windows-x86_64': {
|
||||
url: 'https://example.com/Tolaria-setup.exe',
|
||||
},
|
||||
})
|
||||
expect(resolveStableDownloadTargets(latestPayload, releasesPayload)).toMatchObject({
|
||||
'darwin-aarch64': {
|
||||
url: 'https://example.com/Tolaria.dmg',
|
||||
},
|
||||
'linux-x86_64': {
|
||||
url: 'https://example.com/Tolaria.AppImage',
|
||||
},
|
||||
'windows-x86_64': {
|
||||
url: 'https://example.com/Tolaria-setup.exe',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
const RELEASE_HISTORY_URL = 'https://refactoringhq.github.io/tolaria/'
|
||||
|
||||
type StablePlatformKey = 'darwin-aarch64' | 'linux-x86_64' | 'windows-x86_64'
|
||||
|
||||
type PlatformPayload = {
|
||||
dmg_url?: unknown
|
||||
download_url?: unknown
|
||||
installer_url?: unknown
|
||||
url?: unknown
|
||||
}
|
||||
|
||||
type LatestReleasePayload = {
|
||||
@@ -19,15 +24,42 @@ type GitHubReleasePayload = {
|
||||
prerelease?: unknown
|
||||
}
|
||||
|
||||
type DownloadPageContent = {
|
||||
export type StableDownloadTarget = {
|
||||
buttonLabel: string
|
||||
destinationUrl: string
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type StableDownloadTargets = Partial<Record<StablePlatformKey, StableDownloadTarget>>
|
||||
|
||||
type DownloadPageContent = {
|
||||
helperText: string
|
||||
message: string
|
||||
shouldRedirect: boolean
|
||||
title: string
|
||||
}
|
||||
|
||||
const PLATFORM_METADATA: Record<StablePlatformKey, { buttonLabel: string; label: string }> = {
|
||||
'darwin-aarch64': {
|
||||
buttonLabel: 'Download Tolaria for macOS',
|
||||
label: 'macOS',
|
||||
},
|
||||
'linux-x86_64': {
|
||||
buttonLabel: 'Download Tolaria for Linux',
|
||||
label: 'Linux',
|
||||
},
|
||||
'windows-x86_64': {
|
||||
buttonLabel: 'Download Tolaria for Windows',
|
||||
label: 'Windows',
|
||||
},
|
||||
}
|
||||
|
||||
const PLATFORM_ORDER: StablePlatformKey[] = [
|
||||
'darwin-aarch64',
|
||||
'windows-x86_64',
|
||||
'linux-x86_64',
|
||||
]
|
||||
|
||||
const REDIRECT_PAGE_STYLES = `
|
||||
:root {
|
||||
color-scheme: light;
|
||||
@@ -49,7 +81,7 @@ const REDIRECT_PAGE_STYLES = `
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(100%, 460px);
|
||||
width: min(100%, 520px);
|
||||
background: #ffffff;
|
||||
border: 1px solid #e9e9e7;
|
||||
border-radius: 16px;
|
||||
@@ -68,6 +100,13 @@ const REDIRECT_PAGE_STYLES = `
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.button-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -81,10 +120,20 @@ const REDIRECT_PAGE_STYLES = `
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a[data-secondary="true"] {
|
||||
background: #eef2ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus-visible {
|
||||
background: #1248cc;
|
||||
}
|
||||
|
||||
a[data-secondary="true"]:hover,
|
||||
a[data-secondary="true"]:focus-visible {
|
||||
background: #dbe4ff;
|
||||
}
|
||||
`
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
@@ -95,115 +144,281 @@ function escapeHtml(value: string): string {
|
||||
.replaceAll('"', '"')
|
||||
}
|
||||
|
||||
export function extractStableDmgUrl(payload: unknown): string | null {
|
||||
function normalizeUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function buildStableDownloadTarget(
|
||||
platform: StablePlatformKey,
|
||||
url: string,
|
||||
): StableDownloadTarget {
|
||||
return {
|
||||
...PLATFORM_METADATA[platform],
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
function extractPlatformDownloadUrl(
|
||||
platform: StablePlatformKey,
|
||||
payload: PlatformPayload | undefined,
|
||||
): string | null {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
|
||||
switch (platform) {
|
||||
case 'darwin-aarch64':
|
||||
return (
|
||||
normalizeUrl(payload.download_url)
|
||||
?? normalizeUrl(payload.dmg_url)
|
||||
?? normalizeUrl(payload.url)
|
||||
)
|
||||
case 'windows-x86_64':
|
||||
return (
|
||||
normalizeUrl(payload.download_url)
|
||||
?? normalizeUrl(payload.installer_url)
|
||||
?? normalizeUrl(payload.url)
|
||||
)
|
||||
case 'linux-x86_64':
|
||||
return normalizeUrl(payload.download_url) ?? normalizeUrl(payload.url)
|
||||
}
|
||||
}
|
||||
|
||||
export function extractStableDownloadTargets(payload: unknown): StableDownloadTargets {
|
||||
if (!payload || typeof payload !== 'object') return {}
|
||||
|
||||
const { platforms } = payload as LatestReleasePayload
|
||||
if (!platforms || typeof platforms !== 'object') return null
|
||||
if (!platforms || typeof platforms !== 'object') return {}
|
||||
|
||||
const dmgUrl = platforms['darwin-aarch64']?.dmg_url
|
||||
if (typeof dmgUrl !== 'string') return null
|
||||
const downloads: StableDownloadTargets = {}
|
||||
for (const platform of PLATFORM_ORDER) {
|
||||
const url = extractPlatformDownloadUrl(platform, platforms[platform])
|
||||
if (url) downloads[platform] = buildStableDownloadTarget(platform, url)
|
||||
}
|
||||
|
||||
const trimmedUrl = dmgUrl.trim()
|
||||
return trimmedUrl.length > 0 ? trimmedUrl : null
|
||||
return downloads
|
||||
}
|
||||
|
||||
function isPublicStableRelease(release: GitHubReleasePayload): boolean {
|
||||
return release.draft !== true && release.prerelease !== true
|
||||
}
|
||||
|
||||
function extractAssetDmgUrl(asset: ReleaseAssetPayload): string | null {
|
||||
if (typeof asset.name !== 'string' || !asset.name.endsWith('.dmg')) return null
|
||||
if (typeof asset.browser_download_url !== 'string') return null
|
||||
|
||||
const trimmedUrl = asset.browser_download_url.trim()
|
||||
return trimmedUrl.length > 0 ? trimmedUrl : null
|
||||
}
|
||||
|
||||
function findReleaseDmgUrl(release: GitHubReleasePayload): string | null {
|
||||
if (!isPublicStableRelease(release)) return null
|
||||
if (!Array.isArray(release.assets)) return null
|
||||
|
||||
for (const asset of release.assets) {
|
||||
const dmgUrl = extractAssetDmgUrl(asset)
|
||||
if (dmgUrl !== null) return dmgUrl
|
||||
function classifyReleaseAsset(name: string): {
|
||||
platform: StablePlatformKey
|
||||
preference: number
|
||||
} | null {
|
||||
if (name.endsWith('.dmg')) {
|
||||
return { platform: 'darwin-aarch64', preference: 2 }
|
||||
}
|
||||
if (name.endsWith('.app.tar.gz')) {
|
||||
return { platform: 'darwin-aarch64', preference: 1 }
|
||||
}
|
||||
if (name.endsWith('-setup.exe')) {
|
||||
return { platform: 'windows-x86_64', preference: 2 }
|
||||
}
|
||||
if (name.endsWith('.msi')) {
|
||||
return { platform: 'windows-x86_64', preference: 1 }
|
||||
}
|
||||
if (name.endsWith('.AppImage')) {
|
||||
return { platform: 'linux-x86_64', preference: 2 }
|
||||
}
|
||||
if (name.endsWith('.deb')) {
|
||||
return { platform: 'linux-x86_64', preference: 1 }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractStableDmgUrlFromReleases(payload: unknown): string | null {
|
||||
if (!Array.isArray(payload)) return null
|
||||
type ReleaseAssetSelectionState = {
|
||||
downloads: StableDownloadTargets
|
||||
preferences: Partial<Record<StablePlatformKey, number>>
|
||||
}
|
||||
|
||||
type ReleaseAssetSelection = {
|
||||
platform: StablePlatformKey
|
||||
preference: number
|
||||
url: string
|
||||
}
|
||||
|
||||
function updateDownloadPreference(
|
||||
state: ReleaseAssetSelectionState,
|
||||
selection: ReleaseAssetSelection,
|
||||
) {
|
||||
const currentPreference = state.preferences[selection.platform] ?? Number.NEGATIVE_INFINITY
|
||||
if (selection.preference < currentPreference) return
|
||||
|
||||
state.preferences[selection.platform] = selection.preference
|
||||
state.downloads[selection.platform] = buildStableDownloadTarget(selection.platform, selection.url)
|
||||
}
|
||||
|
||||
function selectReleaseAsset(asset: ReleaseAssetPayload): ReleaseAssetSelection | null {
|
||||
const name = typeof asset.name === 'string' ? asset.name.trim() : ''
|
||||
const url = normalizeUrl(asset.browser_download_url)
|
||||
const classification = classifyReleaseAsset(name)
|
||||
if (!classification || !url) return null
|
||||
|
||||
return {
|
||||
...classification,
|
||||
url,
|
||||
}
|
||||
}
|
||||
|
||||
function extractStableDownloadTargetsFromAssets(
|
||||
assets: ReleaseAssetPayload[],
|
||||
): StableDownloadTargets {
|
||||
const state: ReleaseAssetSelectionState = {
|
||||
downloads: {},
|
||||
preferences: {},
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
const selection = selectReleaseAsset(asset)
|
||||
if (!selection) continue
|
||||
updateDownloadPreference(state, selection)
|
||||
}
|
||||
|
||||
return state.downloads
|
||||
}
|
||||
|
||||
function findPublicStableRelease(
|
||||
payload: unknown[],
|
||||
): GitHubReleasePayload | null {
|
||||
for (const release of payload) {
|
||||
if (!release || typeof release !== 'object') continue
|
||||
|
||||
const dmgUrl = findReleaseDmgUrl(release as GitHubReleasePayload)
|
||||
if (dmgUrl !== null) return dmgUrl
|
||||
const typedRelease = release as GitHubReleasePayload
|
||||
if (!isPublicStableRelease(typedRelease) || !Array.isArray(typedRelease.assets)) continue
|
||||
return typedRelease
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveStableDmgUrl(
|
||||
latestPayload: unknown,
|
||||
releasesPayload: unknown,
|
||||
): string | null {
|
||||
return extractStableDmgUrl(latestPayload) ?? extractStableDmgUrlFromReleases(releasesPayload)
|
||||
export function extractStableDownloadTargetsFromReleases(
|
||||
payload: unknown,
|
||||
): StableDownloadTargets {
|
||||
if (!Array.isArray(payload)) return {}
|
||||
|
||||
const stableRelease = findPublicStableRelease(payload)
|
||||
return stableRelease && Array.isArray(stableRelease.assets)
|
||||
? extractStableDownloadTargetsFromAssets(stableRelease.assets)
|
||||
: {}
|
||||
}
|
||||
|
||||
function buildStableDownloadPageContent(dmgUrl: string | null): DownloadPageContent {
|
||||
if (dmgUrl !== null) {
|
||||
export function resolveStableDownloadTargets(
|
||||
latestPayload: unknown,
|
||||
releasesPayload: unknown,
|
||||
): StableDownloadTargets {
|
||||
return {
|
||||
...extractStableDownloadTargetsFromReleases(releasesPayload),
|
||||
...extractStableDownloadTargets(latestPayload),
|
||||
}
|
||||
}
|
||||
|
||||
function buildStableDownloadPageContent(
|
||||
downloads: StableDownloadTargets,
|
||||
): DownloadPageContent {
|
||||
if (Object.keys(downloads).length > 0) {
|
||||
return {
|
||||
buttonLabel: 'Download latest stable DMG',
|
||||
destinationUrl: dmgUrl,
|
||||
helperText: 'If the download does not start automatically, use the button below.',
|
||||
message: 'Redirecting to the latest stable Tolaria DMG.',
|
||||
helperText: 'If the download does not start automatically, use one of the platform links below.',
|
||||
message: 'Preparing the latest stable Tolaria download for your platform.',
|
||||
shouldRedirect: true,
|
||||
title: 'Tolaria Stable Download',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buttonLabel: 'View release history',
|
||||
destinationUrl: RELEASE_HISTORY_URL,
|
||||
helperText: 'Use the button below to check the latest release history.',
|
||||
message: 'No stable Tolaria DMG is available yet.',
|
||||
message: 'No stable Tolaria downloads are available yet.',
|
||||
shouldRedirect: false,
|
||||
title: 'Tolaria Stable Download Unavailable',
|
||||
}
|
||||
}
|
||||
|
||||
function buildRedirectMarkup(destinationUrl: string, shouldRedirect: boolean): string {
|
||||
if (!shouldRedirect) return ''
|
||||
function buildDownloadsMarkup(downloads: StableDownloadTargets): string {
|
||||
const targets = PLATFORM_ORDER
|
||||
.map((platform) => downloads[platform])
|
||||
.filter((target): target is StableDownloadTarget => Boolean(target))
|
||||
|
||||
if (targets.length === 0) {
|
||||
return `<div class="button-list"><a id="download-link" href="${RELEASE_HISTORY_URL}" data-secondary="true">View release history</a></div>`
|
||||
}
|
||||
|
||||
const primaryTarget = targets[0]
|
||||
const secondaryLinks = targets
|
||||
.map((target) => (
|
||||
`<a href="${escapeHtml(target.url)}" data-secondary="true">${escapeHtml(target.label)}</a>`
|
||||
))
|
||||
.join('')
|
||||
|
||||
return `
|
||||
<div class="button-list">
|
||||
<a id="download-link" href="${escapeHtml(primaryTarget.url)}">${escapeHtml(primaryTarget.buttonLabel)}</a>
|
||||
</div>
|
||||
<div class="button-list">${secondaryLinks}</div>
|
||||
<div class="button-list">
|
||||
<a href="${RELEASE_HISTORY_URL}" data-secondary="true">View release history</a>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function buildRedirectMarkup(downloads: StableDownloadTargets): string {
|
||||
if (Object.keys(downloads).length === 0) return ''
|
||||
|
||||
const serializedTargets = JSON.stringify(downloads)
|
||||
|
||||
const escapedDestinationUrl = escapeHtml(destinationUrl)
|
||||
return `
|
||||
<meta http-equiv="refresh" content="0; url=${escapedDestinationUrl}">
|
||||
<script>
|
||||
window.location.replace(${JSON.stringify(destinationUrl)});
|
||||
const DOWNLOAD_TARGETS = ${serializedTargets};
|
||||
const PLATFORM_ORDER = ${JSON.stringify(PLATFORM_ORDER)};
|
||||
|
||||
function detectPlatform(userAgent) {
|
||||
if (/Windows/i.test(userAgent)) return 'windows-x86_64';
|
||||
if (/Mac OS X|Macintosh/i.test(userAgent)) return 'darwin-aarch64';
|
||||
if (/Linux/i.test(userAgent) && !/Android/i.test(userAgent)) return 'linux-x86_64';
|
||||
return null;
|
||||
}
|
||||
|
||||
const detectedPlatform = detectPlatform(navigator.userAgent);
|
||||
const resolvedTarget = (
|
||||
detectedPlatform && DOWNLOAD_TARGETS[detectedPlatform]
|
||||
) || DOWNLOAD_TARGETS[PLATFORM_ORDER.find((platform) => DOWNLOAD_TARGETS[platform])] || null;
|
||||
|
||||
if (resolvedTarget) {
|
||||
const link = document.getElementById('download-link');
|
||||
const message = document.getElementById('download-message');
|
||||
if (link) {
|
||||
link.href = resolvedTarget.url;
|
||||
link.textContent = resolvedTarget.buttonLabel;
|
||||
}
|
||||
if (message) {
|
||||
message.textContent = 'Redirecting to the latest stable Tolaria download for ' + resolvedTarget.label + '.';
|
||||
}
|
||||
window.location.replace(resolvedTarget.url);
|
||||
}
|
||||
</script>`
|
||||
}
|
||||
|
||||
export function buildStableDownloadRedirectPage(dmgUrl: string | null): string {
|
||||
const page = buildStableDownloadPageContent(dmgUrl)
|
||||
const escapedDestinationUrl = escapeHtml(page.destinationUrl)
|
||||
const redirectMarkup = buildRedirectMarkup(page.destinationUrl, page.shouldRedirect)
|
||||
export function buildStableDownloadRedirectPage(
|
||||
downloads: StableDownloadTargets,
|
||||
): string {
|
||||
const page = buildStableDownloadPageContent(downloads)
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${page.title}</title>${redirectMarkup}
|
||||
<title>${page.title}</title>${page.shouldRedirect ? buildRedirectMarkup(downloads) : ''}
|
||||
<style>${REDIRECT_PAGE_STYLES}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${page.title}</h1>
|
||||
<p>${page.message}</p>
|
||||
<p id="download-message">${page.message}</p>
|
||||
<p>${page.helperText}</p>
|
||||
<a id="download-link" href="${escapedDestinationUrl}">${page.buttonLabel}</a>
|
||||
${buildDownloadsMarkup(downloads)}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -21,8 +21,8 @@ describe('buildReleaseHistoryPage', () => {
|
||||
{
|
||||
assets: [
|
||||
{
|
||||
browser_download_url: 'https://example.com/Tolaria.app.tar.gz',
|
||||
name: 'Tolaria.app.tar.gz',
|
||||
browser_download_url: 'https://example.com/Tolaria-setup.exe',
|
||||
name: 'Tolaria-setup.exe',
|
||||
},
|
||||
],
|
||||
body: '**Alpha** notes with [details](https://example.com/details).',
|
||||
@@ -42,7 +42,7 @@ describe('buildReleaseHistoryPage', () => {
|
||||
expect(html).toContain('<h2>Highlights</h2>')
|
||||
expect(html).toContain('<li>Faster startup</li>')
|
||||
expect(html).toContain('<strong>Alpha</strong> notes')
|
||||
expect(html).toContain('Tolaria.app.tar.gz')
|
||||
expect(html).toContain('Tolaria-setup.exe')
|
||||
expect(html).toContain('View on GitHub')
|
||||
})
|
||||
|
||||
|
||||
@@ -430,7 +430,14 @@ function parsePublishedTimestamp(value: unknown): number {
|
||||
}
|
||||
|
||||
function isDownloadableAsset(name: string): boolean {
|
||||
return name.endsWith('.dmg') || name.endsWith('.app.tar.gz') || name.endsWith('.zip')
|
||||
return (
|
||||
name.endsWith('.dmg')
|
||||
|| name.endsWith('.app.tar.gz')
|
||||
|| name.endsWith('-setup.exe')
|
||||
|| name.endsWith('.msi')
|
||||
|| name.endsWith('.AppImage')
|
||||
|| name.endsWith('.deb')
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeDownloads(assets: ReleaseAssetPayload[] | undefined): ReleaseDownload[] {
|
||||
|
||||
Reference in New Issue
Block a user