diff --git a/.github/agents/dependency-update.md b/.github/agents/dependency-update.md index afa230d272..c4857490fa 100644 --- a/.github/agents/dependency-update.md +++ b/.github/agents/dependency-update.md @@ -90,7 +90,7 @@ higher-numbered lines exist there. **TypeScript is held at 5.9.3 in the libraries.** 7.x is the Go rewrite (`microsoft/typescript-go`), not an incremental release. `Boilerplate.Client.Core` is the canary and is already on 7.0.2 — if a bump is wanted, move one library project, build it, and only then the -rest. Do not move all fifteen at once. +rest. Do not move all sixteen at once. **Vitest is held at `^3`** in `src/Bmotion/Tests/bit-bmotion-js` and `src/Bswup/Tests/bit-bswup-js`. diff --git a/.github/agents/test-platform.md b/.github/agents/test-platform.md index 346db84bc5..43de6987c4 100644 --- a/.github/agents/test-platform.md +++ b/.github/agents/test-platform.md @@ -351,7 +351,7 @@ does not serve silently gets the newest one it does. `feat(release): v-X.Y.Z` commit on `develop` has its tag: an untagged release is answered by the previous one, without a word. 2. **What it does serve.** `mcp -McpVersion X.Y.Z` for each. The library tools (`Search*`, `Get*` and `Inspect*` of BlazorUI, - Brouter, Butil, Bswup, Bmotion and - from the first release that ships them - Bink, Bonboarding, Blex and Bmage) and the source tools (`FindBitPlatformSymbols`, `GetBitPlatformSymbolSource`, + Brouter, Butil, Bswup, Bmotion and - from the first release that ships them - Bink, Balette, Bonboarding, Blex and Bmage) and the source tools (`FindBitPlatformSymbols`, `GetBitPlatformSymbolSource`, `SearchBitPlatformCode`) must all be there. Only the five shared ones (`AskGitHubRepository`, `SendBitPlatformFeedback`, `microsoft_docs_*`) means no release is served at all, and the README's *PlatformWebsite's MCP* section says why that happens. The server version it reports is the website's own build, @@ -370,7 +370,7 @@ does not serve silently gets the newest one it does. 4. **The answers are good.** Put one real task to each library's `Search` tool, worded the way a developer would describe it - BlazorUI "a dropdown the user can also type into", Butil "copy text to the clipboard", Bmotion "fade a list item in", Brouter "a route parameter that must be a number", Bswup "show the download progress of the app's - update", Bink "a signature pad that exports a PNG", Bonboarding "a step that waits for a panel that loads later", Blex "a counter whose value survives a page reload", Bmage "shrink an uploaded photo under 200 KB without its location" - follow up with the `Get` tool it points to, and ask `SearchBitPlatformCode` about the Boilerplate. An + update", Bink "a signature pad that exports a PNG", Balette "a command that asks for a name before it runs", Bonboarding "a step that waits for a panel that loads later", Blex "a counter whose value survives a page reload", Bmage "shrink an uploaded photo under 200 KB without its location" - follow up with the `Get` tool it points to, and ask `SearchBitPlatformCode` about the Boilerplate. An answer must be on topic, specific and true to the version asked for; one that answers wrongly is a failure too. Like an E2E failure, an MCP problem is fixed in `src/Websites/Platform` or in the library's own MCP server, and goes diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4117982883..e6d4646597 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -46,8 +46,8 @@ ## Rules **RULE 1:** If a task (question, code modification or review) involves the use of bit BlazorUI components (e.g., `BitButton`, `BitTooltip`, `BitTextField`), -or refers to `bitplatform`, `bit Bswup`, `bit Butil`, `bit Bmotion`, `bit Bink`, `bit Bonboarding`, `bit Blex`, `bit Bmage`, `bit Brouter`, or `bit Boilerplate`, or involves UI components without explicitly specifying their UI toolkit, -you **MUST** use the `bitplatform` MCP server's tools - start with the matching `Search` tool (e.g. `SearchBitBlazorUI`, `SearchBswup`, `SearchButil`, `SearchBmotion`, `SearchBink`, `SearchBonboarding`, `SearchBlex`, `SearchBmage`, `SearchBrouter`) and follow the calls it names - to find the correct implementation and usage patterns before writing or changing any code. +or refers to `bitplatform`, `bit Bswup`, `bit Butil`, `bit Bmotion`, `bit Bink`, `bit Balette`, `bit Bonboarding`, `bit Blex`, `bit Bmage`, `bit Brouter`, or `bit Boilerplate`, or involves UI components without explicitly specifying their UI toolkit, +you **MUST** use the `bitplatform` MCP server's tools - start with the matching `Search` tool (e.g. `SearchBitBlazorUI`, `SearchBswup`, `SearchButil`, `SearchBmotion`, `SearchBink`, `SearchBalette`, `SearchBonboarding`, `SearchBlex`, `SearchBmage`, `SearchBrouter`) and follow the calls it names - to find the correct implementation and usage patterns before writing or changing any code. **End of RULE 1** diff --git a/.github/workflows/all.cd.yml b/.github/workflows/all.cd.yml index 514c0f7793..9744b32bb7 100644 --- a/.github/workflows/all.cd.yml +++ b/.github/workflows/all.cd.yml @@ -77,6 +77,12 @@ jobs: uses: ./.github/workflows/bink.demo.cd.yml secrets: inherit + balette_demo: + name: balette demo + if: inputs.server + uses: ./.github/workflows/balette.demo.cd.yml + secrets: inherit + bonboarding_demo: name: bonboarding demo if: inputs.server diff --git a/.github/workflows/all.ci.yml b/.github/workflows/all.ci.yml index d515416478..cf104ea397 100644 --- a/.github/workflows/all.ci.yml +++ b/.github/workflows/all.ci.yml @@ -59,6 +59,10 @@ on: description: Bink solution type: boolean default: true + balette: + description: Balette solution + type: boolean + default: true blex: description: Blex solution type: boolean @@ -603,6 +607,11 @@ jobs: dotnet build src/Bink/Bit.Bink/Bit.Bink.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false dotnet pack src/Bink/Bit.Bink/Bit.Bink.csproj --output ./packages --configuration Release + - name: Build and pack Balette + run: | + dotnet build src/Balette/Bit.Balette/Bit.Balette.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false + dotnet pack src/Balette/Bit.Balette/Bit.Balette.csproj --output ./packages --configuration Release + - name: Build and pack Blex run: | dotnet build src/Blex/Bit.Blex/Bit.Blex.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false @@ -701,6 +710,11 @@ jobs: if: ${{ inputs.bink }} uses: ./.github/workflows/bit.ci.Bink.yml + balette: + name: balette + if: ${{ inputs.balette }} + uses: ./.github/workflows/bit.ci.Balette.yml + blex: name: blex if: ${{ inputs.blex }} diff --git a/.github/workflows/balette.demo.cd.yml b/.github/workflows/balette.demo.cd.yml new file mode 100644 index 0000000000..41b0c18623 --- /dev/null +++ b/.github/workflows/balette.demo.cd.yml @@ -0,0 +1,75 @@ +name: Balette Demo CD + +on: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + + build_blazor_wasm: + name: build blazor wasm + runs-on: ubuntu-24.04 + + steps: + + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: src/global.json + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + + - name: Install wasm + run: cd src && dotnet workload install wasm-tools + + - name: Publish + run: dotnet publish src/Balette/Demo/Server/Bit.Balette.Demo.Server.csproj -c Release -o server -p:Version="${{ vars.APP_VERSION}}" -p:InvariantGlobalization=true + + # Server-owned; C:\inetpub keeps its own copies. + - name: Strip server-owned files from bundle + run: rm -fv server/appsettings*.json server/AppCertificate.* server/AppleAuthKey.p8 server/*.pfx + + - name: Upload artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: BaletteDemo + path: server + include-hidden-files: true + + deploy_server: + name: deploy + needs: build_blazor_wasm + runs-on: [self-hosted, Windows, X64, bitplatform-iis] + environment: + name: production + url: 'https://balette.bitplatform.dev' + + steps: + + - name: Checkout deploy action + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 1 + sparse-checkout: .github/actions + + - name: Deploy + uses: ./.github/actions/deploy-to-server + with: + artifact-name: BaletteDemo + app-pool: Balette + site-path: C:\inetpub\BaletteDemo + sentinel-file: Bit.Balette.Demo.Server.dll + health-check-url: 'https://balette.bitplatform.dev/' + cloudflare-zone-id: ${{ secrets.CLOUDFLARE_ZONE_ID }} + cloudflare-token: ${{ secrets.CLOUDFLARE_TOKEN }} diff --git a/.github/workflows/bit.ci.Balette.e2e.yml b/.github/workflows/bit.ci.Balette.e2e.yml new file mode 100644 index 0000000000..92d2e6879c --- /dev/null +++ b/.github/workflows/bit.ci.Balette.e2e.yml @@ -0,0 +1,61 @@ +name: bit platform CI - Balette E2E + +# The demo app in Chromium: opening and closing, typing and ranking, keyboard navigation, nested pages, modes and +# async sources, global shortcuts and sequences, theming and every demo page, against the Release build that +# ships. It takes far longer than the build-and-unit pipeline (bit.ci.Balette.yml), so it runs only when started +# by hand. See src/Balette/Tests/Bit.Balette.Tests.E2E/README.md for what the browser suite proves and how to run +# it locally. + +on: + workflow_dispatch: + +env: + DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: true + +permissions: + contents: read + +jobs: + + e2e: + name: Balette E2E (Chromium) + runs-on: ubuntu-24.04 + + steps: + + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: src/global.json + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + + - name: Build the demo and the E2E suite + run: | + dotnet build src/Balette/Demo/Server/Bit.Balette.Demo.Server.csproj -c Release + dotnet build src/Balette/Tests/Bit.Balette.Tests.E2E/Bit.Balette.Tests.E2E.csproj -c Release + + - name: Install Playwright Chromium + run: pwsh src/Balette/Tests/Bit.Balette.Tests.E2E/bin/Release/net10.0/playwright.ps1 install --with-deps chromium + + - name: Run E2E tests + # Run from inside src so src/global.json's Microsoft.Testing.Platform runner setting applies. + run: > + cd src/Balette/Tests/Bit.Balette.Tests.E2E && + dotnet test -c Release --no-build + --report-trx --report-trx-filename balette-e2e.trx + + - name: Upload test results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: balette-e2e + path: '**/balette-e2e.trx' + if-no-files-found: ignore diff --git a/.github/workflows/bit.ci.Balette.yml b/.github/workflows/bit.ci.Balette.yml new file mode 100644 index 0000000000..15a97e06ad --- /dev/null +++ b/.github/workflows/bit.ci.Balette.yml @@ -0,0 +1,65 @@ +name: bit platform CI - Balette + +# Build, model, search and component tests, and MCP server tests. The browser suite lives in +# bit.ci.Balette.e2e.yml and runs on demand only. + +on: + workflow_dispatch: + workflow_call: + pull_request: + paths: + - 'src/Balette/**' + +env: + DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: true + +permissions: + contents: read + +jobs: + + # Its `if:` skips the job on Release/Prerelease/Version PRs. + build-Balette: + if: startsWith(github.event.pull_request.title, 'Prerelease') != true && startsWith(github.event.pull_request.title, 'Release') != true && startsWith(github.event.pull_request.title, 'Version') != true + name: build Bit.Balette + runs-on: ubuntu-24.04 + + steps: + + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: src/global.json + + - name: Setup .NET 9.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 9.0.x + + - name: Setup .NET 8.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 8.0.x + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + + - name: dotnet workload restore + run: cd src/Balette && dotnet workload restore + + - name: dotnet build + run: dotnet build src/Balette/Bit.Balette.slnx + + - name: dotnet test (model, search + component tests, net8.0/net9.0/net10.0) + # Run from inside src so src/global.json's "test" runner setting applies + # (from the repo root the SDK falls back to the unsupported VSTest path). + run: cd src/Balette/Tests/Bit.Balette.Tests && dotnet test --no-build + + - name: dotnet test (MCP server) + run: cd src/Balette/Tests/Bit.Balette.Tests.Mcp && dotnet test --no-build diff --git a/.github/workflows/bit.ci.Bink.e2e.yml b/.github/workflows/bit.ci.Bink.e2e.yml new file mode 100644 index 0000000000..9f2e87a6b7 --- /dev/null +++ b/.github/workflows/bit.ci.Bink.e2e.yml @@ -0,0 +1,60 @@ +name: bit platform CI - Bink E2E + +# The demo app in Chromium: drawing with mouse, touch and pen input, keyboard handling, display scaling and every +# demo page, against the Release build that ships. It takes far longer than the build-and-unit pipeline +# (bit.ci.Bink.yml), so it runs only when started by hand. See src/Bink/Tests/Bit.Bink.Tests.E2E/README.md for +# what the browser suite proves and how to run it locally. + +on: + workflow_dispatch: + +env: + DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: true + +permissions: + contents: read + +jobs: + + e2e: + name: Bink E2E (Chromium) + runs-on: ubuntu-24.04 + + steps: + + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: src/global.json + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + + - name: Build the demo and the E2E suite + run: | + dotnet build src/Bink/Bit.Bink.Demo/Server/Bit.Bink.Demo.Server.csproj -c Release + dotnet build src/Bink/Tests/Bit.Bink.Tests.E2E/Bit.Bink.Tests.E2E.csproj -c Release + + - name: Install Playwright Chromium + run: pwsh src/Bink/Tests/Bit.Bink.Tests.E2E/bin/Release/net10.0/playwright.ps1 install --with-deps chromium + + - name: Run E2E tests + # Run from inside src so src/global.json's Microsoft.Testing.Platform runner setting applies. + run: > + cd src/Bink/Tests/Bit.Bink.Tests.E2E && + dotnet test -c Release --no-build + --report-trx --report-trx-filename bink-e2e.trx + + - name: Upload test results + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: bink-e2e + path: '**/bink-e2e.trx' + if-no-files-found: ignore diff --git a/.github/workflows/bit.ci.Bink.yml b/.github/workflows/bit.ci.Bink.yml index b048136f80..1a5f34e46a 100644 --- a/.github/workflows/bit.ci.Bink.yml +++ b/.github/workflows/bit.ci.Bink.yml @@ -1,8 +1,7 @@ name: bit platform CI - Bink -# The build/unit stage gates the browser stage below, so a broken build fails once instead of burning a second -# runner. See src/Bink/Tests/Bit.Bink.Tests.E2E/README.md for what the browser suite proves and how to run it -# locally. +# Build, engine and component tests, and MCP server tests. The browser suite lives in bit.ci.Bink.e2e.yml and +# runs on demand only. on: workflow_dispatch: @@ -19,8 +18,7 @@ permissions: jobs: - # Stage 1 - build, engine and component tests, MCP server tests. Every stage below depends on this one, so its - # `if:` is the single switch that skips the whole pipeline on Release/Prerelease/Version PRs. + # Its `if:` skips the job on Release/Prerelease/Version PRs. build-Bink: if: startsWith(github.event.pull_request.title, 'Prerelease') != true && startsWith(github.event.pull_request.title, 'Release') != true && startsWith(github.event.pull_request.title, 'Version') != true name: build Bit.Bink @@ -65,49 +63,3 @@ jobs: - name: dotnet test (MCP server) run: cd src/Bink/Tests/Bit.Bink.Tests.Mcp && dotnet test --no-build - - # Stage 2 - the demo app in Chromium: drawing with mouse, touch and pen input, keyboard handling, display scaling - # and every demo page, against the Release build that ships. - e2e: - needs: build-Bink - name: Bink E2E (Chromium) - runs-on: ubuntu-24.04 - - steps: - - - name: Checkout source code - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Setup .NET 10.0 - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - global-json-file: src/global.json - - - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: 24 - - - name: Build the demo and the E2E suite - run: | - dotnet build src/Bink/Bit.Bink.Demo/Server/Bit.Bink.Demo.Server.csproj -c Release - dotnet build src/Bink/Tests/Bit.Bink.Tests.E2E/Bit.Bink.Tests.E2E.csproj -c Release - - - name: Install Playwright Chromium - run: pwsh src/Bink/Tests/Bit.Bink.Tests.E2E/bin/Release/net10.0/playwright.ps1 install --with-deps chromium - - - name: Run E2E tests - # Run from inside src so src/global.json's Microsoft.Testing.Platform runner setting applies. - run: > - cd src/Bink/Tests/Bit.Bink.Tests.E2E && - dotnet test -c Release --no-build - --report-trx --report-trx-filename bink-e2e.trx - - - name: Upload test results - if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: bink-e2e - path: '**/bink-e2e.trx' - if-no-files-found: ignore diff --git a/.github/workflows/nuget.org.yml b/.github/workflows/nuget.org.yml index 5317c341c6..9f3dabbb7a 100644 --- a/.github/workflows/nuget.org.yml +++ b/.github/workflows/nuget.org.yml @@ -6,6 +6,7 @@ on: branches: - 'main' paths: + - 'src/Balette/**' - 'src/BlazorUI/**' - 'src/Bink/**' - 'src/Blex/**' @@ -175,6 +176,11 @@ jobs: - name: dotnet pack Bink run: dotnet pack src/Bink/Bit.Bink/Bit.Bink.csproj --output . --configuration Release + - name: dotnet build Balette + run: dotnet build src/Balette/Bit.Balette/Bit.Balette.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false + - name: dotnet pack Balette + run: dotnet pack src/Balette/Bit.Balette/Bit.Balette.csproj --output . --configuration Release + - name: dotnet build Blex run: dotnet build src/Blex/Bit.Blex/Bit.Blex.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false - name: dotnet pack Blex diff --git a/.github/workflows/prerelease.nuget.org.yml b/.github/workflows/prerelease.nuget.org.yml index f8bae598d4..0cb603e593 100644 --- a/.github/workflows/prerelease.nuget.org.yml +++ b/.github/workflows/prerelease.nuget.org.yml @@ -158,6 +158,11 @@ jobs: - name: dotnet pack Bink run: dotnet pack src/Bink/Bit.Bink/Bit.Bink.csproj --output . --configuration Release + - name: dotnet build Balette + run: dotnet build src/Balette/Bit.Balette/Bit.Balette.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false + - name: dotnet pack Balette + run: dotnet pack src/Balette/Bit.Balette/Bit.Balette.csproj --output . --configuration Release + - name: dotnet build Blex run: dotnet build src/Blex/Bit.Blex/Bit.Blex.csproj -c Release -p:GeneratePackageOnBuild=false -p:WarningLevel=0 -p:RunCodeAnalysis=false - name: dotnet pack Blex diff --git a/.gitignore b/.gitignore index fe3c47d1d7..5fe870a290 100644 --- a/.gitignore +++ b/.gitignore @@ -283,6 +283,10 @@ _book /src/Bink/Bit.Bink/wwwroot/**/*.js /src/Bink/Bit.Bink/wwwroot/**/*.css +/src/Balette/Bit.Balette/Scripts/**/*.js +/src/Balette/Bit.Balette/wwwroot/**/*.js +/src/Balette/Bit.Balette/wwwroot/**/*.css + /src/Blex/Bit.Blex.Blazor/Scripts/**/*.js /src/Blex/Bit.Blex.Blazor/wwwroot/**/*.js diff --git a/README.md b/README.md index cf6f3d959e..304e89cdf7 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ bit new MyApp - [***bit BlazorUI***](https://blazorui.bitplatform.dev) (`Bit.BlazorUI`): 90+ high-performance, accessible components with Dark and Light themes and RTL support, split across `Bit.BlazorUI`, `Bit.BlazorUI.Extras` (DataGrid, Chart, PdfViewer, RichTextEditor, Map, FullCalendar, CountryPicker, TimeZonePicker, and more), `Bit.BlazorUI.Icons` and `Bit.BlazorUI.Assets`. - [***Bink***](src/Bink/README.md) (`Bit.Bink`): A Blazor-native drawing and ink library, with pressure- and tilt-aware strokes, brushes, erasers, shapes, annotations, selection, layers, undo/redo, real-time collaboration and SVG/PNG export. The document model and the drawing engine run in C#; the browser only captures input and paints. +- [***Balette***](src/Balette/README.md) (`Bit.Balette`): A Blazor-native, keyboard-first command palette (`Ctrl+K` / `⌘K`), with fzf-style fuzzy search, nested pages, prefix modes, async sources, recents and frecency, global shortcuts and sequences, prompts, multi-select and full theming. Matching and ranking run in C#; the browser only opens the dialog and listens for keys. - [***Bmage***](src/Bmage/README.md) (`Bit.Bmage`): Browser-side image processing for Blazor. Load, crop, resize, adjust, annotate and export images on the user's device from strongly typed C#, with a full image editor component (`Bit.Bmage.Editor`) and a browser-independent engine (`Bit.Bmage.Core`). - [***Bmotion***](src/Bmotion/README.md) (`Bit.Bmotion`): A Blazor-native animation library inspired by Motion (Framer Motion), with springs, gestures, keyframes, variants, drag, layout (FLIP) and exit animations. The animation math runs in C#, so there's no JavaScript to wire up. - [***Bonboarding***](src/Bonboarding/README.md) (`Bit.Bonboarding`): A Blazor-native onboarding and product tour library, with spotlights, collision-aware placement, branching and multi-page tours, persistence and built-in accessibility. Every decision is made in C#; the browser layer only measures and observes. diff --git a/src/Balette/Bit.Balette.slnx b/src/Balette/Bit.Balette.slnx new file mode 100644 index 0000000000..45cecf075f --- /dev/null +++ b/src/Balette/Bit.Balette.slnx @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Balette/Bit.Balette/BaletteOptions.cs b/src/Balette/Bit.Balette/BaletteOptions.cs new file mode 100644 index 0000000000..b286dc6df3 --- /dev/null +++ b/src/Balette/Bit.Balette/BaletteOptions.cs @@ -0,0 +1,101 @@ +namespace Bit.Balette; + +/// Global configuration, registered via services.AddBitBaletteServices(o => ...). +public sealed class BaletteOptions +{ + /// Shortcut that toggles the palette. Default Mod+K (⌘K on macOS, Ctrl+K elsewhere). + public BaletteKeyboardShortcut? ToggleShortcut { get; set; } = BaletteKeyboardShortcut.Parse("Mod+K"); + + /// Additional toggle shortcuts (e.g. Ctrl+Shift+P, /). + public List AdditionalToggleShortcuts { get; } = []; + + /// Don't fire global command shortcuts while an input, textarea or contenteditable has focus. + public bool SuppressShortcutsInEditableElements { get; set; } = true; + + /// Milliseconds a multi-chord sequence (G I) waits for the next chord. + public int SequenceTimeoutMs { get; set; } = 1000; + + /// Debounce for async sources, in milliseconds. + public int DebounceMs { get; set; } = 150; + + /// Wrap around when arrowing past the first/last item. + public bool Loop { get; set; } = true; + + /// Enable Ctrl+J/Ctrl+K navigation inside the list (Ctrl+N/Ctrl+P are always on). + public bool VimBindings { get; set; } + + /// Track recents and frecency. + public bool EnableHistory { get; set; } = true; + + /// How many recent commands to show on the empty query. + public int MaxRecents { get; set; } = 5; + + /// Upper bound of history entries kept. + public int MaxHistoryEntries { get; set; } = 200; + + /// localStorage key for . + public string HistoryStorageKey { get; set; } = "bit-balette.history"; + + /// Keep group headings while searching (cmdk style). When false, results are a flat ranked list. + public bool GroupWhenSearching { get; set; } = true; + + /// Max rows rendered (groups excluded) for a query. Protects against huge registries. + public int MaxResults { get; set; } = 150; + + /// Explicit group order. Groups not listed come after, alphabetically. + public List GroupOrder { get; } = []; + + /// Weight of when a query is present (score points per priority unit). + public double PriorityWeight { get; set; } = 0.25; + + /// Weight of frecency when a query is present. + public double FrecencyWeight { get; set; } = 6; + + /// Show a "Modes" hint group on the empty query listing registered prefixes. + public bool ShowModeHints { get; set; } = true; + + /// Close when clicking the backdrop. + public bool CloseOnBackdropClick { get; set; } = true; + + /// Close when the app navigates (e.g. a handler called NavigationManager.NavigateTo). + public bool CloseOnNavigation { get; set; } = true; + + /// Reset query and page stack when closed. When false, reopening restores the previous state. + public bool ResetOnClose { get; set; } = true; + + /// Highlight matched characters in titles and subtitles. + public bool HighlightMatches { get; set; } = true; + + /// Show the footer with keyboard hints. + public bool ShowFooter { get; set; } = true; + + /// Render shortcut hints on rows. + public bool ShowShortcuts { get; set; } = true; + + /// Use <Virtualize> for the list (recommended above ~500 rows). Requires fixed . + public bool Virtualize { get; set; } + + /// Row height in pixels used by virtualization. + public float RowHeight { get; set; } = 44f; + + /// Text matcher used for scoring. Default . + public IBaletteMatcher Matcher { get; set; } = BaletteFuzzyMatcher.Default; + + /// Per-field weights for scoring. + public BaletteMatchWeights Weights { get; } = new(); + + /// UI strings. Subclass or set properties to localize. + public BaletteStrings Strings { get; set; } = new(); + + /// Commands registered at startup. + public List Commands { get; } = []; + + /// Modes registered at startup. + public List Modes { get; } = []; + + /// Async sources queried in the default mode. + public List Sources { get; } = []; + + /// Colour theme: auto (prefers-color-scheme), light or dark. + public string Theme { get; set; } = "auto"; +} diff --git a/src/Balette/Bit.Balette/Bit.Balette.csproj b/src/Balette/Bit.Balette/Bit.Balette.csproj new file mode 100644 index 0000000000..795ada4c82 --- /dev/null +++ b/src/Balette/Bit.Balette/Bit.Balette.csproj @@ -0,0 +1,131 @@ + + + + + + net10.0;net9.0;net8.0 + Bit.Balette + enable + true + A Blazor-native, accessible, keyboard-first command palette (Ctrl+K / Cmd+K): fzf-style fuzzy search, nested pages, prefix modes, async sources, recents and frecency, global shortcuts and sequences, multi-select, prompts, theming and full templating. Matching, ranking and every decision run in C#; the browser layer only handles the dialog, focus and keys. + $(Description) + Bit;Blazor;CommandPalette;Cmdk;CommandMenu;Search;FuzzySearch;Keyboard;Shortcuts;Accessibility + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_BaletteStaleJsStamps Include="$(BaseIntermediateOutputPath)bit-balette.js.*.stamp" /> + + + + + + + + + + + + + + <_BaletteStaleCssStamps Include="$(BaseIntermediateOutputPath)bit-balette.css.*.stamp" /> + + + + + + + + + diff --git a/src/Balette/Bit.Balette/BitBalette.cs b/src/Balette/Bit.Balette/BitBalette.cs new file mode 100644 index 0000000000..18a52fe77a --- /dev/null +++ b/src/Balette/Bit.Balette/BitBalette.cs @@ -0,0 +1,64 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bit.Balette; + +/// +/// Extension methods to register the Bit.Balette services in the DI container. +/// +public static class BitBalette +{ + /// + /// Registers everything the palette needs. Add <Balette /> once to a layout and inject + /// wherever commands are registered. The options are a singleton; the + /// registry, the open state and the history are scoped, which is per circuit on Blazor Server and per + /// application on WebAssembly. In a Blazor Web App, call it in both the server and the client project: + /// builder.Services.AddBitBaletteServices(); + /// + /// The service collection. + /// Optional global configuration: shortcuts, modes, sources, ranking weights, strings. + public static IServiceCollection AddBitBaletteServices(this IServiceCollection services, Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var options = new BaletteOptions(); + configure?.Invoke(options); + + services.TryAddSingleton(options); + services.TryAddSingleton(sp => new BaletteRanker(sp.GetRequiredService())); + services.TryAddScoped(); + services.TryAddScoped(sp => sp.GetRequiredService()); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddSingleton(TimeProvider.System); + return services; + } + + /// + /// Replaces where recents, pins and frecency are kept - by default the browser's localStorage - + /// for example with per-user storage on the server. + /// + public static IServiceCollection AddBitBaletteHistoryStore<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TStore>(this IServiceCollection services) + where TStore : class, IBaletteHistoryStore + { + ArgumentNullException.ThrowIfNull(services); + + services.Replace(ServiceDescriptor.Scoped()); + return services; + } + + /// Keeps the history in memory only, for the lifetime of the scope, so nothing is written to the browser. + public static IServiceCollection AddBitBaletteInMemoryHistoryStore(this IServiceCollection services) + => services.AddBitBaletteHistoryStore(); + + /// Registers an that is resolved from DI and queried in the default mode. + public static IServiceCollection AddBitBaletteSource<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TSource>(this IServiceCollection services) + where TSource : class, IBaletteSource + { + ArgumentNullException.ThrowIfNull(services); + + services.AddScoped(); + return services; + } +} diff --git a/src/Balette/Bit.Balette/Components/Balette.razor b/src/Balette/Bit.Balette/Components/Balette.razor new file mode 100644 index 0000000000..d706299f15 --- /dev/null +++ b/src/Balette/Bit.Balette/Components/Balette.razor @@ -0,0 +1,375 @@ +@namespace Bit.Balette + + + @ChildContent + + + + @if (_isOpen) + { + var frame = CurrentPage; + var state = StateContext; +
+
+ @* ---------- Header ---------- *@ + @if (HeaderTemplate is not null) + { + @HeaderTemplate(state) + } +
+ @if (_pages.Count > 1 || _mode is not null) + { + + } +
+ + + @if (frame.MultiSelect && frame.Selected.Count > 0) + { + @S.Format(S.SelectedCount, frame.Selected.Count) + } + +
+
+ + @* ---------- List ---------- *@ +
+ @if (frame.Prompt is not null) + { +
+

@frame.Prompt.Title

+

+ @(_isMac ? "↩" : "Enter") @S.Apply + · + Esc @S.Back +

+
+ } + else if (_rows.Count == 0) + { + if (_loading) + { + if (LoadingTemplate is not null) + { + @LoadingTemplate + } + else + { +
+ +

@S.Loading

+
+ } + } + else if (EmptyTemplate is not null) + { + @EmptyTemplate(_effectiveQuery) + } + else + { +
+ @IconSvg("empty") +

@(_effectiveQuery.Length > 0 ? S.Format(S.NoResults, _effectiveQuery) : S.Empty)

+ @if (_effectiveQuery.Length > 0) + { +

@S.NoResultsHint

+ } +
+ } + } + else if (EffectiveVirtualize) + { + + @RenderRow(row) + + } + else + { + foreach (var row in _rows) + { + @RenderRow(row) + } + } +
+ + @* ---------- Footer ---------- *@ + @if (EffectiveShowFooter) + { + if (FooterTemplate is not null) + { + @FooterTemplate(state) + } + else + { + + } + } +
+ + @* ---------- Detail pane ---------- *@ + @if (DetailTemplate is not null && ActiveCommand is { } detailCmd) + { + + } +
+ +
@_announcement
+ } +
+ +@code { + private RenderFragment RenderRow(BaletteRow row) => __builder => + { + if (row.Kind == BaletteRowKind.GroupHeader) + { + + return; + } + + var match = row.Match!; + var cmd = match.Command; + var isActive = row.ItemIndex == _activeIndex; + var isChecked = CurrentPage.MultiSelect && CurrentPage.Selected.Contains(cmd.Id); + var isPinned = EffectiveEnableHistory && History.IsPinned(cmd.Id); + var isConfirming = _confirmingId == cmd.Id; + +
+ @if (ItemTemplate is not null) + { + @ItemTemplate(new BaletteItemContext(row, isActive, isChecked, isPinned, isConfirming, _isMac)) + } + else + { + @if (CurrentPage.MultiSelect) + { + + } + + + + @if (isConfirming) + { + @S.ConfirmPrefix + } + @* One inline box for the text and its marks: as direct children of the flex title they would each + become a flex item, spaced apart by the title's gap and never ellipsized. *@ + + @if (cmd.Badge is not null) + { + @cmd.Badge + } + @if (isPinned) + { + @IconSvg("pin") + } + + @if (cmd.Subtitle is not null || match.MatchedKeyword is not null || (cmd.Disabled && cmd.DisabledReason is not null)) + { + + @if (cmd.Disabled && cmd.DisabledReason is not null) + { + @cmd.DisabledReason + } + else if (cmd.Subtitle is not null) + { + + } + else if (match.MatchedKeyword is not null) + { + @match.MatchedKeyword + } + + } + + + @if (cmd.Checked is { } chk) + { + + } + else if (cmd.Value is not null && !cmd.Tags.Contains(ModeHintTag)) + { + @cmd.Value + } + @if (EffectiveShowShortcuts && cmd.Shortcut is not null) + { + + } + @if (HasPage(cmd)) + { + + } + else if (isActive && cmd.Actions is { Count: > 0 }) + { + + } + + } +
+ }; + + private static string DefaultIconFor(BaletteCommand cmd) => cmd.Kind switch + { + BaletteCommandKind.Navigation => "arrow-right", + BaletteCommandKind.Page => "folder", + BaletteCommandKind.Toggle => "toggle", + BaletteCommandKind.Setting => "sliders", + BaletteCommandKind.Search => "search", + BaletteCommandKind.Help => "help", + BaletteCommandKind.Recent => "clock", + _ when cmd.Destructive => "trash", + _ => "bolt", + }; + + private static readonly Dictionary IconPaths = new(StringComparer.Ordinal) + { + ["search"] = "M11 4a7 7 0 1 0 4.2 12.6l3.6 3.6 1.4-1.4-3.6-3.6A7 7 0 0 0 11 4zm0 2a5 5 0 1 1 0 10 5 5 0 0 1 0-10z", + ["arrow-right"] = "M13 5l7 7-7 7-1.4-1.4 4.6-4.6H4v-2h12.2l-4.6-4.6z", + ["arrow-left"] = "M11 19l-7-7 7-7 1.4 1.4L7.8 11H20v2H7.8l4.6 4.6z", + ["chevron-right"] = "M9 6l6 6-6 6-1.4-1.4L12.2 12 7.6 7.4z", + ["folder"] = "M3 6a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z", + ["toggle"] = "M7 7h10a5 5 0 0 1 0 10H7A5 5 0 0 1 7 7zm10 2a3 3 0 1 0 0 6 3 3 0 0 0 0-6z", + ["sliders"] = "M4 6h10v2H4zm12 0h4v2h-4zM4 11h4v2H4zm6 0h10v2H10zM4 16h12v2H4zm14 0h2v2h-2z", + ["help"] = "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm-1-5h2v2h-2zm2.1-2.3c-.6.4-.1 1.3-1.1 1.3h-1c0-1.7.5-2 1.4-2.6.5-.3.6-.7.6-1.1a1 1 0 0 0-2 0H9a3 3 0 0 1 6 0c0 1.2-.7 1.8-1.9 2.4z", + ["clock"] = "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm1-13h-2v6l5 3 1-1.7-4-2.3z", + ["trash"] = "M9 3h6l1 2h4v2H4V5h4zm-3 5h12l-1 12H7zm4 2v8h2v-8zm4 0v8h2v-8z", + ["bolt"] = "M13 2L4 14h7l-1 8 9-12h-7z", + ["check"] = "M9 16.2l-3.5-3.5L4 14.2 9 19.2 20 8.2l-1.4-1.4z", + ["pin"] = "M16 3l5 5-4 1-3 3 1 5-2 2-4-4-5 5-1-1 5-5-4-4 2-2 5 1 3-3z", + ["empty"] = "M4 5h16v3H4zm0 5h16v9H4zm2 2v5h12v-5z", + }; + + private static RenderFragment IconSvg(string name) => __builder => + { + var path = IconPaths.GetValueOrDefault(name, IconPaths["bolt"]); + + }; +} diff --git a/src/Balette/Bit.Balette/Components/Balette.razor.cs b/src/Balette/Bit.Balette/Components/Balette.razor.cs new file mode 100644 index 0000000000..b43f24cfae --- /dev/null +++ b/src/Balette/Bit.Balette/Components/Balette.razor.cs @@ -0,0 +1,1803 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Routing; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.JSInterop; + +namespace Bit.Balette; + +/// +/// The command palette dialog. Place it once in your layout. Commands come from +/// , from declarative children, and from async sources. +/// +public partial class Balette : ComponentBase, IAsyncDisposable +{ + private const string ModeHintTag = "__balette_mode_hint"; + + private readonly string _id = $"bit-balette-{Guid.NewGuid():N}"[..20]; + private readonly List _declarative = []; + private readonly List _pages = []; + private readonly Dictionary> _sourceResults = new(StringComparer.Ordinal); + private readonly HashSet _pendingSources = new(StringComparer.Ordinal); + private readonly List _subscriptions = []; + + private ElementReference _dialogEl; + private ElementReference _inputEl; + private ElementReference _listEl; + private IJSObjectReference? _module; + private IJSObjectReference? _handle; + private DotNetObjectReference? _selfRef; + + private bool _isOpen; + private bool _pendingOpen; + private bool _selectOnOpen; + private bool _pendingFocus; + private bool _pendingScroll; + private bool _isMac; + private string _query = string.Empty; + private int _activeIndex; + private bool _loading; + private string? _confirmingId; + private string? _toast; + private string _announcement = string.Empty; + private CancellationTokenSource? _debounceCts; + private CancellationTokenSource? _executeCts; + private CancellationTokenSource? _toastCts; + private bool _executing; + private bool _pointerSelectionSuspended; + private int _hoverSuspension; + private bool _selectionReset; + private bool _syncedVim; + private string _syncedToggles = string.Empty; + private bool _inputPending; + private int _inputVersion; + private TimeSpan _lastSearchCost; + + private List _all = []; + private HashSet _parentIds = new(StringComparer.Ordinal); + private IEnumerable? _lastCommandsParameter; + private List _rows = []; + private List _itemRows = []; + private BaletteCommandMode? _mode; + private string _effectiveQuery = string.Empty; + private List _registryMatches = []; + private (BalettePageFrame? Page, BaletteCommandMode? Mode, string? Query) _registryMatchKey; + + [Inject] private IJSRuntime JS { get; set; } = default!; + [Inject] private NavigationManager Navigation { get; set; } = default!; + [Inject] private IServiceProvider Services { get; set; } = default!; + [Inject] private BaletteService Service { get; set; } = default!; + [Inject] private BaletteOptions Options { get; set; } = default!; + [Inject] private BaletteRanker Ranker { get; set; } = default!; + [Inject] private BaletteHistory History { get; set; } = default!; + + // ---- Parameters ------------------------------------------------------------------------- + + /// Declarative / children. + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// Placeholder override for the root page. + [Parameter] public string? Placeholder { get; set; } + + /// Strings override (defaults to ). + [Parameter] public BaletteStrings? Strings { get; set; } + + /// Extra CSS class on the dialog element. + [Parameter] public string? Class { get; set; } + + /// Inline style on the dialog element (e.g. --bit-balette-width: 720px). + [Parameter] public string? Style { get; set; } + + /// Theme override: auto, light or dark. + [Parameter] public string? Theme { get; set; } + + /// Render no built-in styles/classes beyond structural data attributes. + [Parameter] public bool Unstyled { get; set; } + + /// Controlled open state (two-way bindable). + [Parameter] public bool Open { get; set; } + + /// Raised when the palette opens or closes, for @bind-Open. + [Parameter] public EventCallback OpenChanged { get; set; } + + /// Replaces the content of every row. The row element itself - its role, ARIA state, data attributes and click handling - stays the palette's. + [Parameter] public RenderFragment? ItemTemplate { get; set; } + + /// Replaces the content of a group heading; receives the heading text. + [Parameter] public RenderFragment? GroupHeaderTemplate { get; set; } + + /// Replaces the "no results" state; receives the query that found nothing. + [Parameter] public RenderFragment? EmptyTemplate { get; set; } + + /// Replaces the loading state shown while a lazily loaded page or an async source has nothing to show yet. + [Parameter] public RenderFragment? LoadingTemplate { get; set; } + + /// Content rendered above the search box, with a snapshot of the palette's state. + [Parameter] public RenderFragment? HeaderTemplate { get; set; } + + /// Replaces the footer with its keyboard hints and toasts, with a snapshot of the palette's state. + [Parameter] public RenderFragment? FooterTemplate { get; set; } + + /// Renders a detail/preview pane for the active command (Raycast style). + [Parameter] public RenderFragment? DetailTemplate { get; set; } + + /// Custom icon renderer. Receives the command; return nothing for no icon. + [Parameter] public RenderFragment? IconTemplate { get; set; } + + /// Override . + [Parameter] public bool? Loop { get; set; } + + /// Override of : Ctrl+J/Ctrl+K move the selection. + [Parameter] public bool? VimBindings { get; set; } + + /// Override of : render the list through <Virtualize>. + [Parameter] public bool? Virtualize { get; set; } + + /// Override of . + [Parameter] public bool? ShowFooter { get; set; } + + /// Override of : show each command's shortcut on its row. + [Parameter] public bool? ShowShortcuts { get; set; } + + /// Override of : mark the matched characters. + [Parameter] public bool? HighlightMatches { get; set; } + + /// Override of : recents, pins and frecency. + [Parameter] public bool? EnableHistory { get; set; } + + /// Override of : keep group headings while a query is typed. + [Parameter] public bool? GroupWhenSearching { get; set; } + + /// Ignore mouse hover for selecting the active item (keyboard only). + [Parameter] public bool DisablePointerSelection { get; set; } + + /// Toggle shortcut override (e.g. Ctrl+Shift+P). + [Parameter] public string? ToggleShortcut { get; set; } + + /// Commands supplied directly to this instance (in addition to the service registry). + [Parameter] public IEnumerable? Commands { get; set; } + + /// Raised after the palette opened. + [Parameter] public EventCallback OnOpened { get; set; } + + /// Raised after the palette closed. + [Parameter] public EventCallback OnClosed { get; set; } + + /// Raised after every change of the query, with the raw text (a mode prefix included). + [Parameter] public EventCallback OnQueryChanged { get; set; } + + /// Raised after a command of this palette ran, with its result and how long the handler took. + [Parameter] public EventCallback OnCommandExecuted { get; set; } + + /// Any other attribute, rendered on the <dialog> element. + [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary? AdditionalAttributes { get; set; } + + // ---- Public API -------------------------------------------------------------------------- + + /// Whether the dialog is open. + public bool IsOpen => _isOpen; + + /// Current raw query (including a mode prefix). + public string Query => _query; + + /// Currently highlighted command, if any. + public BaletteCommand? ActiveCommand => _activeIndex >= 0 && _activeIndex < _itemRows.Count ? _itemRows[_activeIndex].Command : null; + + /// Opens the palette, optionally with a query (a mode prefix is honoured) or directly on a command's page. + public Task OpenAsync(string? query = null, string? pageCommandId = null) => OpenCoreAsync(query, pageCommandId); + + /// Closes the palette. + public Task CloseAsync() => CloseCoreAsync(); + + /// Opens the palette when it is closed and closes it when it is open. + public Task ToggleAsync() => _isOpen ? CloseCoreAsync() : OpenCoreAsync(null, null); + + /// Sets the query programmatically. + public Task SetQueryAsync(string query) + { + _query = query ?? string.Empty; + return OnQueryUpdatedAsync(); + } + + // ---- Lifecycle --------------------------------------------------------------------------- + + protected override void OnInitialized() + { + Service.OpenRequested += OpenFromServiceAsync; + Service.CloseRequested += CloseFromServiceAsync; + Service.ExecuteRequested += ExecuteFromServiceAsync; + Service.CommandsChanged += OnRegistryChanged; + History.Changed += OnHistoryChanged; + Navigation.LocationChanged += OnLocationChanged; + + foreach (var source in Services.GetService(typeof(IEnumerable)) as IEnumerable ?? []) + { + if (!Service.Sources.Contains(source)) + { + _subscriptions.Add(Service.RegisterSource(source)); + } + } + + _pages.Add(new BalettePageFrame { Title = S.Home }); + Rebuild(); + } + + protected override async Task OnParametersSetAsync() + { + if (!ReferenceEquals(_lastCommandsParameter, Commands)) + { + _lastCommandsParameter = Commands; + Rebuild(); + SyncShortcuts(); + } + + SyncBrowserOptions(); + + if (Open && !_isOpen) + { + await OpenCoreAsync(null, null); + } + else if (!Open && _isOpen && OpenChanged.HasDelegate) + { + await CloseCoreAsync(); + } + } + + // A keystroke whose search is still deferred (see OnInput) renders together with its results. + protected override bool ShouldRender() => !_inputPending; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + _module = await JS.InvokeAsync("import", "./_content/Bit.Balette/bit-balette.js"); + _selfRef = DotNetObjectReference.Create(this); + var toggles = ToggleShortcutStrings(); + _syncedToggles = string.Join('\n', toggles); + _syncedVim = EffectiveVim; + _handle = await _module.InvokeAsync("init", _dialogEl, _selfRef, BuildJsOptions(toggles)); + SyncBrowserOptions(); // parameters set while init was on its way + _isMac = await _handle.InvokeAsync("isMac"); + Service.SetPlatform(_isMac); + StateHasChanged(); + } + catch (JSException) + { + // Prerendering without JS or module blocked: palette will still render but without hotkeys. + } + } + + if (_handle is null) + { + return; + } + + if (_pendingOpen) + { + _pendingOpen = false; + // Closed again before this render: showing the dialog now would leave an empty modal nothing dismisses. + if (_isOpen) + { + await _handle.InvokeVoidAsync("open", _inputEl, _selectOnOpen); + _pendingScroll = true; + } + } + + if (_pendingFocus) + { + _pendingFocus = false; + // A command run from a shortcut or from code can ask for focus (KeepOpen, Reset) while the palette is + // closed, and the input only exists while it is open. + if (_isOpen) + { + await _handle.InvokeVoidAsync("focusInput", _inputEl); + } + } + + if (_pendingScroll && _isOpen) + { + _pendingScroll = false; + if (_activeIndex >= 0 && _activeIndex < _itemRows.Count) + { + // The row's position lets the browser layer reach a row Virtualize has not rendered. + var active = _itemRows[_activeIndex]; + await _handle.InvokeVoidAsync("scrollIntoView", _listEl, active.ElementId, active.Position, _rows.Count); + } + } + } + + public async ValueTask DisposeAsync() + { + Service.OpenRequested -= OpenFromServiceAsync; + Service.CloseRequested -= CloseFromServiceAsync; + Service.ExecuteRequested -= ExecuteFromServiceAsync; + Service.CommandsChanged -= OnRegistryChanged; + History.Changed -= OnHistoryChanged; + Navigation.LocationChanged -= OnLocationChanged; + foreach (var s in _subscriptions) + { + s.Dispose(); + } + + _debounceCts?.Cancel(); + _executeCts?.Cancel(); + _toastCts?.Cancel(); + + try + { + if (_handle is not null) + { + await _handle.InvokeVoidAsync("dispose"); + await _handle.DisposeAsync(); + } + + if (_module is not null) + { + await _module.DisposeAsync(); + } + } + catch (JSDisconnectedException) + { + } + catch (JSException) + { + } + + _selfRef?.Dispose(); + } + + // ---- Declarative registration (used by BaletteItem) ------------------------------------- + + internal void AddDeclarative(BaletteCommand command) + { + _declarative.RemoveAll(c => c.Id == command.Id); + _declarative.Add(command); + Rebuild(); + SyncShortcuts(); + _ = InvokeAsync(StateHasChanged); + } + + /// True when selecting the command opens a nested page (static, lazy, or declaratively registered children). + internal bool HasPage(BaletteCommand cmd) => cmd.HasChildren || _parentIds.Contains(cmd.Id); + + internal void RemoveDeclarative(string id) + { + if (_declarative.RemoveAll(c => c.Id == id) > 0) + { + Rebuild(); + SyncShortcuts(); + _ = InvokeAsync(StateHasChanged); + } + } + + // ---- Service / environment callbacks -------------------------------------------------------- + + private void OnRegistryChanged() => _ = InvokeAsync(() => + { + Rebuild(); + SyncShortcuts(); + StateHasChanged(); + }); + + private void OnHistoryChanged() => _ = InvokeAsync(() => + { + Rebuild(); + StateHasChanged(); + }); + + private void OnLocationChanged(object? sender, LocationChangedEventArgs e) + { + if (_isOpen && Options.CloseOnNavigation && !_executing) + { + _ = InvokeAsync(CloseCoreAsync); + } + } + + // The service can be called from any thread - a timer, a hub callback, code after ConfigureAwait(false) - and opening + // or closing renders, so its requests move onto the renderer's dispatcher first. + private Task OpenFromServiceAsync(string? query, string? pageCommandId) => InvokeAsync(() => OpenCoreAsync(query, pageCommandId)); + + private Task CloseFromServiceAsync() => InvokeAsync(CloseCoreAsync); + + private Task ExecuteFromServiceAsync(string commandId) => InvokeAsync(async () => + { + var command = FindCommand(commandId) ?? throw new KeyNotFoundException($"Command '{commandId}' is not registered."); + await ExecuteCommandAsync(command, viaKeyboard: false); + StateHasChanged(); + }); + + /// + /// Looks a command up in the registry first, then among the ones declared in markup and passed to : + /// the order in which keeps one command per id. + /// + private BaletteCommand? FindCommand(string commandId) + => Service.Find(commandId) + ?? _declarative.FirstOrDefault(c => c.Id == commandId) + ?? Commands?.FirstOrDefault(c => c.Id == commandId); + + /// + /// The page a command with children opens. Children registered through the service are flattened into the + /// registry with their parent set, and found there; the static children of a command that never went through + /// the registry - declared in markup or passed to - are listed from the command itself. + /// + private BalettePageFrame PageFor(BaletteCommand cmd, string queryBefore) => new() + { + Title = cmd.Title, + Command = cmd, + Items = !_parentIds.Contains(cmd.Id) && cmd.Children is { Count: > 0 } ? cmd.Children : null, + Placeholder = cmd.ChildrenPlaceholder, + MultiSelect = cmd.MultiSelectChildren, + OnSubmit = cmd.Handler, + QueryBefore = queryBefore, + }; + + /// Opens the palette when something that needs it - a page, a prompt, a confirmation - starts while it is closed. + private Task EnsureOpenAsync() => _isOpen ? Task.CompletedTask : OpenCoreAsync(null, null); + + // ---- Open / close ---------------------------------------------------------------------------- + + private async Task OpenCoreAsync(string? query, string? pageCommandId) + { + if (_isOpen && query is null && pageCommandId is null) + { + return; + } + + // Closing already resets when ResetOnClose is set; an explicit query or page always starts fresh. + if (query is not null || pageCommandId is not null) + { + ResetState(); + } + + if (pageCommandId is not null && FindCommand(pageCommandId) is { } page) + { + PushPage(PageFor(page, string.Empty)); + if (page.ChildrenProvider is not null) + { + _ = LoadChildrenAsync(_pages[^1], page); + } + } + + if (query is not null) + { + _query = query; + } + + var wasOpen = _isOpen; + if (!wasOpen && Options.ResetOnClose) + { + // The state was reset on closing, but rebuilds since may have put another command on the first row. + ResetSelection(); + } + + _isOpen = true; + _pendingOpen = !wasOpen; + // A query passed in on purpose - often a mode prefix - is kept for typing after; one left over from the + // last time the palette was open is selected, so typing replaces it. + _selectOnOpen = query is null; + Service.SetOpen(true); + + if (EffectiveEnableHistory) + { + _ = History.EnsureLoadedAsync(); + } + + Rebuild(); + TriggerSourceSearch(); + StateHasChanged(); + + if (!wasOpen) + { + if (OpenChanged.HasDelegate) + { + await OpenChanged.InvokeAsync(true); + } + + await OnOpened.InvokeAsync(); + } + } + + private async Task CloseCoreAsync() + { + if (!_isOpen) + { + return; + } + + _isOpen = false; + _pendingOpen = false; + _inputPending = false; + _debounceCts?.Cancel(); + Disarm(); + Service.SetOpen(false); + + // A handler still running would answer into a palette the user has dismissed - a page or a prompt would reopen + // it - so it is told, and its answer is dropped (see RunHandlerAsync). + if (_executing) + { + _executeCts?.Cancel(); + } + + if (_handle is not null) + { + try + { + await _handle.InvokeVoidAsync("close"); + } + catch (JSException) + { + } + } + + if (Options.ResetOnClose) + { + ResetState(); + } + + StateHasChanged(); + + if (OpenChanged.HasDelegate) + { + await OpenChanged.InvokeAsync(false); + } + + await OnClosed.InvokeAsync(); + } + + private void ResetState() + { + _query = string.Empty; + _inputPending = false; + _pages.Clear(); + _pages.Add(new BalettePageFrame { Title = S.Home }); + _sourceResults.Clear(); + _pendingSources.Clear(); + _loading = false; + ResetSelection(); + _confirmingId = null; + ShowToast(null); + } + + /// Puts the highlight on the first row of the next rebuild, instead of keeping it on the command it was on. + private void ResetSelection() + { + _activeIndex = 0; + _selectionReset = true; + } + + /// Drops a pending confirmation together with the prompt that asked for it. + private void Disarm() + { + if (_confirmingId is null) + { + return; + } + + _confirmingId = null; + ShowToast(null); + } + + // ---- Input ------------------------------------------------------------------------------------ + + private async Task OnInput(ChangeEventArgs e) + { + _query = e.Value?.ToString() ?? string.Empty; + if (_handle is null || _lastSearchCost < SlowSearchThreshold) + { + await OnQueryUpdatedAsync(); + return; + } + + // Searching takes longer than a frame: let the keystroke paint first, and fold keys typed meanwhile into one search. + // Rendering waits too (see ShouldRender), so the list never re-renders with stale rows in between. + var version = ++_inputVersion; + _inputPending = true; + try + { + await _handle.InvokeVoidAsync("afterPaint"); + } + catch (Exception ex) when (ex is JSException or JSDisconnectedException or OperationCanceledException or ObjectDisposedException) + { + } + + if (_inputPending && version == _inputVersion) + { + await OnQueryUpdatedAsync(); + } + } + + private async Task OnQueryUpdatedAsync() + { + _inputPending = false; + Disarm(); + ResetSelection(); + // The rows change under a pointer resting on the list; hover selects again once it moves (see OnPointerMoved). + _pointerSelectionSuspended = true; + _hoverSuspension++; + var started = Stopwatch.GetTimestamp(); + Rebuild(); + _lastSearchCost = Stopwatch.GetElapsedTime(started); + TriggerSourceSearch(); + _pendingScroll = true; + if (OnQueryChanged.HasDelegate) + { + await OnQueryChanged.InvokeAsync(_query); + } + } + + /// Called from JS for navigation keys (already preventDefault-ed). + [JSInvokable] + public async Task OnKeyDown(string key, bool ctrl, bool shift, bool alt, bool meta) + { + if (!_isOpen) + { + return; + } + + // Navigation acts on the results of everything typed so far. + if (_inputPending) + { + await OnQueryUpdatedAsync(); + } + + // Browsers report Shift+letter as an upper-case key ("P"); match bindings case-insensitively. + if (key.Length == 1) + { + key = key.ToLowerInvariant(); + } + + var mod = _isMac ? meta : ctrl; + var page = CurrentPage; + switch (key) + { + case "ArrowDown": + Move(+1); + break; + case "ArrowUp": + Move(-1); + break; + case "n" when ctrl && !shift: + case "j" when ctrl && !shift && EffectiveVim: + Move(+1); + break; + case "p" when ctrl && !shift: + case "k" when ctrl && !shift && EffectiveVim: + Move(-1); + break; + case "Home": + SetActive(0); + break; + case "End": + SetActive(_itemRows.Count - 1); + break; + case "PageDown": + Move(+10, clamp: true); + break; + case "PageUp": + Move(-10, clamp: true); + break; + case "Enter": + if (page.Prompt is not null) + { + await SubmitPromptAsync(); + } + else if (mod && page.MultiSelect) + { + await SubmitMultiSelectAsync(); + } + else if (mod && ActiveCommand is { Actions.Count: > 0 } ac) + { + // Mod+Enter runs the first secondary action. + await ExecuteCommandAsync(ac.Actions[0], viaKeyboard: true); + } + else if (ActiveCommand is { } cmd) + { + await ExecuteCommandAsync(cmd, viaKeyboard: true); + } + + break; + case "Tab": + case "ArrowRight" when string.IsNullOrEmpty(_query) || key == "Tab": + if (ActiveCommand is { } target) + { + await OpenActionsAsync(target); + } + + break; + case " " when page.MultiSelect && ActiveCommand is { } toggled: + ToggleSelection(toggled); + break; + case "Backspace" when string.IsNullOrEmpty(_query) && _pages.Count > 1: + case "ArrowLeft" when string.IsNullOrEmpty(_query) && _pages.Count > 1: + PopPage(); + break; + case "Escape": + if (_confirmingId is not null) + { + Disarm(); + } + else if (_pages.Count > 1) + { + PopPage(); + } + else + { + await CloseCoreAsync(); + } + + break; + case "Delete" when shift && ActiveCommand is { } removable && EffectiveEnableHistory: + await History.RemoveAsync(removable.Id); + break; + case "p" when mod && shift && ActiveCommand is { } pinnable && EffectiveEnableHistory: + await History.TogglePinAsync(pinnable.Id); + break; + } + + StateHasChanged(); + } + + /// Called by the browser layer when a toggle shortcut was pressed. Not meant to be called from application code. + [JSInvokable] + public Task OnToggleShortcut() => InvokeAsync(ToggleAsync); + + /// Called by the browser layer when a command's global shortcut was completed. Not meant to be called from application code. + [JSInvokable] + public Task OnGlobalShortcut(string normalized) => InvokeAsync(async () => + { + var cmd = _all.FirstOrDefault(c => c.Shortcut is not null && c.GlobalShortcut && c.Shortcut.Normalized == normalized); + if (cmd is null || cmd.Disabled || cmd.IsVisible?.Invoke() == false) + { + return; + } + + await ExecuteCommandAsync(cmd, viaKeyboard: true); + StateHasChanged(); + }); + + /// Called by the browser layer when the backdrop was clicked. Not meant to be called from application code. + [JSInvokable] + public Task OnBackdropClick() => Options.CloseOnBackdropClick ? InvokeAsync(CloseCoreAsync) : Task.CompletedTask; + + /// + /// Called by the browser layer when the pointer first moved after the dialog's data-hover-suspended changed, + /// which re-enables hover selection. Not meant to be called from application code. + /// + [JSInvokable] + public Task OnPointerMoved() + { + _pointerSelectionSuspended = false; + return Task.CompletedTask; + } + + private void Move(int delta, bool clamp = false) + { + if (_itemRows.Count == 0) + { + return; + } + + var next = _activeIndex + delta; + if (clamp || !EffectiveLoop) + { + next = Math.Clamp(next, 0, _itemRows.Count - 1); + } + else + { + next = ((next % _itemRows.Count) + _itemRows.Count) % _itemRows.Count; + } + + SetActive(next); + } + + private void SetActive(int index) + { + if (_itemRows.Count == 0) + { + _activeIndex = 0; + return; + } + + _activeIndex = Math.Clamp(index, 0, _itemRows.Count - 1); + Disarm(); + _pendingScroll = true; + } + + private void OnItemHover(BaletteRow row) + { + if (DisablePointerSelection || _pointerSelectionSuspended) + { + return; + } + + if (row.ItemIndex != _activeIndex) + { + _activeIndex = row.ItemIndex; + Disarm(); + } + } + + private Task OnItemClick(BaletteRow row) + { + if (row.ItemIndex != _activeIndex) + { + _activeIndex = row.ItemIndex; + Disarm(); + } + + return ExecuteCommandAsync(row.Command, viaKeyboard: false); + } + + private Task OnBackClick() + { + if (_pages.Count > 1) + { + PopPage(); + } + + return Task.CompletedTask; + } + + // ---- Pages ------------------------------------------------------------------------------------- + + private BalettePageFrame CurrentPage => _pages[^1]; + + private void PushPage(BalettePageFrame frame) + { + _pages.Add(frame); + _query = frame.Prompt?.InitialValue ?? string.Empty; + _inputPending = false; + ResetSelection(); + Disarm(); + _sourceResults.Clear(); + _pendingFocus = true; + _pendingScroll = true; + Rebuild(); + } + + private void PopPage() + { + if (_pages.Count <= 1) + { + return; + } + + var frame = _pages[^1]; + _pages.RemoveAt(_pages.Count - 1); + _query = frame.QueryBefore; + _inputPending = false; + ResetSelection(); + Disarm(); + _sourceResults.Clear(); + _pendingFocus = true; + _pendingScroll = true; + Rebuild(); + TriggerSourceSearch(); + } + + private async Task LoadChildrenAsync(BalettePageFrame frame, BaletteCommand parent) + { + _loading = true; + StateHasChanged(); + try + { + var ctx = CreateContext(parent, null, CancellationToken.None); + var items = await parent.ChildrenProvider!(ctx, CancellationToken.None); + if (_pages.Contains(frame)) + { + frame.Items = items; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + if (!Service.RaiseFailed(parent, ex)) + { + throw; + } + } + finally + { + _loading = false; + Rebuild(); + StateHasChanged(); + } + } + + private Task OpenActionsAsync(BaletteCommand target) + { + var actions = new List(target.Actions ?? []); + if (EffectiveEnableHistory && !target.ExcludeFromHistory && !target.Tags.Contains(ModeHintTag)) + { + var pinned = History.IsPinned(target.Id); + actions.Add(new BaletteCommand + { + Id = $"__pin:{target.Id}", + Title = pinned ? S.Unpin : S.Pin, + Kind = BaletteCommandKind.Action, + Icon = IconSvg("pin"), + ExcludeFromHistory = true, + Handler = async _ => + { + await History.TogglePinAsync(target.Id); + return BaletteCommandResult.Back; + }, + }); + + if (History.Get(target.Id) is { Count: > 0 }) + { + actions.Add(new BaletteCommand + { + Id = $"__forget:{target.Id}", + Title = S.RemoveFromRecents, + Icon = IconSvg("clock"), + ExcludeFromHistory = true, + Handler = async _ => + { + await History.RemoveAsync(target.Id); + return BaletteCommandResult.Back; + }, + }); + } + } + + if (actions.Count == 0) + { + return Task.CompletedTask; + } + + PushPage(new BalettePageFrame + { + Title = target.Title, + Items = actions, + IsActions = true, + Placeholder = S.Actions, + QueryBefore = _query, + }); + return Task.CompletedTask; + } + + private void ToggleSelection(BaletteCommand cmd) + { + var page = CurrentPage; + if (!page.Selected.Remove(cmd.Id)) + { + page.Selected.Add(cmd.Id); + } + } + + private async Task SubmitMultiSelectAsync() + { + var page = CurrentPage; + var selected = (page.Items ?? _all.Where(c => c.Parent == page.Command?.Id).ToList()) + .Where(c => page.Selected.Contains(c.Id)) + .ToList(); + + var owner = page.Command ?? new BaletteCommand { Id = "__multiselect", Title = page.Title, ExcludeFromHistory = true }; + var submit = page.OnSubmit ?? page.Command?.Handler; + if (submit is null) + { + await CloseCoreAsync(); + return; + } + + await RunHandlerAsync(owner, submit, selected, null); + } + + private async Task SubmitPromptAsync() + { + var page = CurrentPage; + if (page.Prompt is null) + { + return; + } + + var owner = page.Command ?? new BaletteCommand { Id = "__prompt", Title = page.Title, ExcludeFromHistory = true }; + await RunHandlerAsync(owner, page.Prompt.OnSubmit, [], _query); + } + + // ---- Execution ----------------------------------------------------------------------------------- + + private async Task ExecuteCommandAsync(BaletteCommand cmd, bool viaKeyboard) + { + if (_executing) + { + return; + } + + if (cmd.Disabled) + { + ShowToast(cmd.DisabledReason ?? S.Disabled); + return; + } + + var page = CurrentPage; + if (page.MultiSelect && !HasPage(cmd)) + { + ToggleSelection(cmd); + return; + } + + if (cmd.Tags.Contains(ModeHintTag)) + { + _query = cmd.Value ?? string.Empty; + await OnQueryUpdatedAsync(); + _pendingFocus = true; + return; + } + + if (cmd.ConfirmMessage is not null && _confirmingId != cmd.Id) + { + await EnsureOpenAsync(); + + // The second Enter runs the active row, so the command being confirmed has to be it. Started from a + // shortcut or from code, it may not be on the current page at all: it then gets a page of its own. + var row = _itemRows.FindIndex(r => r.Command.Id == cmd.Id); + if (row < 0) + { + PushPage(new BalettePageFrame { Title = cmd.Title, Items = [cmd], QueryBefore = _query }); + row = 0; + } + + _activeIndex = row; + _pendingScroll = true; + _confirmingId = cmd.Id; + ShowToast(S.ConfirmPrefix + cmd.ConfirmMessage, sticky: true); + return; + } + + Disarm(); + + if (HasPage(cmd)) + { + await EnsureOpenAsync(); + + var frame = PageFor(cmd, _query); + PushPage(frame); + if (cmd.ChildrenProvider is not null) + { + await LoadChildrenAsync(frame, cmd); + } + + return; + } + + if (cmd.Handler is null && cmd.Href is not null) + { + await RunHandlerAsync(cmd, async ctx => + { + if (cmd.OpenInNewTab && _handle is not null) + { + await _handle.InvokeVoidAsync("openInNewTab", cmd.Href); + } + else + { + ctx.Navigation.NavigateTo(cmd.Href, cmd.ForceLoad); + } + + return BaletteCommandResult.Close; + }, [], null); + return; + } + + if (cmd.Handler is null) + { + await CloseCoreAsync(); + return; + } + + await RunHandlerAsync(cmd, cmd.Handler, [], null); + } + + private async Task RunHandlerAsync(BaletteCommand cmd, Func> handler, IReadOnlyList selected, string? promptValue) + { + // Submitting a prompt or a multi-select page comes here directly: this is what turns away a second Enter. + if (_executing) + { + return; + } + + var cts = _executeCts = new CancellationTokenSource(); + var ctx = CreateContext(cmd, promptValue, cts.Token, selected); + var sw = Stopwatch.StartNew(); + _executing = true; + BaletteCommandResult result; + try + { + result = await handler(ctx); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + _executing = false; + if (!Service.RaiseFailed(cmd, ex)) + { + throw; + } + + ShowToast(ex.Message); + return; + } + finally + { + _executing = false; + } + + sw.Stop(); + if (EffectiveEnableHistory && !cmd.ExcludeFromHistory && cmd.Id.Length > 0 && !cmd.Id.StartsWith("__", StringComparison.Ordinal)) + { + _ = History.RecordAsync(cmd.Id); + } + + Service.RaiseExecuted(cmd, result, sw.Elapsed); + if (OnCommandExecuted.HasDelegate) + { + await OnCommandExecuted.InvokeAsync(new BaletteCommandExecutedEventArgs(cmd, result, sw.Elapsed)); + } + + // Closed (or disposed) while the handler ran: what it answers with must not bring the palette back. + if (cts.IsCancellationRequested) + { + return; + } + + await ApplyResultAsync(cmd, result); + } + + private async Task ApplyResultAsync(BaletteCommand cmd, BaletteCommandResult result) + { + switch (result) + { + case BaletteCommandResult.CloseResult: + await CloseCoreAsync(); + break; + case BaletteCommandResult.KeepOpenResult: + Rebuild(); + _pendingFocus = true; + break; + case BaletteCommandResult.ResetResult: + ResetState(); + _pendingFocus = true; + Rebuild(); + TriggerSourceSearch(); + break; + case BaletteCommandResult.BackResult: + PopPage(); + break; + case BaletteCommandResult.PageResult p: + // A handler run from a global shortcut or from code can answer with UI while the palette is closed. + await EnsureOpenAsync(); + PushPage(new BalettePageFrame + { + Title = p.Title, + Command = null, + Items = p.Commands, + Placeholder = p.Placeholder, + MultiSelect = p.MultiSelect, + OnSubmit = p.OnSubmit, + QueryBefore = _query, + }); + break; + case BaletteCommandResult.PromptResult pr: + await EnsureOpenAsync(); + PushPage(new BalettePageFrame + { + Title = pr.Title, + Command = cmd, + Prompt = pr, + Placeholder = pr.Placeholder ?? S.PromptPlaceholder, + QueryBefore = _query, + }); + break; + case BaletteCommandResult.ToastResult t: + if (t.StayOpen) + { + await EnsureOpenAsync(); + } + + ShowToast(t.Message); + if (!t.StayOpen && _isOpen) + { + // Long enough to read a short confirmation before the palette goes away. Rendered first: run + // from the keyboard, nothing else renders until the whole flow - the close included - is over. + StateHasChanged(); + await Task.Delay(900); + await CloseCoreAsync(); + } + + break; + } + + StateHasChanged(); + } + + private BaletteCommandContext CreateContext(BaletteCommand cmd, string? promptValue, CancellationToken ct, IReadOnlyList? selected = null) + => new(cmd, _effectiveQuery, _mode?.Prefix, promptValue, selected ?? [], Services, Navigation, Service, ct); + + private void ShowToast(string? message, bool sticky = false) + { + _toast = message; + _toastCts?.Cancel(); + if (message is null || sticky) + { + return; + } + + _toastCts = new CancellationTokenSource(); + var token = _toastCts.Token; + _ = Task.Delay(3000, token).ContinueWith(_ => InvokeAsync(() => + { + if (!token.IsCancellationRequested && _toast == message) + { + _toast = null; + StateHasChanged(); + } + }), TaskContinuationOptions.OnlyOnRanToCompletion); + } + + // ---- Async sources -------------------------------------------------------------------------------- + + private IEnumerable ActiveSources() + { + var page = CurrentPage; + if (!page.IsRoot) + { + return []; + } + + return _mode is not null ? _mode.Sources : Service.Sources; + } + + private void TriggerSourceSearch() + { + _debounceCts?.Cancel(); + var sources = ActiveSources().Where(s => _effectiveQuery.Length >= s.MinQueryLength).ToList(); + if (sources.Count == 0) + { + // Every caller has just rebuilt; only stale source rows or the loading state need another pass. + var stale = _sourceResults.Count > 0 || _loading; + _sourceResults.Clear(); + _pendingSources.Clear(); + _loading = false; + if (stale) + { + Rebuild(reuseRegistryMatches: true); + } + + return; + } + + _debounceCts = new CancellationTokenSource(); + var token = _debounceCts.Token; + var query = _effectiveQuery; + var ctx = new BaletteSourceContext(_mode?.Prefix, CurrentPage.Command?.Id, Services); + + _ = Task.Run(async () => + { + try + { + await Task.Delay(Options.DebounceMs, token); + } + catch (OperationCanceledException) + { + return; + } + + await InvokeAsync(() => + { + // A newer search may have started between the delay and this callback reaching the dispatcher. + if (token.IsCancellationRequested) + { + return; + } + + _loading = true; + _pendingSources.Clear(); + foreach (var s in sources) + { + _pendingSources.Add(s.Name); + } + + StateHasChanged(); + }); + + await Task.WhenAll(sources.Select(async source => + { + IReadOnlyList results; + try + { + results = await source.SearchAsync(query, ctx, token); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception) + { + results = []; + } + + if (token.IsCancellationRequested) + { + return; + } + + await InvokeAsync(() => + { + if (token.IsCancellationRequested) + { + return; + } + + _sourceResults[source.Name] = results.Take(source.MaxResults).ToList(); + _pendingSources.Remove(source.Name); + _loading = _pendingSources.Count > 0; + Rebuild(reuseRegistryMatches: true); + _pendingScroll = true; + StateHasChanged(); + }); + })); + }, token); + } + + // ---- Row building ------------------------------------------------------------------------------------- + + private IEnumerable AllCommands() + { + foreach (var c in Service.Commands) + { + yield return c; + } + + foreach (var c in _declarative) + { + yield return c; + } + + if (Commands is not null) + { + foreach (var c in Commands) + { + yield return c; + } + } + } + + /// Takes one snapshot of every command source so rendering never rescans the registry per row. + private void RefreshSnapshot() + { + // One command per id, in FindCommand's order: one registered through the service and declared in markup as well + // would otherwise be listed twice. + var ids = new HashSet(StringComparer.Ordinal); + _all = AllCommands().Where(c => ids.Add(c.Id)).ToList(); + _parentIds = new HashSet(StringComparer.Ordinal); + foreach (var c in _all) + { + if (c.Parent is not null) + { + _parentIds.Add(c.Parent); + } + } + } + + /// Only async source results changed: keep the registry matches computed for this query. + private void Rebuild(bool reuseRegistryMatches = false) + { + // Rows move under the highlight - a command that just ran joins Recent, source results arrive above it - so it + // stays on the command it was on, unless the selection was reset on purpose. + var activeId = _selectionReset ? null : ActiveCommand?.Id; + _selectionReset = false; + var page = CurrentPage; + ResolveMode(); + var key = (page, _mode, _effectiveQuery); + var reuse = reuseRegistryMatches && _registryMatchKey == key; + if (!reuse) + { + RefreshSnapshot(); + } + + var rows = new List(); + var items = new List(); + + if (page.Prompt is not null) + { + _rows = rows; + _itemRows = items; + _announcement = string.Empty; + return; + } + + var hasQuery = _effectiveQuery.Length > 0; + var matcher = Options.Matcher; + var query = BaletteQuery.Parse(_effectiveQuery); + + // Matching the registry dominates the cost of a keystroke; async source results arriving later reuse it. + if (!reuse) + { + _registryMatches = MatchCandidates(page, matcher, query); + _registryMatchKey = key; + } + + var matches = new List(_registryMatches); + + // Interleave sources that opt into matcher ranking. + var blockSources = new List<(string Name, List Items)>(); + foreach (var source in ActiveSources()) + { + if (!_sourceResults.TryGetValue(source.Name, out var results) || results.Count == 0) + { + continue; + } + + if (source.ApplyMatcher) + { + foreach (var c in results) + { + var scoped = c.Group is null ? c.With(b => b.Group(source.Name)) : c; + var m = matcher.Match(scoped, query, Options.Weights); + if (m is not null) + { + matches.Add(m with { FromSource = true, SourceName = source.Name }); + } + } + } + else + { + var block = results.Select((c, i) => + { + var m = hasQuery ? matcher.Match(c, query, Options.Weights) : null; + return new BaletteMatch(c, 0, m?.TitlePositions ?? [], m?.SubtitlePositions ?? [], null) { Rank = -i, FromSource = true, SourceName = source.Name }; + }).ToList(); + blockSources.Add((source.Name, block)); + } + } + + var ranked = Ranker.Rank(matches, hasQuery, id => EffectiveEnableHistory ? History.Frecency(id) : 0, Options.MaxResults); + + var itemCounter = 0; + var usedIds = new HashSet(StringComparer.Ordinal); + + void AddHeader(string group) => rows.Add(new BaletteRow(BaletteRowKind.GroupHeader, group, null, -1, $"{_id}-g-{rows.Count}") { Position = rows.Count }); + + void AddItem(BaletteMatch m, string? group) + { + var row = new BaletteRow(BaletteRowKind.Item, group, m, itemCounter, $"{_id}-i-{itemCounter}") { Position = rows.Count }; + itemCounter++; + rows.Add(row); + items.Add(row); + } + + // Pinned & recents (root, default mode, empty query). + if (!hasQuery && page.IsRoot && _mode is null && EffectiveEnableHistory && History.IsLoaded) + { + // A source result can share an id with a registered command; the better ranked one stands for both. + var byId = new Dictionary(ranked.Count, StringComparer.Ordinal); + foreach (var m in ranked) + { + byId.TryAdd(m.Command.Id, m); + } + + var pinned = History.Pinned().Where(byId.ContainsKey).Select(id => byId[id]).ToList(); + if (pinned.Count > 0) + { + AddHeader(S.Pinned); + foreach (var m in pinned) + { + AddItem(m, S.Pinned); + usedIds.Add(m.Command.Id); + } + } + + var recents = History.Recents(Options.MaxRecents * 2) + .Where(id => byId.ContainsKey(id) && !usedIds.Contains(id)) + .Take(Options.MaxRecents) + .Select(id => byId[id]) + .ToList(); + if (recents.Count > 0) + { + AddHeader(S.Recent); + foreach (var m in recents) + { + AddItem(m with { Command = m.Command }, S.Recent); + usedIds.Add(m.Command.Id); + } + } + } + + var remaining = ranked.Where(m => !usedIds.Contains(m.Command.Id)).ToList(); + var grouped = !hasQuery || EffectiveGroupWhenSearching; + + if (grouped) + { + var groups = remaining + .GroupBy(m => m.Command.Group ?? string.Empty) + .Select(g => new { Name = g.Key, Items = g.ToList(), Best = g.Max(x => x.Rank), Order = GroupOrderIndex(g.Key) }) + .OrderBy(g => g.Name.Length == 0 ? 0 : 1) // ungrouped items first + .ThenBy(g => hasQuery ? 0 : g.Order) + .ThenByDescending(g => g.Best) + .ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + foreach (var g in groups) + { + if (g.Name.Length > 0) + { + AddHeader(g.Name); + } + + foreach (var m in g.Items) + { + AddItem(m, g.Name.Length > 0 ? g.Name : null); + } + } + } + else + { + foreach (var m in remaining) + { + AddItem(m, null); + } + } + + foreach (var (name, block) in blockSources) + { + AddHeader(name); + foreach (var m in block) + { + AddItem(m, name); + } + } + + _rows = rows; + _itemRows = items; + var kept = activeId is null ? -1 : items.FindIndex(r => r.Command.Id == activeId); + if (kept >= 0) + { + _activeIndex = kept; + } + else + { + if (_activeIndex >= items.Count) + { + _activeIndex = Math.Max(0, items.Count - 1); + } + + // Skip disabled items as initial selection. + if (items.Count > 0 && items[_activeIndex].Command.Disabled) + { + var firstEnabled = items.FindIndex(r => !r.Command.Disabled); + if (firstEnabled >= 0) + { + _activeIndex = firstEnabled; + } + } + } + + _announcement = items.Count switch + { + 0 when _loading => S.Loading, + 0 => S.NoResultsAnnouncement, + 1 => S.OneResultAnnouncement, + _ => S.Format(S.ResultsAnnouncement, items.Count), + }; + } + + private List MatchCandidates(BalettePageFrame page, IBaletteMatcher matcher, BaletteQuery query) + { + var matches = new List(); + foreach (var c in Candidates(page)) + { + var m = matcher.Match(c, query, Options.Weights); + if (m is null) + { + if (c.AlwaysShow) + { + matches.Add(new BaletteMatch(c, double.NegativeInfinity, [], [], null)); + } + + continue; + } + + matches.Add(m); + } + + return matches; + } + + private int GroupOrderIndex(string group) + { + var idx = Options.GroupOrder.FindIndex(g => string.Equals(g, group, StringComparison.OrdinalIgnoreCase)); + return idx < 0 ? int.MaxValue : idx; + } + + private void ResolveMode() + { + _mode = null; + _effectiveQuery = _query.Trim(); + if (!CurrentPage.IsRoot) + { + return; + } + + foreach (var mode in Service.Modes.OrderByDescending(m => m.Prefix.Length)) + { + if (_query.StartsWith(mode.Prefix, StringComparison.Ordinal)) + { + _mode = mode; + _effectiveQuery = _query[mode.Prefix.Length..].Trim(); + return; + } + } + } + + private IEnumerable Candidates(BalettePageFrame page) + { + IEnumerable source; + if (page.Items is not null) + { + source = page.Items; + } + else if (page.Command is not null) + { + source = _all.Where(c => c.Parent == page.Command.Id); + } + else + { + source = _all.Where(c => c.Parent is null); + if (_mode is not null) + { + source = source.Where(_mode.Accepts); + } + else if (Options.ShowModeHints && _effectiveQuery.Length == 0) + { + source = source.Concat(ModeHints()); + } + } + + return source.Where(c => c.IsVisible?.Invoke() != false); + } + + private IEnumerable ModeHints() => Service.Modes.Where(m => m.ShowHint).Select(m => new BaletteCommand + { + Id = $"__mode:{m.Prefix}", + Title = m.Name, + Subtitle = m.Description, + Group = S.Modes, + Kind = BaletteCommandKind.Search, + Value = m.Prefix, + Badge = m.Prefix, + Priority = BaletteCommandPriority.Lowest, + ExcludeFromHistory = true, + Tags = [ModeHintTag], + }); + + // ---- JS plumbing --------------------------------------------------------------------------------------- + + private object BuildJsOptions(List toggles) => new + { + toggleShortcuts = toggles, + commandShortcuts = GlobalShortcutStrings(), + suppressInEditable = Options.SuppressShortcutsInEditableElements, + sequenceTimeout = Options.SequenceTimeoutMs, + vimBindings = _syncedVim, + }; + + private List ToggleShortcutStrings() + { + var toggles = new List(); + if (ToggleShortcut is not null && BaletteKeyboardShortcut.TryParse(ToggleShortcut, out var ts)) + { + toggles.Add(ts.Normalized); + } + else if (Options.ToggleShortcut is not null) + { + toggles.Add(Options.ToggleShortcut.Normalized); + } + + toggles.AddRange(Options.AdditionalToggleShortcuts.Select(s => s.Normalized)); + return toggles; + } + + /// Passes on the parameters the browser layer acts on by itself when they changed since init. + private void SyncBrowserOptions() + { + if (_handle is null) + { + return; + } + + if (EffectiveVim != _syncedVim) + { + _syncedVim = EffectiveVim; + InvokeInBackground("setVimBindings", _syncedVim); + } + + var toggles = ToggleShortcutStrings(); + var key = string.Join('\n', toggles); + if (key != _syncedToggles) + { + _syncedToggles = key; + InvokeInBackground("setToggleShortcuts", toggles); + } + } + + private List GlobalShortcutStrings() => AllCommands() + .Where(c => c.Shortcut is not null && c.GlobalShortcut) + .Select(c => c.Shortcut!.Normalized) + .Distinct(StringComparer.Ordinal) + .ToList(); + + private void SyncShortcuts() + { + if (_handle is null) + { + return; + } + + InvokeInBackground("setCommandShortcuts", GlobalShortcutStrings()); + } + + private void InvokeInBackground(string identifier, object argument) + => _ = _handle!.InvokeVoidAsync(identifier, argument).AsTask().ContinueWith(_ => { }, TaskContinuationOptions.OnlyOnFaulted); + + // ---- Effective settings ---------------------------------------------------------------------------------- + + private BaletteStrings S => Strings ?? Options.Strings; + + private bool EffectiveLoop => Loop ?? Options.Loop; + + private bool EffectiveVim => VimBindings ?? Options.VimBindings; + + private bool EffectiveVirtualize => Virtualize ?? Options.Virtualize; + + private bool EffectiveShowFooter => ShowFooter ?? Options.ShowFooter; + + private bool EffectiveShowShortcuts => ShowShortcuts ?? Options.ShowShortcuts; + + private bool EffectiveHighlight => HighlightMatches ?? Options.HighlightMatches; + + private bool EffectiveEnableHistory => EnableHistory ?? Options.EnableHistory; + + private bool EffectiveGroupWhenSearching => GroupWhenSearching ?? Options.GroupWhenSearching; + + private string EffectivePlaceholder => CurrentPage.Placeholder ?? (_mode?.Placeholder) ?? Placeholder ?? S.Placeholder; + + private string EffectiveTheme => Theme ?? Options.Theme; + + /// + /// Virtualization sizes the list from , so while it is on the rows are pinned + /// to that height inline. Otherwise the stylesheet's --bit-balette-row-height token is left alone, where an + /// app's own override of it can reach the rows. + /// + private string? RowHeightStyle => EffectiveVirtualize + ? $"--bit-balette-row-height:{Options.RowHeight.ToString(System.Globalization.CultureInfo.InvariantCulture)}px" + : null; + + /// + /// Searches slower than this defer to the next frame so typing stays responsive. Only on WebAssembly, where search and + /// painting share one thread; on the server each keystroke already round-trips, so another hop would only add latency. + /// + internal TimeSpan SlowSearchThreshold { get; set; } = OperatingSystem.IsBrowser() ? TimeSpan.FromMilliseconds(8) : TimeSpan.MaxValue; + + private IReadOnlyList Breadcrumbs => _pages.Skip(1).Select(p => p.Title).ToList(); + + private BaletteStateContext StateContext => new( + _effectiveQuery, + _mode?.Prefix, + _mode?.Name, + Breadcrumbs, + _itemRows.Count, + _activeIndex, + ActiveCommand, + _loading, + CurrentPage.MultiSelect, + CurrentPage.Selected.Count, + _toast, + _isMac); + + private string? ActiveDescendant => _itemRows.Count > 0 && _activeIndex < _itemRows.Count ? _itemRows[_activeIndex].ElementId : null; +} diff --git a/src/Balette/Bit.Balette/Components/BaletteCommands.razor b/src/Balette/Bit.Balette/Components/BaletteCommands.razor new file mode 100644 index 0000000000..de7d528974 --- /dev/null +++ b/src/Balette/Bit.Balette/Components/BaletteCommands.razor @@ -0,0 +1,28 @@ +@namespace Bit.Balette +@implements IDisposable +@inject IBaletteService Palette + +@code { + private IDisposable? _registration; + private IEnumerable? _last; + + /// + /// Commands registered while this component is rendered (page-scoped commands, like kbar's useRegisterActions). + /// + [Parameter, EditorRequired] public IEnumerable? Commands { get; set; } + + protected override void OnParametersSet() + { + if (ReferenceEquals(_last, Commands)) + { + return; + } + + _registration?.Dispose(); + _last = Commands; + _registration = Commands is null ? null : Palette.Register(Commands); + } + + /// Removes the registered commands. + public void Dispose() => _registration?.Dispose(); +} diff --git a/src/Balette/Bit.Balette/Components/BaletteCommands.razor.cs b/src/Balette/Bit.Balette/Components/BaletteCommands.razor.cs new file mode 100644 index 0000000000..da1e262157 --- /dev/null +++ b/src/Balette/Bit.Balette/Components/BaletteCommands.razor.cs @@ -0,0 +1,8 @@ +namespace Bit.Balette; + +/// +/// Registers with while it is rendered and removes them when it is +/// disposed - page-scoped commands, like kbar's useRegisterActions. Hand it a new collection instance to swap the +/// registration, for example after a toggle's state changed. +/// +public partial class BaletteCommands; diff --git a/src/Balette/Bit.Balette/Components/BaletteGroup.razor b/src/Balette/Bit.Balette/Components/BaletteGroup.razor new file mode 100644 index 0000000000..101211d46f --- /dev/null +++ b/src/Balette/Bit.Balette/Components/BaletteGroup.razor @@ -0,0 +1,19 @@ +@namespace Bit.Balette + + + @ChildContent + + +@code { + /// Group heading applied to all children. + [Parameter, EditorRequired] public string Heading { get; set; } = string.Empty; + + /// Priority applied to children that don't set their own. + [Parameter] public int? Priority { get; set; } + + /// Tags applied to all children (for mode scoping). + [Parameter] public string[]? Tags { get; set; } + + /// The s of the group. + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/src/Balette/Bit.Balette/Components/BaletteGroup.razor.cs b/src/Balette/Bit.Balette/Components/BaletteGroup.razor.cs new file mode 100644 index 0000000000..a46773e9f4 --- /dev/null +++ b/src/Balette/Bit.Balette/Components/BaletteGroup.razor.cs @@ -0,0 +1,7 @@ +namespace Bit.Balette; + +/// +/// Groups the s declared inside it under one heading, and hands them its +/// and when they do not set their own. Place it inside a . +/// +public partial class BaletteGroup; diff --git a/src/Balette/Bit.Balette/Components/BaletteHighlightedText.razor b/src/Balette/Bit.Balette/Components/BaletteHighlightedText.razor new file mode 100644 index 0000000000..6ac068366c --- /dev/null +++ b/src/Balette/Bit.Balette/Components/BaletteHighlightedText.razor @@ -0,0 +1,38 @@ +@namespace Bit.Balette + +@if (Positions is not { Length: > 0 } || string.IsNullOrEmpty(Text)) +{ + @Text +} +else +{ + var set = new HashSet(Positions); + var i = 0; + while (i < Text.Length) + { + var start = i; + var hit = set.Contains(i); + while (i < Text.Length && set.Contains(i) == hit) + { + i++; + } + + var segment = Text.Substring(start, i - start); + if (hit) + { + @segment + } + else + { + @segment + } + } +} + +@code { + /// Text to render. + [Parameter, EditorRequired] public string? Text { get; set; } + + /// Character indexes to highlight. + [Parameter] public int[]? Positions { get; set; } +} diff --git a/src/Balette/Bit.Balette/Components/BaletteHighlightedText.razor.cs b/src/Balette/Bit.Balette/Components/BaletteHighlightedText.razor.cs new file mode 100644 index 0000000000..bc7e36f8b0 --- /dev/null +++ b/src/Balette/Bit.Balette/Components/BaletteHighlightedText.razor.cs @@ -0,0 +1,7 @@ +namespace Bit.Balette; + +/// +/// Renders a text with the characters at wrapped in <mark>: the palette's own +/// highlighting, for templates that render rows themselves. +/// +public partial class BaletteHighlightedText; diff --git a/src/Balette/Bit.Balette/Components/BaletteItem.razor b/src/Balette/Bit.Balette/Components/BaletteItem.razor new file mode 100644 index 0000000000..adf42e7566 --- /dev/null +++ b/src/Balette/Bit.Balette/Components/BaletteItem.razor @@ -0,0 +1,179 @@ +@namespace Bit.Balette +@implements IDisposable + + + @ChildContent + + +@code { + private string? _registeredId; + private string? _signature; + + [CascadingParameter] private Balette? Palette { get; set; } + + [CascadingParameter] private BaletteGroup? Group { get; set; } + + [CascadingParameter] private BaletteItem? ParentItem { get; set; } + + /// Stable id. Defaults to a slug of . + [Parameter] public string? Id { get; set; } + + /// The label. Searched with the highest weight and highlighted. + [Parameter, EditorRequired] public string Title { get; set; } = string.Empty; + + /// A secondary label next to the title. Searched and highlighted. + [Parameter] public string? Subtitle { get; set; } + + /// Longer text for the detail pane. Searched with a low weight. + [Parameter] public string? Description { get; set; } + + /// Comma or space separated keywords. + [Parameter] public string? Keywords { get; set; } + + /// Shortcut, e.g. Mod+Shift+D or G I. + [Parameter] public string? Shortcut { get; set; } + + /// Whether also fires while the palette is closed. Default true. + [Parameter] public bool GlobalShortcut { get; set; } = true; + + /// An icon rendered before the title. + [Parameter] public RenderFragment? Icon { get; set; } + + /// A CSS class for an icon element, e.g. bi bi-house. + [Parameter] public string? IconCss { get; set; } + + /// Navigation target (used when is not set). + [Parameter] public string? Href { get; set; } + + /// Open in a new tab. + [Parameter] public bool NewTab { get; set; } + + /// Invoked when the item is selected. The palette closes afterwards unless is set. + [Parameter] public EventCallback OnExecute { get; set; } + + /// Keep the palette open after . + [Parameter] public bool KeepOpen { get; set; } + + /// A ranking nudge; see . Defaults to the group's priority. + [Parameter] public int? Priority { get; set; } + + /// Listed but not runnable. + [Parameter] public bool Disabled { get; set; } + + /// Why the item is disabled; shown instead of the subtitle and when it is selected. + [Parameter] public string? DisabledReason { get; set; } + + /// Styled as dangerous. With , Enter has to be pressed twice. + [Parameter] public bool Destructive { get; set; } + + /// Asks for a second Enter before running, showing this message. + [Parameter] public string? ConfirmMessage { get; set; } + + /// A small pill at the end of the row, e.g. "New" or "Beta". + [Parameter] public string? Badge { get; set; } + + /// The current value of a setting, e.g. "Dark". + [Parameter] public string? Value { get; set; } + + /// The state of a toggle; renders a switch. + [Parameter] public bool? Checked { get; set; } + + /// The semantic kind; picks the default icon. Inferred when not set. + [Parameter] public BaletteCommandKind? Kind { get; set; } + + /// Free-form labels that modes can scope to; combined with the group's. + [Parameter] public string[]? Tags { get; set; } + + /// Listed whatever the query, e.g. for a "couldn't find it?" row. + [Parameter] public bool AlwaysShow { get; set; } + + /// Placeholder for the nested page when this item has child items. + [Parameter] public string? ChildrenPlaceholder { get; set; } + + /// Allow checking several children before applying with Ctrl+Enter (calls with the selection). + [Parameter] public bool MultiSelect { get; set; } + + /// Nested s turn this item into a page. + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// The effective command id (available after parameters are set). + public string CommandId => _registeredId ?? Id ?? BaletteCommandIdGenerator.FromTitle(Title, Group?.Heading, ParentItem?.CommandId); + + protected override void OnParametersSet() + { + if (Palette is null) + { + throw new InvalidOperationException($"{nameof(BaletteItem)} must be placed inside a {nameof(Balette)}."); + } + + var signature = string.Join('\u001f', Id, Title, Subtitle, Description, Keywords, Shortcut, GlobalShortcut, IconCss, Href, NewTab, + OnExecute.HasDelegate, KeepOpen, Priority, Disabled, DisabledReason, Destructive, ConfirmMessage, Badge, Value, Checked, Kind, + Tags is null ? null : string.Join(',', Tags), AlwaysShow, ChildrenPlaceholder, MultiSelect, ChildContent is not null, Icon is not null, + Group?.Heading, Group?.Priority, ParentItem?.CommandId); + if (signature == _signature) + { + return; + } + + _signature = signature; + + var b = new BaletteCommandBuilder(Title) + .Subtitle(Subtitle) + .Description(Description) + .Group(Group?.Heading) + .Parent(ParentItem?.CommandId) + .Priority(Priority ?? Group?.Priority ?? BaletteCommandPriority.Normal) + .Disabled(Disabled, DisabledReason) + .Badge(Badge) + .AlwaysShow(AlwaysShow) + .ChildrenPlaceholder(ChildrenPlaceholder) + .MultiSelect(MultiSelect); + + if (Id is not null) b.Id(Id); + if (Value is not null) b.Value(Value); + if (!string.IsNullOrWhiteSpace(Keywords)) b.Keywords(Keywords.Split([',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + if (Shortcut is not null) b.Shortcut(Shortcut, GlobalShortcut); + if (Icon is not null) b.Icon(Icon); + if (IconCss is not null) b.Icon(IconCss); + if (Checked is not null) b.Checked(Checked); + if (Destructive) b.Destructive(ConfirmMessage); else if (ConfirmMessage is not null) b.Confirm(ConfirmMessage); + var tags = (Tags ?? []).Concat(Group?.Tags ?? []).ToArray(); + if (tags.Length > 0) b.Tags(tags); + + if (OnExecute.HasDelegate) + { + var keepOpen = KeepOpen; + var callback = OnExecute; + b.Handler(async ctx => + { + await callback.InvokeAsync(ctx); + return keepOpen ? BaletteCommandResult.KeepOpen : BaletteCommandResult.Close; + }); + } + else if (Href is not null) + { + b.Href(Href, newTab: NewTab); + } + + if (Kind is { } kind) b.Kind(kind); + else if (ChildContent is not null) b.Kind(BaletteCommandKind.Page); + + var command = b.Build(); + if (_registeredId is not null && _registeredId != command.Id) + { + Palette.RemoveDeclarative(_registeredId); + } + + _registeredId = command.Id; + Palette.AddDeclarative(command); + } + + /// Removes the item from the palette. + public void Dispose() + { + if (_registeredId is not null) + { + Palette?.RemoveDeclarative(_registeredId); + } + } +} diff --git a/src/Balette/Bit.Balette/Components/BaletteItem.razor.cs b/src/Balette/Bit.Balette/Components/BaletteItem.razor.cs new file mode 100644 index 0000000000..f852d47ba9 --- /dev/null +++ b/src/Balette/Bit.Balette/Components/BaletteItem.razor.cs @@ -0,0 +1,8 @@ +namespace Bit.Balette; + +/// +/// A command declared in markup inside a (directly or in a ), with a +/// parameter for every member of . Nested items turn it into a page. It belongs to that +/// palette instance rather than to the service's registry, and re-registers only when one of its parameters changed. +/// +public partial class BaletteItem; diff --git a/src/Balette/Bit.Balette/Components/BaletteRow.cs b/src/Balette/Bit.Balette/Components/BaletteRow.cs new file mode 100644 index 0000000000..7230cc81c6 --- /dev/null +++ b/src/Balette/Bit.Balette/Components/BaletteRow.cs @@ -0,0 +1,90 @@ +namespace Bit.Balette; + +/// Kind of a rendered row. +public enum BaletteRowKind +{ + GroupHeader, + Item, + Separator, +} + +/// One flattened row of the result list (header or item). Exposed to templates. +public sealed class BaletteRow +{ + internal BaletteRow(BaletteRowKind kind, string? group, BaletteMatch? match, int itemIndex, string elementId) + { + Kind = kind; + Group = group; + Match = match; + ItemIndex = itemIndex; + ElementId = elementId; + } + + /// Whether the row is a group heading or an item. + public BaletteRowKind Kind { get; } + + /// Group heading text (for headers, and for items the group they belong to). + public string? Group { get; } + + /// Match details for item rows. + public BaletteMatch? Match { get; } + + /// Zero-based index among item rows; -1 for headers. + public int ItemIndex { get; } + + /// DOM id used for aria-activedescendant. + public string ElementId { get; } + + /// Zero-based index among all rows, headings included. + internal int Position { get; init; } + + /// The command of an item row. Throws for a heading. + public BaletteCommand Command => Match?.Command ?? throw new InvalidOperationException("Row has no command."); +} + +/// Context handed to ItemTemplate. +public sealed record BaletteItemContext(BaletteRow Row, bool IsActive, bool IsChecked, bool IsPinned, bool IsConfirming, bool IsMac); + +/// Snapshot of palette state handed to header/footer templates. +public sealed record BaletteStateContext( + string Query, + string? ModePrefix, + string? ModeName, + IReadOnlyList Breadcrumbs, + int ResultCount, + int ActiveIndex, + BaletteCommand? ActiveCommand, + bool IsLoading, + bool IsMultiSelect, + int SelectedCount, + string? Toast, + bool IsMac); + +/// A page on the navigation stack. +internal sealed class BalettePageFrame +{ + public required string Title { get; init; } + + /// The command whose children are listed (null for the root page and ad-hoc pages). + public BaletteCommand? Command { get; init; } + + /// Explicit items for ad-hoc pages (results of , action panels, lazily loaded children). + public IReadOnlyList? Items { get; set; } + + public bool MultiSelect { get; init; } + + public Func>? OnSubmit { get; init; } + + public string? Placeholder { get; init; } + + public BaletteCommandResult.PromptResult? Prompt { get; init; } + + public bool IsActions { get; init; } + + /// Query to restore when this page is popped. + public string QueryBefore { get; init; } = string.Empty; + + public HashSet Selected { get; } = new(StringComparer.Ordinal); + + public bool IsRoot => Command is null && Items is null && Prompt is null; +} diff --git a/src/Balette/Bit.Balette/Components/BaletteShortcutHint.razor b/src/Balette/Bit.Balette/Components/BaletteShortcutHint.razor new file mode 100644 index 0000000000..0f7b001a3d --- /dev/null +++ b/src/Balette/Bit.Balette/Components/BaletteShortcutHint.razor @@ -0,0 +1,30 @@ +@namespace Bit.Balette + +@if (Shortcut is not null) +{ + var chords = Shortcut.DisplayTokens(IsMac); + + @for (var c = 0; c < chords.Count; c++) + { + if (c > 0) + { + + } + foreach (var token in chords[c]) + { + @token + } + } + +} + +@code { + /// Shortcut to render. Accepts a (strings convert implicitly). + [Parameter, EditorRequired] public BaletteKeyboardShortcut? Shortcut { get; set; } + + /// Render macOS symbols (⌘ ⇧ ⌥) instead of words. + [Parameter] public bool IsMac { get; set; } + + /// Extra CSS class on the hint. + [Parameter] public string? Class { get; set; } +} diff --git a/src/Balette/Bit.Balette/Components/BaletteShortcutHint.razor.cs b/src/Balette/Bit.Balette/Components/BaletteShortcutHint.razor.cs new file mode 100644 index 0000000000..52b158558d --- /dev/null +++ b/src/Balette/Bit.Balette/Components/BaletteShortcutHint.razor.cs @@ -0,0 +1,7 @@ +namespace Bit.Balette; + +/// +/// Renders a as key caps - ⌘ K on macOS, Ctrl K elsewhere, +/// G then S for a sequence - with an accessible name in words. +/// +public partial class BaletteShortcutHint; diff --git a/src/Balette/Bit.Balette/Components/BaletteTrigger.razor b/src/Balette/Bit.Balette/Components/BaletteTrigger.razor new file mode 100644 index 0000000000..33989bcfb1 --- /dev/null +++ b/src/Balette/Bit.Balette/Components/BaletteTrigger.razor @@ -0,0 +1,80 @@ +@namespace Bit.Balette +@implements IDisposable +@inject BaletteService Palette +@inject BaletteOptions Options + + + +@code { + /// Label text. Defaults to "Search". + [Parameter] public string? Label { get; set; } + + /// Open the palette with this initial query (e.g. a mode prefix). + [Parameter] public string? Query { get; set; } + + /// Open directly inside this command's page. + [Parameter] public string? PageCommandId { get; set; } + + /// Show the toggle shortcut hint. + [Parameter] public bool ShowShortcut { get; set; } = true; + + /// Force macOS symbols on or off. By default the platform detected by the palette is used. + [Parameter] public bool? IsMac { get; set; } + + /// Extra CSS class on the button. + [Parameter] public string? Class { get; set; } + + /// Replaces the button's content (icon, label and shortcut hint). + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// Any other attribute, rendered on the button. + [Parameter(CaptureUnmatchedValues = true)] public IReadOnlyDictionary? AdditionalAttributes { get; set; } + + private bool EffectiveIsMac => IsMac ?? Palette.IsMac; + + private BaletteKeyboardShortcut? Shortcut => Options.ToggleShortcut; + + /// Value for aria-keyshortcuts, which uses DOM key names (Control, Meta, Shift, Alt). + private string? ShortcutText => Shortcut is null ? null : string.Join(" ", Shortcut.Chords.Select(AriaChord)); + + private string AriaChord(BaletteKeyChord c) + { + var parts = new List(5); + if (c.Mod) parts.Add(EffectiveIsMac ? "Meta" : "Control"); + if (c.Ctrl) parts.Add("Control"); + if (c.Alt) parts.Add("Alt"); + if (c.Shift) parts.Add("Shift"); + if (c.Meta) parts.Add("Meta"); + parts.Add(c.Key.Length == 1 ? c.Key.ToUpperInvariant() : char.ToUpperInvariant(c.Key[0]) + c.Key[1..]); + return string.Join("+", parts); + } + + protected override void OnInitialized() => Palette.PlatformDetected += OnPlatformDetected; + + private void OnPlatformDetected() => _ = InvokeAsync(StateHasChanged); + + private Task OnClick() => Palette.OpenAsync(Query, PageCommandId); + + /// Stops listening for the platform detection. + public void Dispose() => Palette.PlatformDetected -= OnPlatformDetected; +} diff --git a/src/Balette/Bit.Balette/Components/BaletteTrigger.razor.cs b/src/Balette/Bit.Balette/Components/BaletteTrigger.razor.cs new file mode 100644 index 0000000000..ac9dbe6c03 --- /dev/null +++ b/src/Balette/Bit.Balette/Components/BaletteTrigger.razor.cs @@ -0,0 +1,7 @@ +namespace Bit.Balette; + +/// +/// A button that opens the palette - optionally with a query (a mode prefix) or on a command's page - and shows the +/// toggle shortcut in the platform's own symbols once the palette has detected it. +/// +public partial class BaletteTrigger; diff --git a/src/Balette/Bit.Balette/History/BaletteBrowserStorageHistoryStore.cs b/src/Balette/Bit.Balette/History/BaletteBrowserStorageHistoryStore.cs new file mode 100644 index 0000000000..9402b514d7 --- /dev/null +++ b/src/Balette/Bit.Balette/History/BaletteBrowserStorageHistoryStore.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.JSInterop; + +namespace Bit.Balette; + +/// Persists usage in the browser's localStorage so recents survive reloads. +public sealed class BaletteBrowserStorageHistoryStore : IBaletteHistoryStore +{ + private readonly IJSRuntime _js; + private readonly string _key; + + public BaletteBrowserStorageHistoryStore(IJSRuntime js, BaletteOptions options) + { + _js = js; + _key = options.HistoryStorageKey; + } + + public async ValueTask> LoadAsync(CancellationToken cancellationToken = default) + { + try + { + var json = await _js.InvokeAsync("localStorage.getItem", cancellationToken, _key).ConfigureAwait(false); + if (string.IsNullOrEmpty(json)) + { + return new Dictionary(StringComparer.Ordinal); + } + + var data = JsonSerializer.Deserialize(json, BaletteHistoryJsonContext.Default.DictionaryStringBaletteCommandUsage); + return data ?? new Dictionary(StringComparer.Ordinal); + } + catch (Exception ex) when (ex is JSException or JsonException or InvalidOperationException or JSDisconnectedException) + { + // Prerendering, disabled storage, or corrupt data: start empty. + return new Dictionary(StringComparer.Ordinal); + } + } + + public async ValueTask SaveAsync(IReadOnlyDictionary usage, CancellationToken cancellationToken = default) + { + try + { + var json = JsonSerializer.Serialize(new Dictionary(usage), BaletteHistoryJsonContext.Default.DictionaryStringBaletteCommandUsage); + await _js.InvokeVoidAsync("localStorage.setItem", cancellationToken, _key, json).ConfigureAwait(false); + } + catch (Exception ex) when (ex is JSException or InvalidOperationException or JSDisconnectedException) + { + // Ignore: history is best-effort. + } + } +} + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(Dictionary))] +internal sealed partial class BaletteHistoryJsonContext : JsonSerializerContext +{ +} diff --git a/src/Balette/Bit.Balette/History/BaletteHistory.cs b/src/Balette/Bit.Balette/History/BaletteHistory.cs new file mode 100644 index 0000000000..2760cb031b --- /dev/null +++ b/src/Balette/Bit.Balette/History/BaletteHistory.cs @@ -0,0 +1,154 @@ +namespace Bit.Balette; + +/// +/// Tracks recents, pins and frecency (frequency + recency) per command, backed by an . +/// +public sealed class BaletteHistory +{ + private readonly IBaletteHistoryStore _store; + private readonly TimeProvider _time; + private readonly BaletteOptions _options; + private Dictionary? _usage; + private Task? _loading; + + public BaletteHistory(IBaletteHistoryStore store, BaletteOptions options, TimeProvider? time = null) + { + _store = store; + _options = options; + _time = time ?? TimeProvider.System; + } + + /// Raised after usage changed (record, pin, clear). + public event Action? Changed; + + public bool IsLoaded => _usage is not null; + + /// Loads usage from the store once. + public Task EnsureLoadedAsync(CancellationToken cancellationToken = default) + { + if (_usage is not null) + { + return Task.CompletedTask; + } + + return _loading ??= LoadCoreAsync(cancellationToken); + } + + private async Task LoadCoreAsync(CancellationToken cancellationToken) + { + var data = await _store.LoadAsync(cancellationToken).ConfigureAwait(false); + _usage = new Dictionary(data, StringComparer.Ordinal); + Changed?.Invoke(); + } + + public BaletteCommandUsage? Get(string commandId) => _usage is not null && _usage.TryGetValue(commandId, out var u) ? u : null; + + public bool IsPinned(string commandId) => Get(commandId)?.Pinned ?? false; + + /// Frecency score: higher for commands used often and recently. 0 when never used. + public double Frecency(string commandId) + { + var u = Get(commandId); + return u is null ? 0 : ComputeFrecency(u, _time.GetUtcNow()); + } + + /// Weighted frecency similar to Firefox's algorithm: capped count × recency weight. + public static double ComputeFrecency(BaletteCommandUsage usage, DateTimeOffset now) + { + var age = now - usage.LastUsed; + var weight = age.TotalHours switch + { + < 4 => 100, + < 24 => 70, + < 72 => 50, + < 24 * 7 => 30, + < 24 * 30 => 10, + _ => 5, + }; + + var cappedCount = Math.Min(usage.Count, 20); + return cappedCount * weight / 10.0; + } + + /// Most recently used command ids, newest first, excluding pinned (which are listed separately). + public IReadOnlyList Recents(int max) + { + if (_usage is null) + { + return []; + } + + return _usage + .Where(kv => !kv.Value.Pinned) + .OrderByDescending(kv => kv.Value.LastUsed) + .Take(max) + .Select(kv => kv.Key) + .ToList(); + } + + public IReadOnlyList Pinned() + { + if (_usage is null) + { + return []; + } + + return _usage.Where(kv => kv.Value.Pinned).OrderBy(kv => kv.Key, StringComparer.Ordinal).Select(kv => kv.Key).ToList(); + } + + public async Task RecordAsync(string commandId, CancellationToken cancellationToken = default) + { + await EnsureLoadedAsync(cancellationToken).ConfigureAwait(false); + var now = _time.GetUtcNow(); + _usage![commandId] = _usage.TryGetValue(commandId, out var u) + ? u with { Count = u.Count + 1, LastUsed = now } + : new BaletteCommandUsage(1, now, false); + + Prune(); + Changed?.Invoke(); + await _store.SaveAsync(_usage, cancellationToken).ConfigureAwait(false); + } + + public async Task TogglePinAsync(string commandId, CancellationToken cancellationToken = default) + { + await EnsureLoadedAsync(cancellationToken).ConfigureAwait(false); + var now = _time.GetUtcNow(); + _usage![commandId] = _usage.TryGetValue(commandId, out var u) + ? u with { Pinned = !u.Pinned } + : new BaletteCommandUsage(0, now, true); + Changed?.Invoke(); + await _store.SaveAsync(_usage, cancellationToken).ConfigureAwait(false); + } + + public async Task RemoveAsync(string commandId, CancellationToken cancellationToken = default) + { + await EnsureLoadedAsync(cancellationToken).ConfigureAwait(false); + if (_usage!.Remove(commandId)) + { + Changed?.Invoke(); + await _store.SaveAsync(_usage, cancellationToken).ConfigureAwait(false); + } + } + + public async Task ClearAsync(CancellationToken cancellationToken = default) + { + await EnsureLoadedAsync(cancellationToken).ConfigureAwait(false); + var pinned = _usage!.Where(kv => kv.Value.Pinned).ToDictionary(kv => kv.Key, kv => kv.Value with { Count = 0 }, StringComparer.Ordinal); + _usage = pinned; + Changed?.Invoke(); + await _store.SaveAsync(_usage, cancellationToken).ConfigureAwait(false); + } + + private void Prune() + { + if (_usage is null || _usage.Count <= _options.MaxHistoryEntries) + { + return; + } + + foreach (var key in _usage.Where(kv => !kv.Value.Pinned).OrderBy(kv => kv.Value.LastUsed).Take(_usage.Count - _options.MaxHistoryEntries).Select(kv => kv.Key).ToList()) + { + _usage.Remove(key); + } + } +} diff --git a/src/Balette/Bit.Balette/History/IBaletteHistoryStore.cs b/src/Balette/Bit.Balette/History/IBaletteHistoryStore.cs new file mode 100644 index 0000000000..06d3e9b27f --- /dev/null +++ b/src/Balette/Bit.Balette/History/IBaletteHistoryStore.cs @@ -0,0 +1,27 @@ +namespace Bit.Balette; + +/// Usage statistics for one command, used for recents, pinning and frecency ranking. +public sealed record BaletteCommandUsage(int Count, DateTimeOffset LastUsed, bool Pinned); + +/// Persists command usage. Implement to store in a database or user profile. +public interface IBaletteHistoryStore +{ + ValueTask> LoadAsync(CancellationToken cancellationToken = default); + + ValueTask SaveAsync(IReadOnlyDictionary usage, CancellationToken cancellationToken = default); +} + +/// Keeps usage in memory for the lifetime of the scope (circuit / tab). +public sealed class BaletteInMemoryHistoryStore : IBaletteHistoryStore +{ + private Dictionary _data = new(StringComparer.Ordinal); + + public ValueTask> LoadAsync(CancellationToken cancellationToken = default) + => new(new Dictionary(_data, StringComparer.Ordinal)); + + public ValueTask SaveAsync(IReadOnlyDictionary usage, CancellationToken cancellationToken = default) + { + _data = new Dictionary(usage, StringComparer.Ordinal); + return ValueTask.CompletedTask; + } +} diff --git a/src/Balette/Bit.Balette/Localization/BaletteStrings.cs b/src/Balette/Bit.Balette/Localization/BaletteStrings.cs new file mode 100644 index 0000000000..c59f7a7aec --- /dev/null +++ b/src/Balette/Bit.Balette/Localization/BaletteStrings.cs @@ -0,0 +1,49 @@ +namespace Bit.Balette; + +/// All user-visible strings. Subclass and override, or set properties, to localize. +public class BaletteStrings +{ + public virtual string Placeholder { get; set; } = "Type a command or search…"; + public virtual string PromptPlaceholder { get; set; } = "Type a value and press Enter…"; + public virtual string NoResults { get; set; } = "No results for “{0}”"; + public virtual string NoResultsHint { get; set; } = "Try a different search, or check for typos."; + public virtual string Empty { get; set; } = "Nothing to show yet."; + public virtual string Loading { get; set; } = "Searching…"; + public virtual string Recent { get; set; } = "Recent"; + public virtual string Pinned { get; set; } = "Pinned"; + public virtual string Suggestions { get; set; } = "Suggestions"; + public virtual string Modes { get; set; } = "Modes"; + public virtual string Actions { get; set; } = "Actions"; + public virtual string Results { get; set; } = "Results"; + public virtual string Other { get; set; } = "Other"; + public virtual string Navigate { get; set; } = "to navigate"; + public virtual string Select { get; set; } = "to select"; + public virtual string Close { get; set; } = "to close"; + public virtual string Back { get; set; } = "to go back"; + public virtual string OpenActions { get; set; } = "for actions"; + public virtual string Toggle { get; set; } = "to toggle"; + public virtual string Apply { get; set; } = "to apply"; + public virtual string Pin { get; set; } = "Pin to top"; + public virtual string Unpin { get; set; } = "Unpin"; + public virtual string RemoveFromRecents { get; set; } = "Remove from recents"; + public virtual string ClearRecents { get; set; } = "Clear recent commands"; + public virtual string ConfirmPrefix { get; set; } = "Confirm: "; + public virtual string ConfirmHint { get; set; } = "Press Enter again to confirm, Esc to cancel"; + public virtual string SelectedCount { get; set; } = "{0} selected"; + public virtual string ResultsAnnouncement { get; set; } = "{0} results available"; + public virtual string OneResultAnnouncement { get; set; } = "1 result available"; + public virtual string NoResultsAnnouncement { get; set; } = "No results"; + public virtual string Disabled { get; set; } = "Unavailable"; + public virtual string ModeHintTitle { get; set; } = "{0} — {1}"; + public virtual string DialogLabel { get; set; } = "Command palette"; + public virtual string InputLabel { get; set; } = "Search commands"; + public virtual string CloseButton { get; set; } = "Close"; + public virtual string BackButton { get; set; } = "Back"; + public virtual string OpenPalette { get; set; } = "Open command palette"; + public virtual string Search { get; set; } = "Search"; + public virtual string Home { get; set; } = "Home"; + public virtual string MoreResults { get; set; } = "Show {0} more…"; + + /// Format a template string with . + public string Format(string template, params object[] args) => string.Format(System.Globalization.CultureInfo.CurrentCulture, template, args); +} diff --git a/src/Balette/Bit.Balette/Models/BaletteCommand.cs b/src/Balette/Bit.Balette/Models/BaletteCommand.cs new file mode 100644 index 0000000000..7eebbedfd3 --- /dev/null +++ b/src/Balette/Bit.Balette/Models/BaletteCommand.cs @@ -0,0 +1,192 @@ +using Microsoft.AspNetCore.Components; + +namespace Bit.Balette; + +/// +/// Describes a single entry in the command palette: an action, a navigation target, +/// a toggle, or a container of nested commands. Commands are immutable; use to derive copies. +/// +public class BaletteCommand +{ + private string? _explicitId; + private string? _cachedId; + + /// + /// Stable unique identifier. Defaults to a slug derived from , and . + /// Setting null keeps the derived id. + /// + public string Id + { + get => _explicitId ?? (_cachedId ??= BaletteCommandIdGenerator.FromTitle(Title, Group, Parent)); + init => _explicitId = string.IsNullOrEmpty(value) ? null : value; + } + + /// True when was set explicitly rather than derived. + public bool HasExplicitId => _explicitId is not null; + + /// Primary label. Searched with the highest weight. + public required string Title { get; init; } + + /// Secondary label rendered next to the title (e.g. a path or category). + public string? Subtitle { get; init; } + + /// Longer explanation shown in the detail pane. + public string? Description { get; init; } + + /// Alternative search terms (aliases, synonyms, abbreviations). + public IReadOnlyList Keywords { get; init; } = []; + + /// Group / section heading under which the command is listed. + public string? Group { get; init; } + + /// Icon rendered before the title. Either this or . + public RenderFragment? Icon { get; init; } + + /// CSS class used for an icon element (e.g. bi bi-house). + public string? IconCss { get; init; } + + /// Keyboard shortcut. When is true, it also fires while the palette is closed. + public BaletteKeyboardShortcut? Shortcut { get; init; } + + /// Whether is registered globally on the document. + public bool GlobalShortcut { get; init; } = true; + + /// Ranking hint. Higher values sort first when scores tie. See . + public int Priority { get; init; } = BaletteCommandPriority.Normal; + + /// Id of the parent command. Commands with a parent are only visible inside that parent's page. + public string? Parent { get; init; } + + /// Static nested commands. Selecting this command opens a page listing them. + public IReadOnlyList? Children { get; init; } + + /// Lazily resolved nested commands. Selecting this command opens a page listing them. + public Func>>? ChildrenProvider { get; init; } + + /// Optional placeholder for the search box when this command's page is open. + public string? ChildrenPlaceholder { get; init; } + + /// Allow checking several children before confirming with Ctrl+Enter. + public bool MultiSelectChildren { get; init; } + + /// Secondary actions reachable via Tab / → (Raycast-style action panel). + public IReadOnlyList? Actions { get; init; } + + /// Handler executed when the command is selected. + public Func>? Handler { get; init; } + + /// Navigation target. When set and is null the palette navigates here. + public string? Href { get; init; } + + /// Force navigation to reload the page (bypasses client routing). + public bool ForceLoad { get; init; } + + /// Open in a new tab. + public bool OpenInNewTab { get; init; } + + /// Semantic kind used for icons/badges and screen-reader hints. + public BaletteCommandKind Kind { get; init; } = BaletteCommandKind.Action; + + /// Disabled commands are shown but cannot be executed. + public bool Disabled { get; init; } + + /// Explanation rendered when the command is disabled. + public string? DisabledReason { get; init; } + + /// Dynamic availability check evaluated when the list is built. + public Func? IsVisible { get; init; } + + /// Marks the command as destructive (styled red). + public bool Destructive { get; init; } + + /// When set, the user must confirm (press Enter twice) before the handler runs. + public string? ConfirmMessage { get; init; } + + /// Small badge/pill rendered at the end of the row (e.g. "New", "Beta", "3"). + public string? Badge { get; init; } + + /// Current value for toggle/setting commands (e.g. "On", "Dark"). + public string? Value { get; init; } + + /// Checked state for commands. + public bool? Checked { get; init; } + + /// Always show this command regardless of the query (cmdk forceMount). + public bool AlwaysShow { get; init; } + + /// Exclude from the recents / frecency tracking. + public bool ExcludeFromHistory { get; init; } + + /// Arbitrary data for templates and handlers. + public object? Data { get; init; } + + /// Free-form tags, used by scoping. + public IReadOnlyList Tags { get; init; } = []; + + /// True when this command opens a nested page. + public bool HasChildren => Children is { Count: > 0 } || ChildrenProvider is not null; + + /// Returns a copy with changed values. + public BaletteCommand With(Action configure) + { + var b = new BaletteCommandBuilder(this); + configure(b); + return b.Build(); + } + + public override string ToString() => $"{Id}: {Title}"; +} + +/// Well-known priority levels (any int is allowed). +public static class BaletteCommandPriority +{ + /// Listed last; e.g. a feedback row. + public const int Lowest = -100; + /// Below the default. + public const int Low = -50; + /// The default. + public const int Normal = 0; + /// Above the default. + public const int High = 50; + /// Listed first. + public const int Highest = 100; +} + +/// Semantic kinds of commands. +public enum BaletteCommandKind +{ + Action, + Navigation, + Toggle, + Setting, + Search, + Page, + Help, + Recent, +} + +internal static class BaletteCommandIdGenerator +{ + public static string FromTitle(string title, string? group, string? parent) + { + var chars = title.ToLowerInvariant().Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray(); + var slug = new string(chars).Trim('-'); + while (slug.Contains("--", StringComparison.Ordinal)) + { + slug = slug.Replace("--", "-", StringComparison.Ordinal); + } + + var prefixParts = new List(2); + if (!string.IsNullOrWhiteSpace(parent)) + { + prefixParts.Add(parent.ToLowerInvariant()); + } + + if (!string.IsNullOrWhiteSpace(group)) + { + prefixParts.Add(group.ToLowerInvariant().Replace(' ', '-')); + } + + return prefixParts.Count == 0 ? slug : string.Join('.', prefixParts) + "." + slug; + } +} diff --git a/src/Balette/Bit.Balette/Models/BaletteCommandBuilder.cs b/src/Balette/Bit.Balette/Models/BaletteCommandBuilder.cs new file mode 100644 index 0000000000..220d9cc95c --- /dev/null +++ b/src/Balette/Bit.Balette/Models/BaletteCommandBuilder.cs @@ -0,0 +1,218 @@ +using Microsoft.AspNetCore.Components; + +namespace Bit.Balette; + +/// Fluent builder for . +public sealed class BaletteCommandBuilder +{ + private string? _id; + private string _title; + private string? _subtitle; + private string? _description; + private readonly List _keywords; + private string? _group; + private RenderFragment? _icon; + private string? _iconCss; + private BaletteKeyboardShortcut? _shortcut; + private bool _globalShortcut = true; + private int _priority; + private string? _parent; + private List? _children; + private Func>>? _childrenProvider; + private string? _childrenPlaceholder; + private bool _multiSelect; + private List? _actions; + private Func>? _handler; + private string? _href; + private bool _forceLoad; + private bool _newTab; + private BaletteCommandKind _kind = BaletteCommandKind.Action; + private bool _disabled; + private string? _disabledReason; + private Func? _isVisible; + private bool _destructive; + private string? _confirm; + private string? _badge; + private string? _value; + private bool? _checked; + private bool _alwaysShow; + private bool _excludeFromHistory; + private object? _data; + private readonly List _tags; + + /// Starts a command with the given title; is the shorter way to write it. + public BaletteCommandBuilder(string title) + { + _title = title; + _keywords = []; + _tags = []; + } + + internal BaletteCommandBuilder(BaletteCommand c) + { + _id = c.Id; // copies keep their identity even if the title changes + _title = c.Title; + _subtitle = c.Subtitle; + _description = c.Description; + _keywords = [.. c.Keywords]; + _group = c.Group; + _icon = c.Icon; + _iconCss = c.IconCss; + _shortcut = c.Shortcut; + _globalShortcut = c.GlobalShortcut; + _priority = c.Priority; + _parent = c.Parent; + _children = c.Children is null ? null : [.. c.Children]; + _childrenProvider = c.ChildrenProvider; + _childrenPlaceholder = c.ChildrenPlaceholder; + _multiSelect = c.MultiSelectChildren; + _actions = c.Actions is null ? null : [.. c.Actions]; + _handler = c.Handler; + _href = c.Href; + _forceLoad = c.ForceLoad; + _newTab = c.OpenInNewTab; + _kind = c.Kind; + _disabled = c.Disabled; + _disabledReason = c.DisabledReason; + _isVisible = c.IsVisible; + _destructive = c.Destructive; + _confirm = c.ConfirmMessage; + _badge = c.Badge; + _value = c.Value; + _checked = c.Checked; + _alwaysShow = c.AlwaysShow; + _excludeFromHistory = c.ExcludeFromHistory; + _data = c.Data; + _tags = [.. c.Tags]; + } + + /// Sets an explicit, stable id. Do so whenever the title can change, so history and selection survive it. + public BaletteCommandBuilder Id(string id) { _id = id; return this; } + /// Sets the label. + public BaletteCommandBuilder Title(string title) { _title = title; return this; } + /// Sets the secondary label next to the title. + public BaletteCommandBuilder Subtitle(string? subtitle) { _subtitle = subtitle; return this; } + /// Sets the longer text shown in the detail pane. + public BaletteCommandBuilder Description(string? description) { _description = description; return this; } + /// Adds aliases, synonyms and abbreviations that also find the command. + public BaletteCommandBuilder Keywords(params string[] keywords) { _keywords.AddRange(keywords); return this; } + /// Sets the heading the command is listed under. + public BaletteCommandBuilder Group(string? group) { _group = group; return this; } + /// Sets an icon rendered before the title. + public BaletteCommandBuilder Icon(RenderFragment icon) { _icon = icon; return this; } + /// Sets the CSS class of an icon element, e.g. bi bi-house. + public BaletteCommandBuilder Icon(string iconCss) { _iconCss = iconCss; return this; } + /// Sets a shortcut such as Mod+K or G I; global ones also fire while the palette is closed. + public BaletteCommandBuilder Shortcut(string shortcut, bool global = true) { _shortcut = BaletteKeyboardShortcut.Parse(shortcut); _globalShortcut = global; return this; } + /// Sets a shortcut; global ones also fire while the palette is closed. + public BaletteCommandBuilder Shortcut(BaletteKeyboardShortcut shortcut, bool global = true) { _shortcut = shortcut; _globalShortcut = global; return this; } + /// Sets a ranking nudge; see . + public BaletteCommandBuilder Priority(int priority) { _priority = priority; return this; } + /// Lists the command on the page of the command with this id instead of the root. + public BaletteCommandBuilder Parent(string? parentId) { _parent = parentId; return this; } + /// Adds nested commands, turning this command into a page. + public BaletteCommandBuilder Children(params BaletteCommand[] children) { (_children ??= []).AddRange(children); if (_kind == BaletteCommandKind.Action) { _kind = BaletteCommandKind.Page; } return this; } + /// Adds nested commands, turning this command into a page. + public BaletteCommandBuilder Children(IEnumerable children) { (_children ??= []).AddRange(children); if (_kind == BaletteCommandKind.Action) { _kind = BaletteCommandKind.Page; } return this; } + /// Loads the nested commands when the page opens, turning this command into a page. + public BaletteCommandBuilder Children(Func>> provider, string? placeholder = null) { _childrenProvider = provider; _childrenPlaceholder = placeholder; if (_kind == BaletteCommandKind.Action) { _kind = BaletteCommandKind.Page; } return this; } + /// Sets the search box placeholder on this command's page. + public BaletteCommandBuilder ChildrenPlaceholder(string? placeholder) { _childrenPlaceholder = placeholder; return this; } + /// Makes the rows of this command's page checkboxes; Ctrl+Enter runs the handler with the checked ones. + public BaletteCommandBuilder MultiSelect(bool multiSelect = true) { _multiSelect = multiSelect; return this; } + /// Adds secondary actions shown in the action panel on Tab. + public BaletteCommandBuilder Actions(params BaletteCommand[] actions) { (_actions ??= []).AddRange(actions); return this; } + /// Sets what runs on selection, and what the palette does next. + public BaletteCommandBuilder Handler(Func> handler) { _handler = handler; return this; } + /// Sets what runs on selection; the palette closes afterwards. + public BaletteCommandBuilder Handler(Func handler) { _handler = async ctx => { await handler(ctx).ConfigureAwait(false); return BaletteCommandResult.Close; }; return this; } + /// Sets what runs on selection; the palette closes afterwards. + public BaletteCommandBuilder Handler(Action handler) { _handler = ctx => { handler(ctx); return Task.FromResult(BaletteCommandResult.Close); }; return this; } + /// Sets what runs on selection; the palette closes afterwards. + public BaletteCommandBuilder Handler(Action handler) { _handler = _ => { handler(); return Task.FromResult(BaletteCommandResult.Close); }; return this; } + /// Navigates to on selection when there is no handler, and makes the command a navigation. + public BaletteCommandBuilder Href(string href, bool forceLoad = false, bool newTab = false) { _href = href; _forceLoad = forceLoad; _newTab = newTab; _kind = BaletteCommandKind.Navigation; return this; } + /// Sets the semantic kind, which picks the default icon. + public BaletteCommandBuilder Kind(BaletteCommandKind kind) { _kind = kind; return this; } + /// Lists the command but does not run it, showing . + public BaletteCommandBuilder Disabled(bool disabled = true, string? reason = null) { _disabled = disabled; _disabledReason = reason; return this; } + /// Lists the command only while returns true; evaluated every time the list is built. + public BaletteCommandBuilder VisibleWhen(Func predicate) { _isVisible = predicate; return this; } + /// Styles the command as dangerous; with a message, Enter has to be pressed twice. Without one, a confirmation set by stays. + public BaletteCommandBuilder Destructive(string? confirmMessage = null) { _destructive = true; _confirm = confirmMessage ?? _confirm; return this; } + /// Asks for a second Enter before running, showing . + public BaletteCommandBuilder Confirm(string message) { _confirm = message; return this; } + /// Sets a small pill at the end of the row, e.g. "New". + public BaletteCommandBuilder Badge(string? badge) { _badge = badge; return this; } + /// Sets the current value of a setting, e.g. "Dark". + public BaletteCommandBuilder Value(string? value) { _value = value; if (_kind == BaletteCommandKind.Action) { _kind = BaletteCommandKind.Setting; } return this; } + /// Makes the command a toggle with this state. + public BaletteCommandBuilder Checked(bool? isChecked) { _checked = isChecked; _kind = BaletteCommandKind.Toggle; return this; } + /// Lists the command whatever the query. + public BaletteCommandBuilder AlwaysShow(bool alwaysShow = true) { _alwaysShow = alwaysShow; return this; } + /// Keeps the command out of recents and frecency. + public BaletteCommandBuilder ExcludeFromHistory(bool exclude = true) { _excludeFromHistory = exclude; return this; } + /// Attaches anything templates or handlers need. + public BaletteCommandBuilder Data(object? data) { _data = data; return this; } + /// Adds free-form labels that modes can scope to. + public BaletteCommandBuilder Tags(params string[] tags) { _tags.AddRange(tags); return this; } + + /// Creates the immutable command. Rarely needed: a builder converts to a command implicitly. + public BaletteCommand Build() => new() + { + Id = _id!, // null keeps the id derived (see BaletteCommand.Id) + Title = _title, + Subtitle = _subtitle, + Description = _description, + Keywords = _keywords, + Group = _group, + Icon = _icon, + IconCss = _iconCss, + Shortcut = _shortcut, + GlobalShortcut = _globalShortcut, + Priority = _priority, + Parent = _parent, + Children = _children, + ChildrenProvider = _childrenProvider, + ChildrenPlaceholder = _childrenPlaceholder, + MultiSelectChildren = _multiSelect, + Actions = _actions, + Handler = _handler, + Href = _href, + ForceLoad = _forceLoad, + OpenInNewTab = _newTab, + Kind = _kind, + Disabled = _disabled, + DisabledReason = _disabledReason, + IsVisible = _isVisible, + Destructive = _destructive, + ConfirmMessage = _confirm, + Badge = _badge, + Value = _value, + Checked = _checked, + AlwaysShow = _alwaysShow, + ExcludeFromHistory = _excludeFromHistory, + Data = _data, + Tags = _tags, + }; + + /// Builds the command, so a builder can be passed wherever a command is expected. + public static implicit operator BaletteCommand(BaletteCommandBuilder b) => b.Build(); +} + +/// Entry points for the fluent API: BaletteCmd.New("Title").Href("/").Build(). +public static class BaletteCmd +{ + /// Starts a command with the given title. + public static BaletteCommandBuilder New(string title) => new(title); + /// Starts a command that navigates to . + public static BaletteCommandBuilder Navigate(string title, string href) => new BaletteCommandBuilder(title).Href(href); + /// Starts a command that runs and closes the palette. + public static BaletteCommandBuilder Action(string title, Action action) => new BaletteCommandBuilder(title).Handler(action); + /// Starts a command that runs and closes the palette. + public static BaletteCommandBuilder Action(string title, Func action) => new BaletteCommandBuilder(title).Handler(_ => action()); + /// Starts a command whose handler decides what the palette does next. + public static BaletteCommandBuilder Action(string title, Func> action) => new BaletteCommandBuilder(title).Handler(action); + /// Starts a command that opens a page listing . + public static BaletteCommandBuilder Page(string title, params BaletteCommand[] children) => new BaletteCommandBuilder(title).Kind(BaletteCommandKind.Page).Children(children); +} diff --git a/src/Balette/Bit.Balette/Models/BaletteCommandContext.cs b/src/Balette/Bit.Balette/Models/BaletteCommandContext.cs new file mode 100644 index 0000000000..1775bf4d38 --- /dev/null +++ b/src/Balette/Bit.Balette/Models/BaletteCommandContext.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Components; + +namespace Bit.Balette; + +/// Information passed to a command handler. +public sealed class BaletteCommandContext +{ + internal BaletteCommandContext( + BaletteCommand command, + string query, + string? modePrefix, + string? promptValue, + IReadOnlyList selected, + IServiceProvider services, + NavigationManager navigation, + IBaletteService palette, + CancellationToken cancellationToken) + { + Command = command; + Query = query; + ModePrefix = modePrefix; + PromptValue = promptValue; + SelectedCommands = selected; + Services = services; + Navigation = navigation; + Palette = palette; + CancellationToken = cancellationToken; + } + + /// The command being executed. + public BaletteCommand Command { get; } + + /// The search text at execution time (without a mode prefix). + public string Query { get; } + + /// Active mode prefix (e.g. >), or null for the default mode. + public string? ModePrefix { get; } + + /// Text entered in a prompt step, when the command was reached via . + public string? PromptValue { get; } + + /// In multi-select pages, the set of checked commands. + public IReadOnlyList SelectedCommands { get; } + + /// The scope's services. + public IServiceProvider Services { get; } + + /// For handlers that navigate. + public NavigationManager Navigation { get; } + + /// The palette service, for handlers that register, open or execute other commands. + public IBaletteService Palette { get; } + + /// Cancelled when the palette is closed while this command is still running; whatever the handler then returns is ignored. + public CancellationToken CancellationToken { get; } + + /// Resolves a service from the scope, throwing when it is not registered. + public T GetRequiredService() where T : notnull + => (T)(Services.GetService(typeof(T)) ?? throw new InvalidOperationException($"Service {typeof(T)} is not registered.")); +} diff --git a/src/Balette/Bit.Balette/Models/BaletteCommandMode.cs b/src/Balette/Bit.Balette/Models/BaletteCommandMode.cs new file mode 100644 index 0000000000..f2854fbb0c --- /dev/null +++ b/src/Balette/Bit.Balette/Models/BaletteCommandMode.cs @@ -0,0 +1,55 @@ +namespace Bit.Balette; + +/// +/// A mode scopes the palette to a subset of commands/sources, activated by typing a prefix +/// (VS Code > for commands, GitHub # for issues, etc.). +/// +public sealed class BaletteCommandMode +{ + /// Prefix that activates the mode, e.g. >, #, @, /. + public required string Prefix { get; init; } + + /// Human readable name shown as a chip next to the input. + public required string Name { get; init; } + + /// Placeholder shown while the mode is active. + public string? Placeholder { get; init; } + + /// Help text listed in the default mode's "modes" hint group. + public string? Description { get; init; } + + /// Only registry commands with one of these tags are shown. Null = no tag filter. + public IReadOnlyList? Tags { get; init; } + + /// Only registry commands with one of these groups are shown. Null = no group filter. + public IReadOnlyList? Groups { get; init; } + + /// Hide registry commands entirely (only are used). + public bool HideRegistryCommands { get; init; } + + /// Async sources queried in this mode. + public IReadOnlyList Sources { get; init; } = []; + + /// Whether a hint item for this mode is listed in the default mode when the query is empty. + public bool ShowHint { get; init; } = true; + + internal bool Accepts(BaletteCommand c) + { + if (HideRegistryCommands) + { + return false; + } + + if (Tags is { Count: > 0 } && !c.Tags.Any(t => Tags.Contains(t, StringComparer.OrdinalIgnoreCase))) + { + return false; + } + + if (Groups is { Count: > 0 } && (c.Group is null || !Groups.Contains(c.Group, StringComparer.OrdinalIgnoreCase))) + { + return false; + } + + return true; + } +} diff --git a/src/Balette/Bit.Balette/Models/BaletteCommandResult.cs b/src/Balette/Bit.Balette/Models/BaletteCommandResult.cs new file mode 100644 index 0000000000..a1e2c58917 --- /dev/null +++ b/src/Balette/Bit.Balette/Models/BaletteCommandResult.cs @@ -0,0 +1,49 @@ +namespace Bit.Balette; + +/// Tells the palette what to do after a handler ran. +public abstract record BaletteCommandResult +{ + /// Close the palette (default). + public static BaletteCommandResult Close { get; } = new CloseResult(); + + /// Keep the palette open on the current page (e.g. toggles). + public static BaletteCommandResult KeepOpen { get; } = new KeepOpenResult(); + + /// Clear the query and return to the root page, keeping the palette open. + public static BaletteCommandResult Reset { get; } = new ResetResult(); + + /// Go back one page, keeping the palette open. + public static BaletteCommandResult Back { get; } = new BackResult(); + + /// Push a page listing the given commands. + public static BaletteCommandResult Page(string title, IReadOnlyList commands, string? placeholder = null, bool multiSelect = false, Func>? onSubmit = null) + => new PageResult(title, commands, placeholder, multiSelect, onSubmit); + + /// Ask the user for free text, then run with set. + public static BaletteCommandResult Prompt(string title, Func> onSubmit, string? placeholder = null, string? initialValue = null) + => new PromptResult(title, onSubmit, placeholder, initialValue); + + /// Show a transient message in the palette footer. + public static BaletteCommandResult Toast(string message, bool keepOpen = false) => new ToastResult(message, keepOpen); + + /// See . + public sealed record CloseResult : BaletteCommandResult; + + /// See . + public sealed record KeepOpenResult : BaletteCommandResult; + + /// See . + public sealed record ResetResult : BaletteCommandResult; + + /// See . + public sealed record BackResult : BaletteCommandResult; + + /// A page of commands. OnSubmit is invoked with on Ctrl+Enter for multi-select pages. + public sealed record PageResult(string Title, IReadOnlyList Commands, string? Placeholder, bool MultiSelect, Func>? OnSubmit = null) : BaletteCommandResult; + + /// A free-text step; see . + public sealed record PromptResult(string Title, Func> OnSubmit, string? Placeholder, string? InitialValue) : BaletteCommandResult; + + /// A footer message; see . + public sealed record ToastResult(string Message, bool StayOpen) : BaletteCommandResult; +} diff --git a/src/Balette/Bit.Balette/Models/BaletteKeyboardShortcut.cs b/src/Balette/Bit.Balette/Models/BaletteKeyboardShortcut.cs new file mode 100644 index 0000000000..c967c75d45 --- /dev/null +++ b/src/Balette/Bit.Balette/Models/BaletteKeyboardShortcut.cs @@ -0,0 +1,190 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Bit.Balette; + +/// +/// A keyboard shortcut: one or more chords pressed in sequence (Mod+K, Ctrl+Shift+P, G I). +/// Mod maps to ⌘ on macOS and Ctrl elsewhere. +/// +public sealed record BaletteKeyboardShortcut +{ + /// Creates a shortcut from its chords, pressed in order. + public BaletteKeyboardShortcut(IReadOnlyList chords) + { + if (chords.Count == 0) + { + throw new ArgumentException("A shortcut needs at least one chord.", nameof(chords)); + } + + Chords = chords; + } + + /// The chords, pressed in order; one for a combination such as Mod+K. + public IReadOnlyList Chords { get; } + + /// True for multi-chord sequences such as G I. + public bool IsSequence => Chords.Count > 1; + + /// Parse a shortcut string. Chords are separated by spaces, keys inside a chord by +. + public static BaletteKeyboardShortcut Parse(string text) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + var chords = text.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(BaletteKeyChord.Parse) + .ToArray(); + return new BaletteKeyboardShortcut(chords); + } + + /// Parses a shortcut string, returning false instead of throwing for an invalid one. + public static bool TryParse(string? text, [NotNullWhen(true)] out BaletteKeyboardShortcut? shortcut) + { + shortcut = null; + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + try + { + shortcut = Parse(text); + return true; + } + catch (FormatException) + { + return false; + } + } + + /// Canonical lower-case form used for matching, e.g. mod+k or g i. + public string Normalized => string.Join(' ', Chords.Select(c => c.Normalized)); + + /// Platform-specific display tokens per chord, e.g. [["⌘","K"]] or [["Ctrl","K"]]. + public IReadOnlyList> DisplayTokens(bool isMac) => Chords.Select(c => c.DisplayTokens(isMac)).ToArray(); + + /// Human readable text, e.g. Ctrl+K or G then I. + public string ToDisplayString(bool isMac) => string.Join(isMac ? " " : " then ", DisplayTokens(isMac).Select(t => string.Join(isMac ? string.Empty : "+", t))); + + /// Shortcuts are equal when their normalized forms are: Ctrl+K equals control+k. + public bool Equals(BaletteKeyboardShortcut? other) => other is not null && Normalized == other.Normalized; + + public override int GetHashCode() => Normalized.GetHashCode(StringComparison.Ordinal); + + public override string ToString() => Normalized; + + /// Parses a string, so a shortcut can be written as text wherever one is expected. + public static implicit operator BaletteKeyboardShortcut(string text) => Parse(text); +} + +/// A single key press with modifiers. +public sealed record BaletteKeyChord(string Key, bool Ctrl = false, bool Shift = false, bool Alt = false, bool Meta = false, bool Mod = false) +{ + private static readonly Dictionary KeyAliases = new(StringComparer.OrdinalIgnoreCase) + { + ["esc"] = "escape", + ["return"] = "enter", + // Spelled out: a literal space separates the chords of a sequence in Normalized. + ["spacebar"] = "space", + ["del"] = "delete", + ["ins"] = "insert", + ["up"] = "arrowup", + ["down"] = "arrowdown", + ["left"] = "arrowleft", + ["right"] = "arrowright", + ["plus"] = "+", + ["comma"] = ",", + ["period"] = ".", + ["slash"] = "/", + ["backslash"] = "\\", + ["minus"] = "-", + ["pgup"] = "pageup", + ["pgdn"] = "pagedown", + }; + + /// Parses one chord such as Ctrl+Shift+P; throws a without a key or with two. + public static BaletteKeyChord Parse(string text) + { + ArgumentException.ThrowIfNullOrWhiteSpace(text); + bool ctrl = false, shift = false, alt = false, meta = false, mod = false; + string? key = null; + + IEnumerable parts = text.EndsWith("++", StringComparison.Ordinal) + ? text[..^2].Split('+', StringSplitOptions.RemoveEmptyEntries).Append("+") + : text.Split('+', StringSplitOptions.RemoveEmptyEntries); + + foreach (var raw in parts) + { + var p = raw.Trim(); + switch (p.ToLowerInvariant()) + { + case "ctrl" or "control" or "⌃": ctrl = true; break; + case "shift" or "⇧": shift = true; break; + case "alt" or "option" or "opt" or "⌥": alt = true; break; + case "meta" or "cmd" or "command" or "win" or "super" or "⌘": meta = true; break; + case "mod" or "cmdorctrl" or "commandorcontrol": mod = true; break; + default: + if (key is not null) + { + throw new FormatException($"Chord '{text}' has more than one non-modifier key."); + } + + key = KeyAliases.TryGetValue(p, out var alias) ? alias : p.ToLowerInvariant(); + break; + } + } + + if (key is null) + { + throw new FormatException($"Chord '{text}' has no key."); + } + + return new BaletteKeyChord(key, ctrl, shift, alt, meta, mod); + } + + /// Canonical lower-case form, modifiers in fixed order: mod, ctrl, alt, shift, meta. + public string Normalized + { + get + { + var sb = new StringBuilder(); + if (Mod) sb.Append("mod+"); + if (Ctrl) sb.Append("ctrl+"); + if (Alt) sb.Append("alt+"); + if (Shift) sb.Append("shift+"); + if (Meta) sb.Append("meta+"); + sb.Append(Key); + return sb.ToString(); + } + } + + /// The key caps to render, e.g. ⌘ K on macOS and Ctrl K elsewhere. + public IReadOnlyList DisplayTokens(bool isMac) + { + var tokens = new List(5); + if (Mod) tokens.Add(isMac ? "⌘" : "Ctrl"); + if (Ctrl) tokens.Add(isMac ? "⌃" : "Ctrl"); + if (Alt) tokens.Add(isMac ? "⌥" : "Alt"); + if (Shift) tokens.Add(isMac ? "⇧" : "Shift"); + if (Meta) tokens.Add(isMac ? "⌘" : "Win"); + tokens.Add(DisplayKey(Key, isMac)); + return tokens; + } + + private static string DisplayKey(string key, bool isMac) => key switch + { + "enter" => isMac ? "↩" : "Enter", + "escape" => "Esc", + "backspace" => isMac ? "⌫" : "Backspace", + "delete" => isMac ? "⌦" : "Del", + "tab" => isMac ? "⇥" : "Tab", + "space" => "Space", + "arrowup" => "↑", + "arrowdown" => "↓", + "arrowleft" => "←", + "arrowright" => "→", + "pageup" => "PgUp", + "pagedown" => "PgDn", + _ when key.Length == 1 => key.ToUpperInvariant(), + _ => char.ToUpperInvariant(key[0]) + key[1..], + }; +} diff --git a/src/Balette/Bit.Balette/README.md b/src/Balette/Bit.Balette/README.md new file mode 100644 index 0000000000..617bbf8be9 --- /dev/null +++ b/src/Balette/Bit.Balette/README.md @@ -0,0 +1,79 @@ +# Bit.Balette + +An accessible, keyboard-first command palette for Blazor: the `Ctrl+K` / `⌘K` box that searches every page, +action and setting of an app and runs the one you pick. Matching, ranking, paging and history are written in C#; +JavaScript only opens the native ``, moves focus and listens for keys - there is no JavaScript command-menu +library underneath. + +## Quick start + +```bash +dotnet add package Bit.Balette +``` + +```csharp +// Program.cs - in both projects of a Blazor Web App +builder.Services.AddBitBaletteServices(); +``` + +```html + + +``` + +```razor +@* _Imports.razor *@ +@using Bit.Balette +``` + +```razor +@* MainLayout.razor - an interactively rendered layout *@ +@inject IBaletteService Palette + + +@Body + + +@code { + protected override void OnInitialized() => Palette.Register( + BaletteCmd.Navigate("Home", "/").Group("Navigation").Shortcut("G H"), + BaletteCmd.Navigate("Settings", "/settings").Group("Navigation").Shortcut("G S").Keywords("preferences"), + BaletteCmd.New("Theme").Group("Preferences").Children( + BaletteCmd.Action("Light", () => SetTheme("light")), + BaletteCmd.Action("Dark", () => SetTheme("dark")))); + + private void SetTheme(string theme) { /* ... */ } +} +``` + +`Register` returns an `IDisposable` - dispose it to remove page-scoped commands, or use +``. Commands can also be written as markup with `` and +`` inside ``. + +## Features + +- An fzf-style fuzzy matcher with word-boundary, camelCase and consecutive-run bonuses, smart case and highlighting. +- Multi-term queries with `'exact`, `^prefix`, `suffix$` and `!negate` operators; weighted fields; priority and + frecency ranking. +- Nested pages - static, lazily loaded, declarative or returned by a handler - with breadcrumbs. +- Prefix modes (`>`, `#`, `@`, `?`) and debounced, cancellable async sources. +- Prompts, multi-select, confirmation of destructive commands, toasts and a `Tab` action panel. +- Recents and pins persisted to `localStorage` or any store you plug in. +- Global shortcuts and sequences such as `G I`, matched in the browser, with platform-aware hints. +- A native modal `` with the WAI-ARIA combobox pattern and live announcements. +- CSS-variable theming, light and dark, eight templates or a fully unstyled mode, localizable strings. +- Blazor WebAssembly, Server, Web App (including prerendering) and Hybrid on .NET 8, 9 and 10; trimming and AOT + friendly. + +## For AI agents + +The demo site hosts an MCP server at `https://balette.bitplatform.dev/mcp` (also served through +`https://bitplatform.dev/mcp`) that answers from the shipped library: its API, its guide and the demo's working +pages. + +## Documentation + +The full guide - commands, declarative markup, handlers and results, nested pages, search and ranking, modes, async +sources, history, the keyboard, theming, templates, localization, accessibility and hosting models - is in the +[Balette README](https://github.com/bitfoundation/bitplatform/tree/develop/src/Balette). See it running at +[balette.bitplatform.dev](https://balette.bitplatform.dev). diff --git a/src/Balette/Bit.Balette/Scripts/bit-balette.ts b/src/Balette/Bit.Balette/Scripts/bit-balette.ts new file mode 100644 index 0000000000..7840450d9d --- /dev/null +++ b/src/Balette/Bit.Balette/Scripts/bit-balette.ts @@ -0,0 +1,443 @@ +/*! + * Bit.Balette browser layer. + * + * This module does only what the DOM alone can do: open and close the native , move focus in + * and out of it, scroll the active row into view, detect the platform, and listen for keys. Matching, + * ranking, paging and every other decision stays in C#. + * + * Global shortcuts are matched here rather than in .NET: the registered chords are synced as + * normalized strings ('mod+k', 'g i'), and only a key press that completes one of them crosses the + * interop boundary, so typing anywhere on the page never produces a stream of round trips under + * Blazor Server. Inside the dialog, the navigation keys are claimed with preventDefault before .NET + * sees them, because Blazor's own preventDefault is all-or-nothing and, over SignalR, too late: Tab + * would already have left the dialog and Backspace would already have deleted text. + */ + +// --------------------------------------------------------------------------- +// Contracts shared with the C# interop +// --------------------------------------------------------------------------- + +interface DotNetObjectReference { + invokeMethodAsync(method: string, ...args: unknown[]): Promise; +} + +/** The options Balette.razor.cs sends to `init` (camelCase JSON of an anonymous object). */ +interface BaletteInitOptions { + toggleShortcuts?: string[] | null; + commandShortcuts?: string[] | null; + suppressInEditable?: boolean; + sequenceTimeout?: number; + vimBindings?: boolean; +} + +/** The handle `init` returns; .NET holds it as an IJSObjectReference and calls its members. */ +interface BaletteHandle { + isMac(): boolean; + open(input: HTMLInputElement | null, selectAll: boolean): void; + close(): void; + focusInput(input: HTMLInputElement | null): void; + scrollIntoView(list: HTMLElement | null, id: string, position?: number, count?: number): void; + setCommandShortcuts(list: string[] | null): void; + setToggleShortcuts(list: string[] | null): void; + setVimBindings(enabled: boolean): void; + afterPaint(): Promise; + openInNewTab(url: string): void; + dispose(): void; +} + +// --------------------------------------------------------------------------- +// Key handling helpers +// --------------------------------------------------------------------------- + +const NAV_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Home', 'End', 'PageDown', 'PageUp', 'Enter', 'Tab', 'Escape']); +const MOD_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta', 'OS']); + +function detectMac(): boolean { + const uaData = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData; + if (uaData && uaData.platform) { + return /mac|iphone|ipad|ipod/i.test(uaData.platform); + } + return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent || ''); +} + +function isEditable(el: EventTarget | null): boolean { + if (!(el instanceof Element)) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + return (el as HTMLElement).isContentEditable === true; +} + +/** + * Turns a KeyboardEvent into the chord strings it can stand for: the literal one ('ctrl+k') and, when + * the platform's primary modifier is held, the 'mod' variant ('mod+k') - the same canonical form + * BaletteKeyChord.Normalized writes on the C# side. + */ +function chordsFor(e: KeyboardEvent, isMac: boolean): string[] { + const raw = e.key; + if (!raw) return []; + if (MOD_KEYS.has(raw)) return []; + // Space is written out, as on the C# side: a literal ' ' is what separates the chords of a sequence. + const key = raw === ' ' ? 'space' : raw.toLowerCase(); + + // Option on macOS, and AltGr on many layouts, compose a character instead ('Ω' for Option+Z), which no + // shortcut is written with: with Alt held, the physical letter or digit key stands for the key as well. + const keys = [key]; + if (e.altKey && !/^[a-z0-9]$/.test(key)) { + const physical = physicalKey(e.code); + if (physical) keys.push(physical); + } + + const parts: string[] = []; + if (e.ctrlKey) parts.push('ctrl'); + if (e.altKey) parts.push('alt'); + if (e.shiftKey) parts.push('shift'); + if (e.metaKey) parts.push('meta'); + + // The 'mod' variant: Meta on macOS, Ctrl everywhere else. + const modPressed = isMac ? e.metaKey : e.ctrlKey; + const modParts = ['mod']; + if (isMac && e.ctrlKey) modParts.push('ctrl'); + if (e.altKey) modParts.push('alt'); + if (e.shiftKey) modParts.push('shift'); + if (!isMac && e.metaKey) modParts.push('meta'); + + const result: string[] = []; + for (const k of keys) { + result.push([...parts, k].join('+')); + if (modPressed) result.push([...modParts, k].join('+')); + } + + // A shifted single character also matches without its shift: a shortcut written "?" fires on + // Shift+/ on a US layout without the author having to know that. + if (e.shiftKey && key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + result.push(key); + } + + return result; +} + +/** The key a letter or digit key ('KeyZ', 'Digit1') stands for whatever it types, or null for any other key. */ +function physicalKey(code: string): string | null { + const m = /^(?:Key([A-Z])|Digit([0-9]))$/.exec(code || ''); + return m ? (m[1] || m[2]).toLowerCase() : null; +} + +/** Scrolls the list just enough to show the element, with the group heading above a group's first item. */ +function reveal(list: HTMLElement | null, el: Element): void { + const prev = el.previousElementSibling; + const anchor = prev && prev.getAttribute('role') === 'presentation' ? prev : el; + if (anchor !== el && list) { + const r = list.getBoundingClientRect(); + const a = anchor.getBoundingClientRect(); + if (a.top < r.top) { + anchor.scrollIntoView({ block: 'nearest' }); + return; + } + } + el.scrollIntoView({ block: 'nearest' }); +} + +/** Scrolls the list to where row `position` of `count` lies, estimated from the list's height. */ +function scrollToEstimate(list: HTMLElement, position: number, count: number): void { + const style = getComputedStyle(list); + const padTop = parseFloat(style.paddingTop) || 0; + const content = list.scrollHeight - padTop - (parseFloat(style.paddingBottom) || 0); + const height = content / count; + const top = padTop + height * position; + if (top < list.scrollTop) { + list.scrollTop = top; + } else if (top + height > list.scrollTop + list.clientHeight) { + list.scrollTop = top + height - list.clientHeight; + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +export function init(dialog: HTMLDialogElement, dotnet: DotNetObjectReference, options: BaletteInitOptions): BaletteHandle { + const isMac = detectMac(); + let toggleShortcuts = new Set(options.toggleShortcuts || []); + let commandShortcuts = new Set(options.commandShortcuts || []); + const suppressInEditable = options.suppressInEditable !== false; + const sequenceTimeout = options.sequenceTimeout || 1000; + let vimBindings = options.vimBindings === true; + + // Each entry holds the alternative chord strings of one key press (see chordsFor). + let buffer: string[][] = []; + let bufferTimer: ReturnType | null = null; + let previouslyFocused: Element | null = null; + let inputEl: HTMLInputElement | null = null; + let disposed = false; + let reportedSuspension: string | null = null; + let scrollRequest = 0; + + const allShortcuts = () => [...toggleShortcuts, ...commandShortcuts]; + + /** In-palette chords (Ctrl+N/P, Vim Ctrl+J/K, Mod+Shift+P to pin) win over a toggle bound to the same keys. */ + function isReservedInside(e: KeyboardEvent): boolean { + const lower = e.key.length === 1 ? e.key.toLowerCase() : e.key; + const plainCtrl = e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey; + if (plainCtrl && (lower === 'n' || lower === 'p')) return true; + if (vimBindings && plainCtrl && (lower === 'j' || lower === 'k')) return true; + if (lower === 'p' && e.shiftKey && !e.altKey && (isMac ? e.metaKey : e.ctrlKey)) return true; + return false; + } + + function resetBuffer(): void { + buffer = []; + if (bufferTimer) { + clearTimeout(bufferTimer); + bufferTimer = null; + } + } + + /** Matches the tail of the buffer against every registered sequence, and says whether a longer one could still match. */ + function matchBuffer(): { matched: string | null; prefixPossible: boolean } { + let prefixPossible = false; + for (const seq of allShortcuts()) { + const chords = seq.split(' '); + if (chords.length > buffer.length) { + let ok = true; + for (let i = 0; i < buffer.length; i++) { + if (!buffer[i].includes(chords[i])) { ok = false; break; } + } + if (ok) prefixPossible = true; + continue; + } + + const offset = buffer.length - chords.length; + let ok = true; + for (let i = 0; i < chords.length; i++) { + if (!buffer[offset + i].includes(chords[i])) { ok = false; break; } + } + if (ok) { + return { matched: seq, prefixPossible }; + } + } + return { matched: null, prefixPossible }; + } + + function onDocumentKeyDown(e: KeyboardEvent): void { + if (disposed || e.defaultPrevented || e.isComposing) return; + const alts = chordsFor(e, isMac); + if (alts.length === 0) return; + + // While the palette is open only the single-chord toggles are global; the dialog's own + // handler owns every other key. + if (dialog.open) { + if (isReservedInside(e)) return; + for (const t of toggleShortcuts) { + if (!t.includes(' ') && alts.includes(t)) { + e.preventDefault(); + dotnet.invokeMethodAsync('OnToggleShortcut'); + return; + } + } + return; + } + + // Plain keys never fire from an editable element - typing "g" in a text box is typing - but + // chords with a modifier still do. + const hasModifier = e.ctrlKey || e.metaKey || e.altKey; + if (isEditable(e.target) && suppressInEditable && !hasModifier) { + resetBuffer(); + return; + } + + buffer.push(alts); + if (bufferTimer) clearTimeout(bufferTimer); + bufferTimer = setTimeout(resetBuffer, sequenceTimeout); + + const { matched, prefixPossible } = matchBuffer(); + if (matched) { + e.preventDefault(); + e.stopPropagation(); + resetBuffer(); + if (toggleShortcuts.has(matched)) { + dotnet.invokeMethodAsync('OnToggleShortcut'); + } else { + dotnet.invokeMethodAsync('OnGlobalShortcut', matched); + } + return; + } + if (!prefixPossible) { + resetBuffer(); + } + } + + function onDialogKeyDown(e: KeyboardEvent): void { + if (disposed || e.isComposing) return; + const key = e.key; + const ctrl = e.ctrlKey, shift = e.shiftKey, alt = e.altKey, meta = e.metaKey; + const lower = key.length === 1 ? key.toLowerCase() : key; + const emptyQuery = inputEl !== null && inputEl.value.length === 0; + + let handled = false; + if (NAV_KEYS.has(key)) { + handled = true; + } else if (ctrl && !shift && !alt && !meta && (lower === 'n' || lower === 'p')) { + handled = true; + } else if (vimBindings && ctrl && !shift && !alt && !meta && (lower === 'j' || lower === 'k')) { + handled = true; + } else if (key === 'Backspace' && emptyQuery) { + handled = true; + } else if ((key === 'ArrowLeft' || key === 'ArrowRight') && emptyQuery) { + handled = true; + } else if (key === ' ' && emptyQuery && dialog.dataset.multiselect === 'true') { + handled = true; + } else if (key === 'Delete' && shift) { + handled = true; + } else if (lower === 'p' && shift && (isMac ? meta : ctrl)) { + handled = true; + } + + if (!handled) return; + e.preventDefault(); + e.stopPropagation(); + dotnet.invokeMethodAsync('OnKeyDown', key, ctrl, shift, alt, meta); + } + + // Escape is handled by .NET (cancel a confirmation, then go back a page, then close), so the + // dialog's own close-on-Escape is cancelled. + function onCancel(e: Event): void { + e.preventDefault(); + } + + // A click whose target is the dialog element itself landed on the ::backdrop. + function onDialogClick(e: MouseEvent): void { + if (e.target === dialog) { + dotnet.invokeMethodAsync('OnBackdropClick'); + } + } + + // Hover selection is suspended while a new query moves rows under a resting pointer, and each suspension renders + // a fresh value on the dialog. Only the first move of each crosses to .NET: every other one would be a round + // trip under Blazor Server for nothing. + function onPointerMove(): void { + const suspension = dialog.getAttribute('data-hover-suspended'); + if (suspension === null || suspension === reportedSuspension) return; + reportedSuspension = suspension; + dotnet.invokeMethodAsync('OnPointerMoved'); + } + + document.addEventListener('keydown', onDocumentKeyDown, true); + dialog.addEventListener('keydown', onDialogKeyDown); + dialog.addEventListener('cancel', onCancel); + dialog.addEventListener('click', onDialogClick); + dialog.addEventListener('pointermove', onPointerMove, { passive: true }); + + return { + isMac() { + return isMac; + }, + + open(input, selectAll) { + inputEl = input instanceof HTMLInputElement ? input : null; + previouslyFocused = document.activeElement; + if (!dialog.open) { + try { + dialog.showModal(); + } catch { + dialog.setAttribute('open', ''); + } + } + document.documentElement.classList.add('bit-balette-scroll-lock'); + if (inputEl) { + inputEl.focus({ preventScroll: true }); + if (selectAll) { + // A query left from the last time is selected, so typing replaces it. + inputEl.select(); + } else { + // A query passed in on purpose (often a mode prefix such as '>') is kept: typing goes after it. + const len = inputEl.value.length; + try { inputEl.setSelectionRange(len, len); } catch { /* not a text input */ } + } + } + }, + + close() { + if (dialog.open) { + dialog.close(); + } + document.documentElement.classList.remove('bit-balette-scroll-lock'); + if (previouslyFocused instanceof HTMLElement || previouslyFocused instanceof SVGElement) { + try { previouslyFocused.focus({ preventScroll: true }); } catch { /* the element is gone */ } + } + previouslyFocused = null; + }, + + focusInput(input) { + // An element reference .NET captured before the input was rendered arrives as something else entirely. + if (input instanceof HTMLInputElement) inputEl = input; + if (inputEl && inputEl.isConnected) { + inputEl.focus({ preventScroll: true }); + const len = inputEl.value.length; + try { inputEl.setSelectionRange(len, len); } catch { /* not a text input */ } + } + }, + + scrollIntoView(list, id, position, count) { + const request = ++scrollRequest; + const el = document.getElementById(id); + if (el) { + reveal(list, el); + return; + } + + // Virtualized out of the DOM: scroll to where the row should be so that Virtualize renders it, then + // reveal it once it is there. The estimate is redone while Virtualize is still measuring its rows. + if (!list || typeof position !== 'number' || !count) return; + let frames = 0; + const step = () => { + if (disposed || request !== scrollRequest || !list.isConnected) return; + const found = document.getElementById(id); + if (found) { + reveal(list, found); + return; + } + if (frames % 4 === 0) scrollToEstimate(list, position, count); + if (++frames < 60) requestAnimationFrame(step); + }; + step(); + }, + + setCommandShortcuts(list) { + commandShortcuts = new Set(list || []); + }, + + setToggleShortcuts(list) { + toggleShortcuts = new Set(list || []); + }, + + setVimBindings(enabled) { + vimBindings = enabled === true; + }, + + /** Resolves once the browser has had a chance to paint, or after 100 ms when frames are throttled. */ + afterPaint() { + return new Promise(resolve => { + const fallback = setTimeout(resolve, 100); + requestAnimationFrame(() => setTimeout(() => { + clearTimeout(fallback); + resolve(); + }, 0)); + }); + }, + + openInNewTab(url) { + window.open(url, '_blank', 'noopener,noreferrer'); + }, + + dispose() { + disposed = true; + document.removeEventListener('keydown', onDocumentKeyDown, true); + dialog.removeEventListener('keydown', onDialogKeyDown); + dialog.removeEventListener('cancel', onCancel); + dialog.removeEventListener('click', onDialogClick); + dialog.removeEventListener('pointermove', onPointerMove); + document.documentElement.classList.remove('bit-balette-scroll-lock'); + resetBuffer(); + }, + }; +} diff --git a/src/Balette/Bit.Balette/Search/BaletteFuzzyMatcher.cs b/src/Balette/Bit.Balette/Search/BaletteFuzzyMatcher.cs new file mode 100644 index 0000000000..f186f366cc --- /dev/null +++ b/src/Balette/Bit.Balette/Search/BaletteFuzzyMatcher.cs @@ -0,0 +1,375 @@ +namespace Bit.Balette; + +/// +/// fzf-inspired fuzzy matcher using dynamic programming (Smith-Waterman style with affine gaps). +/// Rewards matches at word boundaries, camelCase humps, consecutive runs and the first pattern character; +/// penalizes gaps. Smart case: a lower-case query is case-insensitive, mixed case is case-sensitive. +/// Returns the positions of the optimal alignment for highlighting. +/// +public sealed class BaletteFuzzyMatcher : IBaletteMatcher +{ + /// Score of every matched character. + public const int ScoreMatch = 16; + /// Penalty of the first skipped character of a gap. + public const int ScoreGapStart = -3; + /// Penalty of every further skipped character of a gap. + public const int ScoreGapExtension = -1; + /// Bonus for a match at the start of a word. + public const int BonusBoundary = 8; + /// Bonus for matching punctuation the query typed. + public const int BonusNonWord = 8; + /// Bonus for a camelCase hump or a letter-to-digit step. + public const int BonusCamel = 7; + /// Minimum bonus for a character that follows the previous match. + public const int BonusConsecutive = 4; + /// The first query character's bonus is multiplied by this. + public const int BonusFirstCharMultiplier = 2; + /// Bonus for matching the case exactly. + public const int BonusCaseMatch = 1; + + /// Shared default instance. + public static BaletteFuzzyMatcher Default { get; } = new(); + + /// Disable smart case and always match case-insensitively. + public bool IgnoreCase { get; init; } + + /// Scores against , with the positions of the best alignment. + public BaletteFieldMatch Match(string query, string text) + { + if (string.IsNullOrEmpty(query)) + { + return BaletteFieldMatch.Empty; + } + + if (string.IsNullOrEmpty(text) || query.Length > text.Length) + { + return BaletteFieldMatch.None; + } + + // Runs once per field per command on every keystroke (interpreted on WebAssembly), so it avoids + // LINQ, per-call pooling and full-matrix initialization. Results are identical to the plain DP. + var caseSensitive = !IgnoreCase && HasUpper(query); + int p = query.Length, t = text.Length; + var size = (p + 1) * (t + 1); + (t_scratch ??= new Scratch()).Get(p, t, size, out var q, out var tx, out var m, out var d, out var consec, out var bonus); + Fold(query, q, caseSensitive); + Fold(text, tx, caseSensitive); + + // Fast reject: must be a subsequence. + if (!IsSubsequence(q, p, tx, t)) + { + return BaletteFieldMatch.None; + } + + const int NegInf = int.MinValue / 2; + const int Floor = int.MinValue / 4; + var w = t + 1; + + ComputeBonus(text, bonus); + + // Column 0 is the only part of m/d read before being written; consec is only read behind a match guard. + for (var i = 0; i <= p; i++) + { + m[i * w] = NegInf; + d[i * w] = NegInf; + } + + // Row-major (p + 1) x (t + 1) matrices: row i + 1 / column j + 1 hold query[i] / text[j]. + // m = best score with query[i] matched at text[j]; d = best score for query[..i] within text[..j]; + // consec = length of the consecutive run ending at (i, j). + for (var i = 0; i < p; i++) + { + var qc = q[i]; + var prevRow = i * w; + var curRow = prevRow + w; + var inGapPrev = false; + for (var j = 0; j < t; j++) + { + var cur = curRow + j + 1; + var mv = NegInf; + if (qc == tx[j]) + { + var prevScore = 0; + var run = 0; + var reachable = true; + if (i > 0) + { + prevScore = d[prevRow + j]; + if (prevScore <= Floor) + { + reachable = false; + } + else if (m[prevRow + j] == prevScore) + { + run = consec[prevRow + j]; + } + } + + if (reachable) + { + var b = bonus[j]; + if (run > 0) + { + // Carry the bonus that started the run so "Foo" inside "fooBar" still benefits. + var runStart = j - run; + var startBonus = runStart >= 0 ? bonus[runStart] : 0; + b = Math.Max(Math.Max(b, BonusConsecutive), startBonus); + } + + if (i == 0) + { + b *= BonusFirstCharMultiplier; + } + + if (caseSensitive || (query[i] == text[j] && char.IsUpper(text[j]))) + { + b += BonusCaseMatch; + } + + mv = prevScore + ScoreMatch + b; + consec[cur] = run + 1; + } + } + + m[cur] = mv; + var left = d[cur - 1]; + var gapScore = left <= Floor ? NegInf : left + (inGapPrev ? ScoreGapExtension : ScoreGapStart); + if (mv >= gapScore) + { + d[cur] = mv; + inGapPrev = false; + } + else + { + d[cur] = gapScore; + inGapPrev = true; + } + } + } + + // Best end position for the last pattern char. + var best = NegInf; + var bestJ = -1; + var lastRow = p * w; + for (var j = 0; j < t; j++) + { + var v = m[lastRow + j + 1]; + if (v > best) + { + best = v; + bestJ = j; + } + } + + if (bestJ < 0 || best <= Floor) + { + return BaletteFieldMatch.None; + } + + // Backtrace positions. + var positions = new int[p]; + var jj = bestJ; + for (var i = p - 1; i >= 0; i--) + { + positions[i] = jj; + if (i == 0) + { + break; + } + + // Previous match ends at the j' <= jj-1 where d == m (the alignment came from a match, not a gap). + jj--; + var row = i * w; + while (jj >= 0 && d[row + jj + 1] != m[row + jj + 1]) + { + jj--; + } + } + + // Tie-breakers (fractions of one bonus point): prefer shorter texts and earlier matches. + var score = best - 0.02 * t - 0.01 * positions[0]; + return new BaletteFieldMatch(true, score, positions); + } + + [ThreadStatic] + private static Scratch? t_scratch; + + /// Per-thread DP buffers reused across calls. Oversized requests get throwaway arrays. + private sealed class Scratch + { + private const int MaxRetainedCells = 16 * 1024; + + private int[] _m = new int[256]; + private int[] _d = new int[256]; + private int[] _consec = new int[256]; + private int[] _bonus = new int[64]; + private char[] _query = new char[32]; + private char[] _text = new char[64]; + + public void Get(int p, int t, int size, out char[] query, out char[] text, out int[] m, out int[] d, out int[] consec, out int[] bonus) + { + if (size > MaxRetainedCells) + { + query = new char[p]; + text = new char[t]; + m = new int[size]; + d = new int[size]; + consec = new int[size]; + bonus = new int[t]; + return; + } + + if (_m.Length < size) + { + var n = Math.Min(MaxRetainedCells, Math.Max(size, _m.Length * 2)); + _m = new int[n]; + _d = new int[n]; + _consec = new int[n]; + } + + if (_text.Length < t) + { + var n = Math.Max(t, _text.Length * 2); + _text = new char[n]; + _bonus = new int[n]; + } + + if (_query.Length < p) + { + _query = new char[Math.Max(p, _query.Length * 2)]; + } + + query = _query; + text = _text; + m = _m; + d = _d; + consec = _consec; + bonus = _bonus; + } + } + + private static bool HasUpper(string s) + { + foreach (var c in s) + { + if (char.IsUpper(c)) + { + return true; + } + } + + return false; + } + + /// Copies into , lower-cased unless matching case-sensitively. + private static void Fold(string s, char[] dst, bool caseSensitive) + { + if (caseSensitive) + { + s.CopyTo(0, dst, 0, s.Length); + return; + } + + for (var i = 0; i < s.Length; i++) + { + var c = s[i]; + dst[i] = c < 128 + ? ((uint)(c - 'A') <= 'Z' - 'A' ? (char)(c | 0x20) : c) + : char.ToLowerInvariant(c); + } + } + + private static bool IsSubsequence(char[] query, int queryLength, char[] text, int textLength) + { + var qi = 0; + for (var ti = 0; ti < textLength; ti++) + { + if (text[ti] == query[qi] && ++qi == queryLength) + { + return true; + } + } + + return false; + } + + private static void ComputeBonus(string text, int[] bonus) + { + var prev = CharClass.NonWord; + for (var i = 0; i < text.Length; i++) + { + var cls = Classify(text[i]); + bonus[i] = BonusFor(prev, cls); + prev = cls; + } + } + + private enum CharClass { NonWord, Lower, Upper, Digit, Letter } + + private static CharClass Classify(char c) + { + if (c < 128) + { + if ((uint)(c - 'a') <= 'z' - 'a') return CharClass.Lower; + if ((uint)(c - 'A') <= 'Z' - 'A') return CharClass.Upper; + if ((uint)(c - '0') <= '9' - '0') return CharClass.Digit; + return CharClass.NonWord; + } + + if (char.IsLower(c)) return CharClass.Lower; + if (char.IsUpper(c)) return CharClass.Upper; + if (char.IsDigit(c)) return CharClass.Digit; + if (char.IsLetter(c)) return CharClass.Letter; + return CharClass.NonWord; + } + + private static int BonusFor(CharClass prev, CharClass cur) + { + if (prev == CharClass.NonWord && cur != CharClass.NonWord) + { + return BonusBoundary; // start of word (after space, punctuation, or at index 0) + } + + if (prev == CharClass.Lower && cur == CharClass.Upper) + { + return BonusCamel; // camelCase hump + } + + if (prev != CharClass.Digit && cur == CharClass.Digit) + { + return BonusCamel; // letter→digit transition + } + + if (cur == CharClass.NonWord) + { + return BonusNonWord; + } + + return 0; + } +} + +/// Simple case-insensitive substring matcher. Faster, no typo tolerance. +public sealed class BaletteSubstringMatcher : IBaletteMatcher +{ + /// Shared default instance. + public static BaletteSubstringMatcher Default { get; } = new(); + + /// Finds in ignoring case, scoring a match at the start higher. + public BaletteFieldMatch Match(string query, string text) + { + if (string.IsNullOrEmpty(query)) + { + return BaletteFieldMatch.Empty; + } + + var idx = text.IndexOf(query, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + { + return BaletteFieldMatch.None; + } + + var score = query.Length * BaletteFuzzyMatcher.ScoreMatch + (idx == 0 ? BaletteFuzzyMatcher.BonusBoundary * 2 : 0); + return new BaletteFieldMatch(true, score, Enumerable.Range(idx, query.Length).ToArray()); + } +} diff --git a/src/Balette/Bit.Balette/Search/BaletteMatch.cs b/src/Balette/Bit.Balette/Search/BaletteMatch.cs new file mode 100644 index 0000000000..cbc2c8d7a7 --- /dev/null +++ b/src/Balette/Bit.Balette/Search/BaletteMatch.cs @@ -0,0 +1,27 @@ +namespace Bit.Balette; + +/// Result of matching a query against one text field. +public readonly record struct BaletteFieldMatch(bool Matched, double Score, int[] Positions) +{ + /// The text does not match. + public static BaletteFieldMatch None { get; } = new(false, double.NegativeInfinity, []); + + /// An empty query: everything matches with a score of 0. + public static BaletteFieldMatch Empty { get; } = new(true, 0, []); +} + +/// Result of matching a query against a across its searchable fields. +public sealed record BaletteMatch(BaletteCommand Command, double Score, int[] TitlePositions, int[] SubtitlePositions, string? MatchedKeyword) +{ + /// Final rank after priority and frecency adjustments. Set by the ranker. + public double Rank { get; init; } + + /// Frecency contribution included in . + public double Frecency { get; init; } + + /// True when the command came from an async . + public bool FromSource { get; init; } + + /// The name of the source the command came from, when . + public string? SourceName { get; init; } +} diff --git a/src/Balette/Bit.Balette/Search/BaletteRanker.cs b/src/Balette/Bit.Balette/Search/BaletteRanker.cs new file mode 100644 index 0000000000..19df7ddf77 --- /dev/null +++ b/src/Balette/Bit.Balette/Search/BaletteRanker.cs @@ -0,0 +1,107 @@ +namespace Bit.Balette; + +/// Combines match score, priority and frecency into a final rank and sorts results. +public sealed class BaletteRanker +{ + private static readonly StringComparer TitleComparer = StringComparer.CurrentCultureIgnoreCase; + + private readonly BaletteOptions _options; + + /// Creates a ranker using the weights of . + public BaletteRanker(BaletteOptions options) + { + _options = options; + } + + /// + /// Ranks matches. With a non-empty query: match score dominates, frecency and priority nudge, and equal ranks go + /// by title. With an empty query: priority, then frecency, then the order the commands were registered or + /// declared in - an author who lists Auto, Light and Dark meant that order. + /// + public List Rank(IEnumerable matches, bool hasQuery, Func frecencyOf) + => Rank(matches, hasQuery, frecencyOf, int.MaxValue); + + /// + /// Same ordering as , keeping only the best + /// results: only those are sorted and copied, which keeps big registries cheap per keystroke. + /// + internal List Rank(IEnumerable matches, bool hasQuery, Func frecencyOf, int limit) + { + var items = matches as IReadOnlyList ?? matches.ToList(); + var n = items.Count; + limit = Math.Max(0, limit); + var ranks = new double[n]; + var frecencies = new double[n]; + for (var i = 0; i < n; i++) + { + var m = items[i]; + var frec = frecencyOf(m.Command.Id); + ranks[i] = hasQuery + ? m.Score + m.Command.Priority * _options.PriorityWeight + Math.Log(1 + frec) * _options.FrecencyWeight + : m.Command.Priority * 1000 + frec; + frecencies[i] = frec; + } + + // Rank descending, then (while searching) title, then original position - a stable sort. + int Compare(int a, int b) + { + var c = ranks[b].CompareTo(ranks[a]); + if (c == 0 && hasQuery) + { + c = TitleComparer.Compare(items[a].Command.Title, items[b].Command.Title); + } + + return c != 0 ? c : a.CompareTo(b); + } + + int[] order; + // A heap only pays for itself when it drops most of the matches: just above the limit, sorting + // everything and keeping the front is cheaper than maintaining the heap. + if (limit >= n / 2) + { + order = new int[n]; + for (var i = 0; i < n; i++) + { + order[i] = i; + } + + Array.Sort(order, Compare); + if (limit < n) + { + order = order[..limit]; + } + } + else + { + // Keep the best `limit` indexes in a heap whose root is the worst one kept. + var heap = new PriorityQueue(limit, Comparer.Create((a, b) => Compare(b, a))); + for (var i = 0; i < n; i++) + { + if (heap.Count < limit) + { + heap.Enqueue(i, i); + } + else if (limit > 0 && Compare(i, heap.Peek()) < 0) + { + heap.DequeueEnqueue(i, i); + } + } + + order = new int[heap.Count]; + for (var i = 0; heap.TryDequeue(out var index, out _); i++) + { + order[i] = index; + } + + Array.Sort(order, Compare); + } + + var ranked = new List(order.Length); + foreach (var i in order) + { + ranked.Add(items[i] with { Rank = ranks[i], Frecency = frecencies[i] }); + } + + return ranked; + } +} diff --git a/src/Balette/Bit.Balette/Search/IBaletteMatcher.cs b/src/Balette/Bit.Balette/Search/IBaletteMatcher.cs new file mode 100644 index 0000000000..185e8e0b44 --- /dev/null +++ b/src/Balette/Bit.Balette/Search/IBaletteMatcher.cs @@ -0,0 +1,267 @@ +namespace Bit.Balette; + +/// Scores how well a query matches a text. Implement to plug in a custom algorithm. +public interface IBaletteMatcher +{ + /// + /// Match against . + /// Returns when the text does not match. + /// Positions are indexes into to highlight. + /// + BaletteFieldMatch Match(string query, string text); +} + +/// Field weights used when combining per-field scores into a command score. +public sealed class BaletteMatchWeights +{ + /// Weight of the title. Default 1.0. + public double Title { get; set; } = 1.0; + /// Weight of a keyword. Default 0.9. + public double Keyword { get; set; } = 0.9; + /// Weight of the subtitle. Default 0.7. + public double Subtitle { get; set; } = 0.7; + /// Weight of the group heading. Default 0.5. + public double Group { get; set; } = 0.5; + /// Weight of the description. Default 0.4. + public double Description { get; set; } = 0.4; + /// Weight of a tag. Default 0.3. + public double Tag { get; set; } = 0.3; +} + +/// Matches a query (possibly multi-term) against a whole command. +public static class BaletteMatcherExtensions +{ + /// + /// Matches every whitespace-separated term of against the command's fields. + /// All terms must match (AND). Each term's score is the best weighted field score. + /// Supports fzf-style operators: 'exact, ^prefix, suffix$, !negate. + /// + public static BaletteMatch? Match(this IBaletteMatcher matcher, BaletteCommand command, string query, BaletteMatchWeights? weights = null) + => matcher.Match(command, BaletteQuery.Parse(query), weights ?? DefaultWeights); + + /// Same as the string overload, for a query parsed once and matched against many commands. + internal static BaletteMatch? Match(this IBaletteMatcher matcher, BaletteCommand command, BaletteQuery query, BaletteMatchWeights weights) + { + var terms = query.Terms; + if (terms.Length == 0) + { + return new BaletteMatch(command, 0, [], [], null); + } + + double total = 0; + int[]? titlePos = null; + int[]? subtitlePos = null; + string? matchedKeyword = null; + + foreach (var (term, op) in terms) + { + if (op == TermOperator.Negate) + { + if (AnyFieldContains(command, term)) + { + return null; + } + + continue; + } + + var best = double.NegativeInfinity; + int[]? bestTitle = null; + int[]? bestSubtitle = null; + string? bestKeyword = null; + + // The best weighted field wins; only title/subtitle positions are kept for highlighting. + if (Improves(matcher, term, op, command.Title, weights.Title, ref best, out var positions)) + { + (bestTitle, bestSubtitle, bestKeyword) = (positions, null, null); + } + + var keywords = command.Keywords; + for (var i = 0; i < keywords.Count; i++) + { + if (Improves(matcher, term, op, keywords[i], weights.Keyword, ref best, out _)) + { + (bestTitle, bestSubtitle, bestKeyword) = (null, null, keywords[i]); + } + } + + if (Improves(matcher, term, op, command.Subtitle, weights.Subtitle, ref best, out positions)) + { + (bestTitle, bestSubtitle, bestKeyword) = (null, positions, null); + } + + if (Improves(matcher, term, op, command.Group, weights.Group, ref best, out _)) + { + (bestTitle, bestSubtitle, bestKeyword) = (null, null, null); + } + + if (Improves(matcher, term, op, command.Description, weights.Description, ref best, out _)) + { + (bestTitle, bestSubtitle, bestKeyword) = (null, null, null); + } + + var tags = command.Tags; + for (var i = 0; i < tags.Count; i++) + { + if (Improves(matcher, term, op, tags[i], weights.Tag, ref best, out _)) + { + (bestTitle, bestSubtitle, bestKeyword) = (null, null, null); + } + } + + if (double.IsNegativeInfinity(best)) + { + return null; + } + + total += best; + if (bestTitle is { Length: > 0 }) + { + titlePos = titlePos is null ? bestTitle : [.. titlePos, .. bestTitle]; + } + + if (bestSubtitle is { Length: > 0 }) + { + subtitlePos = subtitlePos is null ? bestSubtitle : [.. subtitlePos, .. bestSubtitle]; + } + + matchedKeyword ??= bestKeyword; + } + + return new BaletteMatch(command, total, SortedDistinct(titlePos), SortedDistinct(subtitlePos), matchedKeyword); + } + + /// Scores one field; when it beats , updates it and returns the field's positions. + private static bool Improves(IBaletteMatcher matcher, string term, TermOperator op, string? text, double weight, ref double best, out int[] positions) + { + positions = []; + if (string.IsNullOrEmpty(text)) + { + return false; + } + + var m = op == TermOperator.Fuzzy ? matcher.Match(term, text) : ExactMatch(term, text, op); + if (!m.Matched) + { + return false; + } + + var s = m.Score * weight; + if (!(s > best)) + { + return false; + } + + best = s; + positions = m.Positions; + return true; + } + + /// A sorted, de-duplicated copy (matchers usually return ascending positions already). + private static int[] SortedDistinct(int[]? positions) + { + if (positions is null) + { + return []; + } + + var copy = (int[])positions.Clone(); + for (var i = 1; i < copy.Length; i++) + { + if (copy[i] <= copy[i - 1]) + { + return [.. new HashSet(copy).Order()]; + } + } + + return copy; + } + + private static readonly BaletteMatchWeights DefaultWeights = new(); + + internal enum TermOperator { Fuzzy, Exact, Prefix, Suffix, Negate } + + internal static (string Term, TermOperator Op) ParseOperator(string raw) + { + if (raw.Length > 1 && raw[0] == '!') + { + return (raw[1..], TermOperator.Negate); + } + + if (raw.Length > 1 && raw[0] == '\'') + { + return (raw[1..], TermOperator.Exact); + } + + if (raw.Length > 1 && raw[0] == '^') + { + return (raw[1..], TermOperator.Prefix); + } + + if (raw.Length > 1 && raw[^1] == '$') + { + return (raw[..^1], TermOperator.Suffix); + } + + return (raw, TermOperator.Fuzzy); + } + + private static bool AnyFieldContains(BaletteCommand c, string term) + { + var cmp = StringComparison.OrdinalIgnoreCase; + return c.Title.Contains(term, cmp) + || (c.Subtitle?.Contains(term, cmp) ?? false) + || (c.Group?.Contains(term, cmp) ?? false) + || c.Keywords.Any(k => k.Contains(term, cmp)); + } + + private static BaletteFieldMatch ExactMatch(string term, string text, TermOperator op) + { + var cmp = StringComparison.OrdinalIgnoreCase; + int idx = op switch + { + TermOperator.Prefix => text.StartsWith(term, cmp) ? 0 : -1, + TermOperator.Suffix => text.EndsWith(term, cmp) ? text.Length - term.Length : -1, + _ => text.IndexOf(term, cmp), + }; + + if (idx < 0) + { + return BaletteFieldMatch.None; + } + + // Exact matches score like a perfect consecutive fuzzy run plus a boundary bonus. + var score = term.Length * (BaletteFuzzyMatcher.ScoreMatch + BaletteFuzzyMatcher.BonusConsecutive) + (idx == 0 ? BaletteFuzzyMatcher.BonusBoundary * 2 : 0); + return new BaletteFieldMatch(true, score, Enumerable.Range(idx, term.Length).ToArray()); + } +} + +/// A query split into terms with their operators: parsed once per keystroke, matched against every command. +internal sealed class BaletteQuery +{ + public static BaletteQuery Empty { get; } = new([]); + + private BaletteQuery((string Term, BaletteMatcherExtensions.TermOperator Op)[] terms) => Terms = terms; + + public (string Term, BaletteMatcherExtensions.TermOperator Op)[] Terms { get; } + + public static BaletteQuery Parse(string? query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return Empty; + } + + var terms = new List<(string, BaletteMatcherExtensions.TermOperator)>(); + foreach (var raw in query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var (term, op) = BaletteMatcherExtensions.ParseOperator(raw); + if (term.Length > 0) + { + terms.Add((term, op)); + } + } + + return new([.. terms]); + } +} diff --git a/src/Balette/Bit.Balette/Search/IBaletteSource.cs b/src/Balette/Bit.Balette/Search/IBaletteSource.cs new file mode 100644 index 0000000000..e5759010c8 --- /dev/null +++ b/src/Balette/Bit.Balette/Search/IBaletteSource.cs @@ -0,0 +1,126 @@ +namespace Bit.Balette; + +/// +/// Provides commands asynchronously for a query (e.g. search an API for documents, users, issues). +/// Sources are queried with debouncing and cancellation; results are shown under . +/// +public interface IBaletteSource +{ + /// Group heading for results of this source. + string Name { get; } + + /// Minimum query length before the source is asked. 0 queries on an empty box too. + int MinQueryLength => 1; + + /// Upper bound of results taken from this source. + int MaxResults => 20; + + /// + /// When true, returned commands are re-scored and interleaved with registry results by the matcher. + /// When false (default), the source's own order is kept and shown as a block. + /// + bool ApplyMatcher => false; + + /// Search for commands matching . + Task> SearchAsync(string query, BaletteSourceContext context, CancellationToken cancellationToken); +} + +/// Extra information available to sources. +public sealed record BaletteSourceContext(string? ModePrefix, string? ParentCommandId, IServiceProvider Services); + +/// Convenience built from a delegate. +public sealed class BaletteDelegateSource : IBaletteSource +{ + private readonly Func>> _search; + + /// Creates a source from a delegate that takes the query. + public BaletteDelegateSource(string name, Func>> search, int minQueryLength = 1, int maxResults = 20, bool applyMatcher = false) + : this(name, (q, _, ct) => search(q, ct), minQueryLength, maxResults, applyMatcher) + { + } + + /// Creates a source from a delegate that takes the query and the source context. + public BaletteDelegateSource(string name, Func>> search, int minQueryLength = 1, int maxResults = 20, bool applyMatcher = false) + { + Name = name; + _search = search; + MinQueryLength = minQueryLength; + MaxResults = maxResults; + ApplyMatcher = applyMatcher; + } + + /// The group heading of the source's results. + public string Name { get; } + + /// The minimum query length before the source is asked. + public int MinQueryLength { get; } + + /// The most results taken from the source. + public int MaxResults { get; } + + /// Whether results are re-ranked with the registry. + public bool ApplyMatcher { get; } + + /// Runs the delegate. + public Task> SearchAsync(string query, BaletteSourceContext context, CancellationToken cancellationToken) + => _search(query, context, cancellationToken); +} + +/// A source backed by an in-memory list, filtered with the palette's matcher (useful for large static datasets). +public sealed class BaletteStaticSource : IBaletteSource +{ + private readonly Func> _items; + + /// Creates a source over a fixed list. + public BaletteStaticSource(string name, IEnumerable items, int maxResults = 50) + : this(name, () => items, maxResults) + { + } + + /// Creates a source over a list read again on every search. + public BaletteStaticSource(string name, Func> items, int maxResults = 50) + { + Name = name; + _items = items; + MaxResults = maxResults; + } + + /// The group heading of the source's results. + public string Name { get; } + + /// Always 1. + public int MinQueryLength => 1; + + /// The most results returned. + public int MaxResults { get; } + + /// Always true: the list is matched and ranked by the palette's matcher. + public bool ApplyMatcher => true; + + /// + /// Matches the list with the palette's matcher and returns the best of it, best first. + /// The palette keeps only of what a source returns, so handing it the unfiltered list + /// would let only its first items ever be found. + /// + public Task> SearchAsync(string query, BaletteSourceContext context, CancellationToken cancellationToken) + { + var options = context.Services.GetService(typeof(BaletteOptions)) as BaletteOptions; + var matcher = options?.Matcher ?? BaletteFuzzyMatcher.Default; + var weights = options?.Weights ?? new BaletteMatchWeights(); + var parsed = BaletteQuery.Parse(query); + + var matches = new List(); + foreach (var command in _items()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (matcher.Match(command, parsed, weights) is { } match) + { + matches.Add(match); + } + } + + IReadOnlyList best = [.. matches.OrderByDescending(m => m.Score).Take(MaxResults).Select(m => m.Command)]; + return Task.FromResult(best); + } +} diff --git a/src/Balette/Bit.Balette/Services/BaletteService.cs b/src/Balette/Bit.Balette/Services/BaletteService.cs new file mode 100644 index 0000000000..6479953624 --- /dev/null +++ b/src/Balette/Bit.Balette/Services/BaletteService.cs @@ -0,0 +1,330 @@ +namespace Bit.Balette; + +/// Default . One instance per scope (circuit / browser tab). +public sealed class BaletteService : IBaletteService +{ + private readonly object _gate = new(); + private readonly List _registrations = []; + private readonly Dictionary _byId = new(StringComparer.Ordinal); + private readonly List _modes = []; + private readonly List _sources = []; + private readonly BaletteOptions _options; + + public BaletteService(BaletteOptions options) + { + _options = options; + if (options.Commands.Count > 0) + { + Register(options.Commands); + } + + _modes.AddRange(options.Modes); + _sources.AddRange(options.Sources); + } + + public bool IsOpen { get; private set; } + + public bool IsMac { get; private set; } + + public event Action? PlatformDetected; + + internal void SetPlatform(bool isMac) + { + IsMac = isMac; + PlatformDetected?.Invoke(); + } + + public IReadOnlyCollection Commands + { + get + { + lock (_gate) + { + return _byId.Values.ToList(); + } + } + } + + public IReadOnlyCollection Modes + { + get + { + lock (_gate) + { + return _modes.ToList(); + } + } + } + + public IReadOnlyCollection Sources + { + get + { + lock (_gate) + { + return _sources.ToList(); + } + } + } + + public event Action? CommandsChanged; + + public event Action? Opened; + + public event Action? Closed; + + public event EventHandler? CommandExecuted; + + public event EventHandler? CommandFailed; + + /// The component asks the service to open with these arguments (internal plumbing). + internal event Func? OpenRequested; + + internal event Func? CloseRequested; + + /// The palette resolves the id itself: it also knows the commands declared in its markup and passed to it directly. + internal event Func? ExecuteRequested; + + public IDisposable Register(params BaletteCommand[] commands) => Register((IEnumerable)commands); + + public IDisposable Register(IEnumerable commands) + { + var list = Flatten(commands).ToList(); + var reg = new Registration(this, list); + lock (_gate) + { + _registrations.Add(reg); + Rebuild(); + } + + CommandsChanged?.Invoke(); + return reg; + } + + public void Update(BaletteCommand command) + { + lock (_gate) + { + var replaced = false; + foreach (var reg in _registrations) + { + var idx = reg.Commands.FindIndex(c => c.Id == command.Id); + if (idx >= 0) + { + // The old command's static children leave with it, and the new one's are flattened in its place. + var stale = new HashSet(StringComparer.Ordinal); + CollectChildIds(reg.Commands, reg.Commands[idx], command.Id, stale); + var at = idx - reg.Commands.Take(idx).Count(c => stale.Contains(c.Id)); + reg.Commands.RemoveAt(idx); + reg.Commands.RemoveAll(c => stale.Contains(c.Id)); + reg.Commands.InsertRange(at, Flatten([command])); + replaced = true; + } + } + + if (!replaced) + { + _registrations.Add(new Registration(this, Flatten([command]).ToList())); + } + + Rebuild(); + } + + CommandsChanged?.Invoke(); + } + + public bool Unregister(string commandId) + { + bool removed; + lock (_gate) + { + removed = false; + foreach (var reg in _registrations) + { + removed |= reg.Commands.RemoveAll(c => c.Id == commandId) > 0; + } + + if (removed) + { + Rebuild(); + } + } + + if (removed) + { + CommandsChanged?.Invoke(); + } + + return removed; + } + + public BaletteCommand? Find(string commandId) + { + lock (_gate) + { + return _byId.GetValueOrDefault(commandId); + } + } + + public IDisposable RegisterMode(BaletteCommandMode mode) + { + lock (_gate) + { + _modes.RemoveAll(m => m.Prefix == mode.Prefix); + _modes.Add(mode); + } + + CommandsChanged?.Invoke(); + return new ActionDisposable(() => + { + lock (_gate) + { + _modes.Remove(mode); + } + + CommandsChanged?.Invoke(); + }); + } + + public IDisposable RegisterSource(IBaletteSource source) + { + lock (_gate) + { + _sources.Add(source); + } + + CommandsChanged?.Invoke(); + return new ActionDisposable(() => + { + lock (_gate) + { + _sources.Remove(source); + } + + CommandsChanged?.Invoke(); + }); + } + + public Task OpenAsync(string? query = null, string? pageCommandId = null) + => OpenRequested?.Invoke(query, pageCommandId) ?? Task.CompletedTask; + + public Task CloseAsync() => CloseRequested?.Invoke() ?? Task.CompletedTask; + + public Task ToggleAsync() => IsOpen ? CloseAsync() : OpenAsync(); + + public Task ExecuteAsync(string commandId) + { + if (ExecuteRequested is { } execute) + { + return execute(commandId); + } + + _ = Find(commandId) ?? throw new KeyNotFoundException($"Command '{commandId}' is not registered."); + return Task.CompletedTask; + } + + internal void SetOpen(bool open) + { + if (IsOpen == open) + { + return; + } + + IsOpen = open; + if (open) + { + Opened?.Invoke(); + } + else + { + Closed?.Invoke(); + } + } + + internal void RaiseExecuted(BaletteCommand command, BaletteCommandResult result, TimeSpan duration) + => CommandExecuted?.Invoke(this, new BaletteCommandExecutedEventArgs(command, result, duration)); + + internal bool RaiseFailed(BaletteCommand command, Exception ex) + { + var args = new BaletteCommandErrorEventArgs(command, ex); + CommandFailed?.Invoke(this, args); + return args.Handled; + } + + internal BaletteOptions Options => _options; + + /// Flattens static children into the registry with set so nested pages work uniformly. + private static IEnumerable Flatten(IEnumerable commands, string? parent = null) + { + foreach (var c in commands) + { + var cmd = parent is not null && c.Parent is null + ? c.With(b => b.Parent(parent).Id(ChildId(c, parent))) + : c; + yield return cmd; + if (cmd.Children is { Count: > 0 }) + { + foreach (var child in Flatten(cmd.Children, cmd.Id)) + { + yield return child; + } + } + } + } + + /// The id a static child gets once flattened under the command with id . + private static string ChildId(BaletteCommand child, string parentId) + => child.Parent is not null || child.HasExplicitId ? child.Id : BaletteCommandIdGenerator.FromTitle(child.Title, child.Group, parentId); + + /// Collects the ids a command's static children were flattened into, down to the last level. + private static void CollectChildIds(List commands, BaletteCommand parent, string parentId, HashSet ids) + { + foreach (var child in parent.Children ?? []) + { + var id = ChildId(child, parentId); + if (ids.Add(id)) + { + // A child updated since brings children of its own; one no longer stored, the ones it had. + CollectChildIds(commands, commands.Find(c => c.Id == id) ?? child, id, ids); + } + } + } + + private void Rebuild() + { + _byId.Clear(); + foreach (var c in _registrations.SelectMany(r => r.Commands)) + { + _byId[c.Id] = c; // later registrations win, allowing pages to override global commands + } + } + + private void Remove(Registration reg) + { + lock (_gate) + { + if (!_registrations.Remove(reg)) + { + return; + } + + Rebuild(); + } + + CommandsChanged?.Invoke(); + } + + private sealed class Registration(BaletteService owner, List commands) : IDisposable + { + public List Commands { get; } = commands; + + public void Dispose() => owner.Remove(this); + } + + private sealed class ActionDisposable(Action dispose) : IDisposable + { + private Action? _dispose = dispose; + + public void Dispose() => Interlocked.Exchange(ref _dispose, null)?.Invoke(); + } +} diff --git a/src/Balette/Bit.Balette/Services/IBaletteService.cs b/src/Balette/Bit.Balette/Services/IBaletteService.cs new file mode 100644 index 0000000000..98e33d0efe --- /dev/null +++ b/src/Balette/Bit.Balette/Services/IBaletteService.cs @@ -0,0 +1,99 @@ +namespace Bit.Balette; + +/// +/// Scoped service that owns the command registry and the open/closed state of the palette. +/// Inject it anywhere to register commands, open the palette, or react to executions. +/// +public interface IBaletteService +{ + /// Whether a palette component is currently open. + bool IsOpen { get; } + + /// True once the palette has detected a macOS / iOS browser (use it to render ⌘ instead of Ctrl). + bool IsMac { get; } + + /// All registered commands (root and nested). + IReadOnlyCollection Commands { get; } + + /// Registered modes. + IReadOnlyCollection Modes { get; } + + /// Async sources used by the default mode. + IReadOnlyCollection Sources { get; } + + /// Registers commands. Dispose the returned handle to remove them (e.g. in a page's Dispose). + IDisposable Register(params BaletteCommand[] commands); + + /// Registers commands. Dispose the returned handle to remove them. + IDisposable Register(IEnumerable commands); + + /// Replaces a command with the same (or adds it). Useful for toggles whose label changes. + void Update(BaletteCommand command); + + /// Removes a command by id. + bool Unregister(string commandId); + + /// Looks up a command by id across all registrations. + BaletteCommand? Find(string commandId); + + IDisposable RegisterMode(BaletteCommandMode mode); + + IDisposable RegisterSource(IBaletteSource source); + + /// Opens the palette, optionally with an initial query (a mode prefix is honoured) or directly inside a command's page. + Task OpenAsync(string? query = null, string? pageCommandId = null); + + Task CloseAsync(); + + Task ToggleAsync(); + + /// + /// Executes a command programmatically as if selected - one from the registry, or one declared in the palette's + /// markup or passed to its Commands parameter. A command that needs the palette (a page, a confirmation, a + /// prompt) opens it. Throws for an unknown id. + /// + Task ExecuteAsync(string commandId); + + /// Fired whenever the registry changes. + event Action? CommandsChanged; + + event Action? Opened; + + event Action? Closed; + + event EventHandler? CommandExecuted; + + event EventHandler? CommandFailed; + + /// + /// Raised once the palette has detected the browser's platform, so UI that renders shortcut hints from + /// can re-render with ⌘ instead of Ctrl. + /// + event Action? PlatformDetected; +} + +/// Describes a command that ran: and . +public sealed class BaletteCommandExecutedEventArgs(BaletteCommand command, BaletteCommandResult result, TimeSpan duration) : EventArgs +{ + /// The command. + public BaletteCommand Command { get; } = command; + + /// What the handler answered. + public BaletteCommandResult Result { get; } = result; + + /// How long the handler took. + public TimeSpan Duration { get; } = duration; +} + +/// Describes a handler that threw: . +public sealed class BaletteCommandErrorEventArgs(BaletteCommand command, Exception exception) : EventArgs +{ + /// The command. + public BaletteCommand Command { get; } = command; + + /// What the handler threw. + public Exception Exception { get; } = exception; + + /// Set to true to prevent the exception from being rethrown. + public bool Handled { get; set; } +} diff --git a/src/Balette/Bit.Balette/Styles/bit-balette.css b/src/Balette/Bit.Balette/Styles/bit-balette.css new file mode 100644 index 0000000000..82a748a142 --- /dev/null +++ b/src/Balette/Bit.Balette/Styles/bit-balette.css @@ -0,0 +1,456 @@ +/*! + * Bit.Balette default theme. + * + * The design tokens are custom properties on :root, so the trigger button and the shortcut hints that + * live outside the dialog share them with it. Override any of them on :root, on + * dialog[data-balette="dialog"], or inline through the Style parameter: + * + * :root { --bit-balette-accent: #6d28d9; --bit-balette-width: 760px; } + * + * The theme follows prefers-color-scheme, , or the component's Theme + * parameter (data-theme on the dialog). Every component rule is scoped to .bit-balette-dialog, so + * Unstyled="true" renders bare markup that can be styled from scratch through the data-balette + * attributes. No external CSS framework is used or assumed. + */ + +:root { + /* Layout */ + --bit-balette-width: 640px; + --bit-balette-max-height: min(70vh, 560px); + --bit-balette-radius: 14px; + --bit-balette-row-height: 44px; + --bit-balette-gap: 8px; + --bit-balette-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --bit-balette-font-size: 14px; + --bit-balette-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --bit-balette-z: 1000; + + /* Light palette */ + --bit-balette-bg: #ffffff; + --bit-balette-bg-elevated: #f6f7f9; + --bit-balette-fg: #111318; + --bit-balette-fg-muted: #6b7280; + --bit-balette-fg-subtle: #9ca3af; + --bit-balette-border: rgba(17, 19, 24, 0.08); + --bit-balette-accent: #4f46e5; + --bit-balette-accent-fg: #ffffff; + --bit-balette-accent-soft: rgba(79, 70, 229, 0.10); + --bit-balette-active-bg: rgba(17, 19, 24, 0.06); + --bit-balette-danger: #dc2626; + --bit-balette-danger-soft: rgba(220, 38, 38, 0.10); + --bit-balette-mark-bg: rgba(79, 70, 229, 0.18); + --bit-balette-kbd-bg: #f3f4f6; + --bit-balette-kbd-fg: #374151; + --bit-balette-kbd-border: rgba(17, 19, 24, 0.12); + --bit-balette-backdrop: rgba(17, 19, 24, 0.45); + --bit-balette-shadow: 0 24px 64px -12px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(0, 0, 0, 0.04); + --bit-balette-scrollbar: rgba(17, 19, 24, 0.2); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bit-balette-bg: #17181c; + --bit-balette-bg-elevated: #1f2126; + --bit-balette-fg: #f3f4f6; + --bit-balette-fg-muted: #a1a1aa; + --bit-balette-fg-subtle: #71717a; + --bit-balette-border: rgba(255, 255, 255, 0.08); + --bit-balette-accent: #818cf8; + --bit-balette-accent-fg: #0b0c10; + --bit-balette-accent-soft: rgba(129, 140, 248, 0.16); + --bit-balette-active-bg: rgba(255, 255, 255, 0.07); + --bit-balette-danger: #f87171; + --bit-balette-danger-soft: rgba(248, 113, 113, 0.14); + --bit-balette-mark-bg: rgba(129, 140, 248, 0.28); + --bit-balette-kbd-bg: #26282e; + --bit-balette-kbd-fg: #d4d4d8; + --bit-balette-kbd-border: rgba(255, 255, 255, 0.12); + --bit-balette-backdrop: rgba(0, 0, 0, 0.6); + --bit-balette-shadow: 0 24px 64px -12px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.06); + --bit-balette-scrollbar: rgba(255, 255, 255, 0.2); + } +} + +:root[data-theme="dark"], +dialog[data-balette="dialog"][data-theme="dark"] { + --bit-balette-bg: #17181c; + --bit-balette-bg-elevated: #1f2126; + --bit-balette-fg: #f3f4f6; + --bit-balette-fg-muted: #a1a1aa; + --bit-balette-fg-subtle: #71717a; + --bit-balette-border: rgba(255, 255, 255, 0.08); + --bit-balette-accent: #818cf8; + --bit-balette-accent-fg: #0b0c10; + --bit-balette-accent-soft: rgba(129, 140, 248, 0.16); + --bit-balette-active-bg: rgba(255, 255, 255, 0.07); + --bit-balette-danger: #f87171; + --bit-balette-danger-soft: rgba(248, 113, 113, 0.14); + --bit-balette-mark-bg: rgba(129, 140, 248, 0.28); + --bit-balette-kbd-bg: #26282e; + --bit-balette-kbd-fg: #d4d4d8; + --bit-balette-kbd-border: rgba(255, 255, 255, 0.12); + --bit-balette-backdrop: rgba(0, 0, 0, 0.6); + --bit-balette-shadow: 0 24px 64px -12px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.06); + --bit-balette-scrollbar: rgba(255, 255, 255, 0.2); +} + +dialog[data-balette="dialog"][data-theme="light"] { + --bit-balette-bg: #ffffff; + --bit-balette-bg-elevated: #f6f7f9; + --bit-balette-fg: #111318; + --bit-balette-fg-muted: #6b7280; + --bit-balette-fg-subtle: #9ca3af; + --bit-balette-border: rgba(17, 19, 24, 0.08); + --bit-balette-accent: #4f46e5; + --bit-balette-accent-fg: #ffffff; + --bit-balette-accent-soft: rgba(79, 70, 229, 0.10); + --bit-balette-active-bg: rgba(17, 19, 24, 0.06); + --bit-balette-danger: #dc2626; + --bit-balette-danger-soft: rgba(220, 38, 38, 0.10); + --bit-balette-mark-bg: rgba(79, 70, 229, 0.18); + --bit-balette-kbd-bg: #f3f4f6; + --bit-balette-kbd-fg: #374151; + --bit-balette-kbd-border: rgba(17, 19, 24, 0.12); + --bit-balette-backdrop: rgba(17, 19, 24, 0.45); + --bit-balette-shadow: 0 24px 64px -12px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(0, 0, 0, 0.04); + --bit-balette-scrollbar: rgba(17, 19, 24, 0.2); +} + +/* ---------- Dialog shell ---------- */ + +.bit-balette-dialog { + position: fixed; + inset: 0; + margin: 0 auto; + top: 12vh; + width: min(var(--bit-balette-width), calc(100vw - 32px)); + max-width: calc(100vw - 32px); + padding: 0; + border: 0; + border-radius: var(--bit-balette-radius); + background: var(--bit-balette-bg); + color: var(--bit-balette-fg); + box-shadow: var(--bit-balette-shadow); + font-family: var(--bit-balette-font); + font-size: var(--bit-balette-font-size); + line-height: 1.4; + overflow: hidden; + z-index: var(--bit-balette-z); + height: fit-content; + max-height: calc(100vh - 24vh); +} + +.bit-balette-dialog[data-has-detail="true"] { + --bit-balette-width: 920px; +} + +.bit-balette-dialog::backdrop { + background: var(--bit-balette-backdrop, rgba(17, 19, 24, 0.45)); /* ::backdrop does not inherit custom properties in older browsers */ + -webkit-backdrop-filter: blur(2px); + backdrop-filter: blur(2px); +} + +.bit-balette-dialog[open] { + animation: bit-balette-pop 140ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.bit-balette-dialog[open]::backdrop { + animation: bit-balette-fade 140ms ease-out; +} + +@keyframes bit-balette-pop { + from { opacity: 0; transform: translateY(-6px) scale(0.98); } + to { opacity: 1; transform: none; } +} + +@keyframes bit-balette-fade { + from { opacity: 0; } + to { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .bit-balette-dialog[open], .bit-balette-dialog[open]::backdrop { animation: none; } + .bit-balette-dialog .bit-balette-spinner { animation-duration: 1.5s; } +} + +html.bit-balette-scroll-lock { overflow: hidden; } + +.bit-balette-dialog .bit-balette-root { display: flex; min-height: 0; } +.bit-balette-dialog .bit-balette-main { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; max-height: var(--bit-balette-max-height); } + +/* ---------- Header ---------- */ + +.bit-balette-dialog .bit-balette-header { display: flex; flex-direction: column; flex: none; border-bottom: 1px solid var(--bit-balette-border); } + +.bit-balette-dialog .bit-balette-breadcrumbs { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 14px 0; + flex-wrap: wrap; +} + +.bit-balette-dialog .bit-balette-crumb { + display: inline-flex; + align-items: center; + gap: 4px; + height: 22px; + padding: 0 8px; + border-radius: 6px; + background: var(--bit-balette-bg-elevated); + color: var(--bit-balette-fg-muted); + font-size: 12px; + font-weight: 500; + border: 0; + font-family: inherit; +} + +.bit-balette-dialog .bit-balette-crumb-home { cursor: pointer; padding: 0 6px; } +.bit-balette-dialog .bit-balette-crumb-home:hover { color: var(--bit-balette-fg); } +.bit-balette-dialog .bit-balette-mode-chip { background: var(--bit-balette-accent-soft); color: var(--bit-balette-accent); } + +.bit-balette-dialog .bit-balette-input-row { display: flex; align-items: center; gap: 10px; padding: 0 14px; height: 56px; } +.bit-balette-dialog .bit-balette-input-icon { display: inline-flex; color: var(--bit-balette-fg-muted); flex: none; width: 18px; justify-content: center; } + +.bit-balette-dialog .bit-balette-input { + flex: 1 1 auto; + min-width: 0; + height: 100%; + border: 0; + outline: 0; + background: transparent; + color: var(--bit-balette-fg); + font: inherit; + font-size: 16px; +} + +.bit-balette-dialog .bit-balette-input::placeholder { color: var(--bit-balette-fg-subtle); } + +.bit-balette-dialog .bit-balette-selected-count { font-size: 12px; color: var(--bit-balette-accent); font-weight: 600; white-space: nowrap; } + +.bit-balette-dialog .bit-balette-close { + display: inline-flex; + align-items: center; + border: 0; + background: transparent; + padding: 0; + cursor: pointer; + color: inherit; + font: inherit; +} + +/* ---------- List ---------- */ + +.bit-balette-dialog .bit-balette-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--bit-balette-gap); + scroll-padding-block: 40px 8px; + scrollbar-width: thin; + scrollbar-color: var(--bit-balette-scrollbar) transparent; +} + +.bit-balette-dialog .bit-balette-group-heading { + position: sticky; + top: calc(-1 * var(--bit-balette-gap)); + z-index: 1; + padding: 10px 10px 6px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--bit-balette-fg-subtle); + background: var(--bit-balette-bg); +} + +.bit-balette-dialog .bit-balette-item { + display: flex; + align-items: center; + gap: 12px; + min-height: var(--bit-balette-row-height); + padding: 6px 10px; + border-radius: 8px; + cursor: pointer; + user-select: none; + -webkit-user-select: none; + color: var(--bit-balette-fg); + scroll-margin-block: 36px 4px; +} + +.bit-balette-dialog .bit-balette-item[data-active="true"] { background: var(--bit-balette-active-bg); } +.bit-balette-dialog .bit-balette-item[data-active="true"] .bit-balette-item-icon { color: var(--bit-balette-fg); } +.bit-balette-dialog .bit-balette-item[data-disabled="true"] { opacity: 0.5; cursor: not-allowed; } +.bit-balette-dialog .bit-balette-item[data-destructive="true"] { color: var(--bit-balette-danger); } +.bit-balette-dialog .bit-balette-item[data-destructive="true"] .bit-balette-item-icon { color: var(--bit-balette-danger); } +.bit-balette-dialog .bit-balette-item[data-destructive="true"][data-active="true"] { background: var(--bit-balette-danger-soft); } +.bit-balette-dialog .bit-balette-item[data-confirming="true"] { outline: 2px solid var(--bit-balette-danger); outline-offset: -2px; } +.bit-balette-dialog .bit-balette-item[data-checked="true"] .bit-balette-check { color: var(--bit-balette-accent); } + +.bit-balette-dialog .bit-balette-check { display: inline-flex; width: 16px; height: 16px; flex: none; border: 1.5px solid var(--bit-balette-fg-subtle); border-radius: 4px; align-items: center; justify-content: center; } +.bit-balette-dialog .bit-balette-item[data-checked="true"] .bit-balette-check { border-color: var(--bit-balette-accent); background: var(--bit-balette-accent-soft); } + +.bit-balette-dialog .bit-balette-item-icon { display: inline-flex; flex: none; width: 18px; height: 18px; align-items: center; justify-content: center; color: var(--bit-balette-fg-muted); } +.bit-balette-dialog .bit-balette-item-icon i { font-size: 16px; } + +.bit-balette-dialog .bit-balette-item-body { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; } +.bit-balette-dialog .bit-balette-item-title { display: flex; align-items: center; gap: 8px; min-width: 0; white-space: nowrap; overflow: hidden; font-weight: 500; } +.bit-balette-dialog .bit-balette-item-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.bit-balette-dialog .bit-balette-item-subtitle { font-size: 12px; color: var(--bit-balette-fg-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.bit-balette-dialog .bit-balette-keyword-hit::before { content: "↳ "; opacity: 0.6; } +.bit-balette-dialog .bit-balette-confirm-label { color: var(--bit-balette-danger); font-weight: 600; } + +.bit-balette-dialog .bit-balette-mark { background: var(--bit-balette-mark-bg); color: inherit; border-radius: 2px; padding: 0 1px; margin: 0 -1px; } + +.bit-balette-dialog .bit-balette-badge { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 6px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + background: var(--bit-balette-accent-soft); + color: var(--bit-balette-accent); + flex: none; +} + +.bit-balette-dialog .bit-balette-pin { display: inline-flex; color: var(--bit-balette-fg-subtle); flex: none; } +.bit-balette-dialog .bit-balette-pin .bit-balette-svg { width: 12px; height: 12px; } + +.bit-balette-dialog .bit-balette-item-trailing { display: inline-flex; align-items: center; gap: 8px; flex: none; color: var(--bit-balette-fg-muted); } +.bit-balette-dialog .bit-balette-value { font-size: 12px; color: var(--bit-balette-fg-muted); } +.bit-balette-dialog .bit-balette-chevron { display: inline-flex; color: var(--bit-balette-fg-subtle); } + +.bit-balette-dialog .bit-balette-toggle { position: relative; width: 28px; height: 16px; border-radius: 999px; background: var(--bit-balette-kbd-border); transition: background 120ms; } +.bit-balette-dialog .bit-balette-toggle::after { content: ""; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: #fff; transition: transform 120ms; } +.bit-balette-dialog .bit-balette-toggle[data-on="true"] { background: var(--bit-balette-accent); } +.bit-balette-dialog .bit-balette-toggle[data-on="true"]::after { transform: translateX(12px); } + +/* ---------- Shortcut / kbd ---------- */ + +.bit-balette-shortcut { display: inline-flex; align-items: center; gap: 3px; } +.bit-balette-shortcut-then { font-size: 10px; color: var(--bit-balette-fg-subtle); margin: 0 2px; } + +.bit-balette-kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 5px; + border-radius: 5px; + background: var(--bit-balette-kbd-bg); + color: var(--bit-balette-kbd-fg); + border: 1px solid var(--bit-balette-kbd-border); + border-bottom-width: 2px; + font-family: var(--bit-balette-font); + font-size: 11px; + font-weight: 500; + line-height: 1; +} + +/* ---------- Empty / loading / prompt ---------- */ + +.bit-balette-dialog .bit-balette-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; padding: 40px 16px; color: var(--bit-balette-fg-muted); text-align: center; } +.bit-balette-dialog .bit-balette-empty .bit-balette-svg { width: 28px; height: 28px; opacity: 0.5; margin-bottom: 4px; } +.bit-balette-dialog .bit-balette-empty p { margin: 0; } +.bit-balette-dialog .bit-balette-empty-title { color: var(--bit-balette-fg); font-weight: 500; } +.bit-balette-dialog .bit-balette-empty-hint { font-size: 12px; } + +.bit-balette-dialog .bit-balette-prompt { padding: 28px 16px; text-align: center; color: var(--bit-balette-fg-muted); } +.bit-balette-dialog .bit-balette-prompt-title { margin: 0 0 8px; color: var(--bit-balette-fg); font-weight: 500; } +.bit-balette-dialog .bit-balette-prompt-hint { margin: 0; font-size: 12px; display: flex; align-items: center; justify-content: center; gap: 6px; } +.bit-balette-dialog .bit-balette-sep { opacity: 0.5; } + +.bit-balette-dialog .bit-balette-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid var(--bit-balette-border); + border-top-color: var(--bit-balette-accent); + border-radius: 50%; + animation: bit-balette-spin 700ms linear infinite; +} + +@keyframes bit-balette-spin { to { transform: rotate(360deg); } } + +/* ---------- Footer ---------- */ + +.bit-balette-dialog .bit-balette-footer { + display: flex; + flex: none; + align-items: center; + gap: 14px; + height: 40px; + padding: 0 14px; + border-top: 1px solid var(--bit-balette-border); + background: var(--bit-balette-bg-elevated); + color: var(--bit-balette-fg-muted); + font-size: 12px; + overflow: hidden; + white-space: nowrap; +} + +.bit-balette-dialog .bit-balette-hint { display: inline-flex; align-items: center; gap: 5px; min-width: 0; overflow: hidden; } +.bit-balette-dialog .bit-balette-hint-right { margin-left: auto; flex: none; padding-left: 8px; } +.bit-balette-dialog .bit-balette-toast { color: var(--bit-balette-fg); font-weight: 500; overflow: hidden; text-overflow: ellipsis; } +.bit-balette-dialog .bit-balette-toast-confirm { color: var(--bit-balette-danger); } + +/* ---------- Detail pane ---------- */ + +.bit-balette-dialog .bit-balette-detail { + flex: 0 0 40%; + max-height: var(--bit-balette-max-height); + overflow: auto; + border-left: 1px solid var(--bit-balette-border); + padding: 16px; + background: var(--bit-balette-bg-elevated); + color: var(--bit-balette-fg-muted); + font-size: 13px; +} + +/* ---------- Trigger button ---------- */ + +.bit-balette-trigger { + display: inline-flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 10px 0 12px; + border-radius: 8px; + border: 1px solid var(--bit-balette-kbd-border, rgba(0, 0, 0, 0.12)); + background: var(--bit-balette-kbd-bg, #f3f4f6); + color: var(--bit-balette-fg-muted, #6b7280); + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.bit-balette-trigger:hover { color: var(--bit-balette-fg, #111); } +.bit-balette-trigger-label { flex: 1 1 auto; text-align: left; } +.bit-balette-trigger-shortcut { margin-left: 8px; } + +/* ---------- Utilities ---------- */ + +.bit-balette-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.bit-balette-svg { flex: none; } + +/* ---------- Mobile ---------- */ + +@media (max-width: 640px) { + .bit-balette-dialog { + top: 0; + width: 100vw; + max-width: 100vw; + height: 100dvh; + max-height: 100dvh; + border-radius: 0; + } + .bit-balette-dialog .bit-balette-main { max-height: 100dvh; height: 100dvh; } + .bit-balette-dialog .bit-balette-root { flex-direction: column; } + .bit-balette-dialog .bit-balette-detail { display: none; } + .bit-balette-dialog .bit-balette-footer .bit-balette-hint:not(.bit-balette-hint-right) { display: none; } + .bit-balette-dialog .bit-balette-item-trailing .bit-balette-shortcut { display: none; } +} diff --git a/src/Balette/Bit.Balette/_Imports.razor b/src/Balette/Bit.Balette/_Imports.razor new file mode 100644 index 0000000000..ebc2004a2d --- /dev/null +++ b/src/Balette/Bit.Balette/_Imports.razor @@ -0,0 +1,4 @@ +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Bit.Balette diff --git a/src/Balette/Bit.Balette/package-lock.json b/src/Balette/Bit.Balette/package-lock.json new file mode 100644 index 0000000000..e1a590e459 --- /dev/null +++ b/src/Balette/Bit.Balette/package-lock.json @@ -0,0 +1,511 @@ +{ + "name": "Bit.Balette", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "esbuild": "0.28.2", + "typescript": "5.9.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/src/Balette/Bit.Balette/package.json b/src/Balette/Bit.Balette/package.json new file mode 100644 index 0000000000..9d770fb1e8 --- /dev/null +++ b/src/Balette/Bit.Balette/package.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "esbuild": "0.28.2", + "typescript": "5.9.3" + } +} diff --git a/src/Balette/Bit.Balette/tsconfig.json b/src/Balette/Bit.Balette/tsconfig.json new file mode 100644 index 0000000000..8e2be6e1df --- /dev/null +++ b/src/Balette/Bit.Balette/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "allowJs": true, + "target": "ES2019", + // bit-balette.js is loaded by the C# interop through a dynamic `import()` and its + // functions are called as named module exports, so the output must stay an ES module: + // `module: ESNext` with `rootDir`/`outDir` emits Scripts/bit-balette.ts straight to + // wwwroot/bit-balette.js with its `export`s preserved. + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": [ "DOM", "DOM.Iterable", "ESNext" ], + "rootDir": "Scripts", + "outDir": "wwwroot", + "strict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ "./Scripts/**/*.ts" ] +} diff --git a/src/Balette/Demo/Client/Bit.Balette.Demo.Client.csproj b/src/Balette/Demo/Client/Bit.Balette.Demo.Client.csproj new file mode 100644 index 0000000000..b847f9a51b --- /dev/null +++ b/src/Balette/Demo/Client/Bit.Balette.Demo.Client.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + true + + true + + Default + true + + + + + + + + + + + diff --git a/src/Balette/Demo/Client/Extensions/IServiceCollectionExtensions.cs b/src/Balette/Demo/Client/Extensions/IServiceCollectionExtensions.cs new file mode 100644 index 0000000000..b0159c5cf3 --- /dev/null +++ b/src/Balette/Demo/Client/Extensions/IServiceCollectionExtensions.cs @@ -0,0 +1,78 @@ +using Bit.Balette; +using Bit.Balette.Demo.Client.Services; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// The services shared by the WebAssembly client and the server host. Both containers have to +/// register the same set: every page is prerendered on the server before it runs in the browser, +/// so anything a page injects must resolve in both. +/// +public static class IServiceCollectionExtensions +{ + public static IServiceCollection AddDemoServices(this IServiceCollection services) + { + // The sources are created here, once, with the options; the log they report to has to exist before them. + var sourceLog = new SourceLog(); + services.AddSingleton(sourceLog); + + services.AddBitBaletteServices(options => + { + // Ctrl+K / ⌘K is the default; Ctrl+Shift+P is there for hands trained on VS Code. + options.AdditionalToggleShortcuts.Add("Mod+Shift+P"); + + // Groups appear in this order on the empty query; the ones not listed follow alphabetically. + options.GroupOrder.AddRange(["Navigation", "Actions", "Preferences", "Danger zone", "Help"]); + + // Ctrl+J / Ctrl+K move through the list as well as the arrows and Ctrl+N / Ctrl+P. + options.VimBindings = true; + + // Modes are typed prefixes, as in VS Code and GitHub. Each one can narrow the registry, add its own + // sources, or both. + options.Modes.Add(new BaletteCommandMode + { + Prefix = ">", + Name = "Commands", + Description = "Run any command", + Placeholder = "Run a command…", + Tags = [DemoCommands.CommandTag], + }); + options.Modes.Add(new BaletteCommandMode + { + Prefix = "#", + Name = "Issues", + Description = "Search issues by number or title", + Placeholder = "Search issues…", + HideRegistryCommands = true, + Sources = [new IssueSource(sourceLog)], + }); + options.Modes.Add(new BaletteCommandMode + { + Prefix = "@", + Name = "People", + Description = "Find a teammate", + Placeholder = "Search people…", + HideRegistryCommands = true, + Sources = [new PeopleSource(sourceLog)], + }); + options.Modes.Add(new BaletteCommandMode + { + Prefix = "?", + Name = "Help", + Description = "Keyboard shortcuts and docs", + Groups = ["Help"], + }); + + // In the default mode the documentation is searched too, next to the registry: debounced, cancelled + // when the query moves on, and re-ranked with the commands. + options.Sources.Add(new DocsSource(sourceLog)); + }); + + // What the commands act on, and the directory the sources and the lazy "Assign to…" page read from. Neither + // touches JavaScript until a command runs, so resolving them while prerendering is harmless. + services.AddScoped(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Balette/Demo/Client/Layout/MainLayout.razor b/src/Balette/Demo/Client/Layout/MainLayout.razor new file mode 100644 index 0000000000..fcf311f1b3 --- /dev/null +++ b/src/Balette/Demo/Client/Layout/MainLayout.razor @@ -0,0 +1,134 @@ +@inherits LayoutComponentBase +@implements IDisposable +@inject DemoState State +@inject IBaletteService Palette + +@* data-interactive turns true once the page runs in the browser. Prerendered markup is on screen + long before its event handlers are, which is what the E2E suite waits out with it. data-palette-ready + follows once the palette's browser layer listens for keys (it reports the platform as it starts): a + Ctrl+K pressed between the two reaches no listener at all, so the suite waits for both. *@ +
+ + +
+
+ @* The trigger reads the toggle shortcut from the options, so its hint is always the real one, + in the platform's own symbols once the palette has detected it. *@ + +
+ + + +
+
+
+ @Body +
+
+
+ +@* The app-wide commands. A new list is handed over whenever the demo state changes, and BaletteCommands + swaps the registration, so a toggle's switch and a setting's value are never stale. *@ + + +@* One palette for the whole app. Everything it is passed here comes from DemoState, which the theming, + search and performance pages change, so they restyle this very palette rather than a copy of it. *@ + + @* Declarative commands live next to the markup that owns them. An item with nested items becomes a page. *@ + + + + + + + + + + +@code { + private List _globalCommands = []; + private RenderFragment? _detailPane; + private bool _paletteReady; + + protected override void OnInitialized() + { + _globalCommands = [.. DemoCommands.Global(State)]; + State.Changed += OnStateChanged; + // The detail pane's shortcut hint reads IsMac, which is only known once the palette has started. + Palette.PlatformDetected += OnPlatformDetected; + + // A Raycast-style preview of the active row. It is a template like any other, so it can show whatever a + // command carries - here the Person record the people source attaches as Data. + _detailPane = command => + @
+
@command.Kind
+

@command.Title

+ @if (command.Subtitle is not null) + { +

@command.Subtitle

+ } + @if (command.Description is not null) + { +

@command.Description

+ } + @if (command.Shortcut is not null) + { +

Shortcut:

+ } + @if (command.Keywords.Count > 0) + { +

Also matches: @string.Join(", ", command.Keywords)

+ } + @if (command.Data is Person person) + { +
+
Role
@person.Role
+
E-mail
@person.Email
+
+ } + @command.Id +
; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // The inline script in App.razor applied a stored theme before the first paint; this tells the state. + if (firstRender) + { + await State.RestoreThemeAsync(); + } + } + + private void OnStateChanged() + { + _globalCommands = [.. DemoCommands.Global(State)]; + _ = InvokeAsync(StateHasChanged); + } + + // The component's own callback sees every execution - declarative items included - with how long the + // handler took. The pages listen to IBaletteService.CommandExecuted for the same thing. + private void OnExecuted(BaletteCommandExecutedEventArgs e) + => Console.WriteLine($"[balette] {e.Command.Id} -> {e.Result.GetType().Name} in {e.Duration.TotalMilliseconds:F0} ms"); + + private void OnPlatformDetected() + { + _paletteReady = true; + _ = InvokeAsync(StateHasChanged); + } + + public void Dispose() + { + State.Changed -= OnStateChanged; + Palette.PlatformDetected -= OnPlatformDetected; + } +} diff --git a/src/Balette/Demo/Client/Layout/NavMenu.razor b/src/Balette/Demo/Client/Layout/NavMenu.razor new file mode 100644 index 0000000000..496825893e --- /dev/null +++ b/src/Balette/Demo/Client/Layout/NavMenu.razor @@ -0,0 +1,31 @@ + diff --git a/src/Balette/Demo/Client/Pages/Commands.razor b/src/Balette/Demo/Client/Pages/Commands.razor new file mode 100644 index 0000000000..30938aa3da --- /dev/null +++ b/src/Balette/Demo/Client/Pages/Commands.razor @@ -0,0 +1,239 @@ +@page "/commands" +@implements IDisposable +@inject IBaletteService Palette +@inject DemoState State + +Registering commands - bit Balette + +

Registering commands

+

+ A command is an immutable record: a title, where it is listed, what it matches and what it does. There are three + ways to hand one to the palette, and this app uses all of them at once - the palette merges them into one list and + nothing in it says where a row came from. +

+ +@* Exists only while this page is rendered: leave the page and the three counter commands are gone from the + palette, shortcuts included (kbar calls this useRegisterActions). *@ + + +
+
+

Page-scoped commands

+

Counter: @_count

+

+ Open the palette and look for Increment counter and Decrement counter under "This page", + or press Ctrl ↑ / Ctrl ↓ with the palette closed. Both keep the + palette open, so the value can be stepped from inside it. +

+
+ + +
+
+ +
+

The programmatic API

+

+ IBaletteService is the palette's remote control: register, update, find, open, close and execute + from anywhere that can inject it. +

+
+ + + +
+

+ Executed this visit: @_executed · Last: @(_lastExecuted ?? "nothing yet") + · The palette is @(Palette.IsOpen ? "open" : "closed") + · The registry holds @Palette.Commands.Count commands +

+
+
+ +

The fluent builder

+

+ Every command in this app's Navigation, Actions, Preferences, Danger zone and + Help groups is built in Services/DemoCommands.cs and registered once, from the layout. + BaletteCmd is the entry point; a builder converts to a command on its own, so a list of them needs no + Build() calls. +

+
@BuilderExample
+ +

Declarative markup

+

+ Commands can live in markup next to the component that owns them. A BaletteItem inside a + BaletteItem turns the outer one into a page, and a BaletteGroup hands its heading and + priority to every item in it. The Quick create group of this app is declared like this in + Layout/MainLayout.razor: +

+
@MarkupExample
+
+ +
+ +

Scoped to a page

+

+ BaletteCommands registers its list when it renders and removes it when it is disposed, and hands a + new registration over when it is passed a different list. IBaletteService.Register does the same from + code and returns the handle to dispose. +

+
@ScopedExample
+ +

Settings that stay in sync

+

+ These values are what the palette's Preferences commands change. Change one here and open the palette: + the toggle shows it. Change it there and this page shows it. The layout rebuilds its command list from + DemoState whenever it changes, so a toggle's switch is never stale. +

+ +
+ + + + +
+ +
+ Rebuilding the whole list is the simplest way to keep it current, and cheap: it is a few dozen records. + IBaletteService.Update(command) replaces a single command by id instead, which suits a registry + too large to rebuild on every change. +
+ +@code { + private const string BuilderExample = """ + BaletteCmd.Navigate("Settings", "/commands#settings") + .Group("Navigation") + .Shortcut("G S") + .Keywords("preferences", "options", "config"), + + BaletteCmd.New("Word wrap") + .Id("toggle:wordwrap") + .Group("Preferences") + .Shortcut("Alt+Z") + .Checked(state.WordWrap) + .Handler(_ => + { + state.ToggleWordWrap(); + return Task.FromResult(BaletteCommandResult.KeepOpen); + }), + """; + + private const string MarkupExample = """ + + + + + + + + + + + """; + + private const string ScopedExample = """ + + + @code { + private int _count; + private List _pageCommands = []; + + protected override void OnInitialized() => _pageCommands = + [ + BaletteCmd.New("Increment counter") + .Group("This page") + .Shortcut("Ctrl+Up") + .Handler(_ => + { + _count++; + StateHasChanged(); + return Task.FromResult(BaletteCommandResult.KeepOpen); + }), + ]; + } + """; + + private static readonly BaletteKeyboardShortcut WordWrapShortcut = "Alt+Z"; + + private int _count; + private int _executed; + private string? _lastExecuted; + private List _pageCommands = []; + + protected override void OnInitialized() + { + _pageCommands = + [ + BaletteCmd.New("Increment counter").Group("This page").Shortcut("Ctrl+Up").Keywords("plus", "add") + .Description("Registered by Pages/Commands.razor; gone when you leave the page.") + .Handler(_ => Step(+1)), + BaletteCmd.New("Decrement counter").Group("This page").Shortcut("Ctrl+Down").Keywords("minus", "subtract") + .Description("Registered by Pages/Commands.razor; gone when you leave the page.") + .Handler(_ => Step(-1)), + BaletteCmd.New("Reset counter").Group("This page").Keywords("zero", "clear") + .Destructive("Reset the counter to zero?") + .Handler(_ => + { + _count = 0; + Changed(); + return Task.FromResult(BaletteCommandResult.Close); + }), + ]; + + State.Changed += Refresh; + Palette.CommandExecuted += OnExecuted; + Palette.CommandsChanged += Refresh; + Palette.Opened += Refresh; + Palette.Closed += Refresh; + } + + private Task Step(int delta) + { + _count += delta; + Changed(); + return Task.FromResult(BaletteCommandResult.KeepOpen); + } + + private void Changed() + { + State.Record($"Counter is now {_count}"); + Refresh(); + } + + private void OnExecuted(object? sender, BaletteCommandExecutedEventArgs e) + { + _executed++; + _lastExecuted = e.Command.Title; + Refresh(); + } + + private void Refresh() => _ = InvokeAsync(StateHasChanged); + + private Task OnThemeChanged(ChangeEventArgs e) => State.SetThemeAsync(e.Value?.ToString() ?? "auto"); + + public void Dispose() + { + State.Changed -= Refresh; + Palette.CommandExecuted -= OnExecuted; + Palette.CommandsChanged -= Refresh; + Palette.Opened -= Refresh; + Palette.Closed -= Refresh; + } +} diff --git a/src/Balette/Demo/Client/Pages/Home.razor b/src/Balette/Demo/Client/Pages/Home.razor new file mode 100644 index 0000000000..e0f5d658e2 --- /dev/null +++ b/src/Balette/Demo/Client/Pages/Home.razor @@ -0,0 +1,155 @@ +@page "/" +@implements IDisposable +@inject IBaletteService Palette +@inject DemoState State + +bit Balette - a command palette for Blazor + +

bit Balette

+

+ A keyboard-first command palette for Blazor. Press Ctrl K (⌘ K on a + Mac) anywhere on this site. Fuzzy search, nested pages, modes, async sources, recents, global shortcuts, + multi-select, prompts and a detail pane, with full keyboard and screen-reader support. Matching, ranking and every + decision run in C#; JavaScript only owns the dialog, focus and keys. +

+ +
+ + + + + @* Opens straight inside a command's page: the id is the one the builder derived from the group and the title. *@ + +
+ +
+
+

Try these

+
    +
  • Type rc - fuzzy matching jumps to Registering commands, with the word starts highlighted.
  • +
  • Type theme and press Enter - a nested page; Backspace on an empty box goes back.
  • +
  • Type > for commands only, #105 to look up an issue, @@ada to search people (async).
  • +
  • Type doc theming - both terms must match, and the async documentation source answers too.
  • +
  • Pick Set labels… - a multi-select page; Ctrl Enter applies.
  • +
  • Pick Rename project… - a free-text prompt step.
  • +
  • Pick Delete project - destructive, so it needs a second Enter.
  • +
  • Press Tab on any row - the action panel: pin, forget, and the row's own actions.
  • +
  • Close the palette and press G then S - a sequence shortcut opens the settings.
  • +
  • Try the operators 'exact, ^prefix, suffix$ and !exclude.
  • +
+
+ +
+

Activity

+

+ Project: @State.ProjectName · Labels: @(State.Labels.Count == 0 ? "none" : string.Join(", ", State.Labels)) + · Theme: @State.Theme +

+ @if (State.Log.Count == 0) + { +

Run a command and it shows up here.

+ } + else + { +
    + @foreach (var entry in State.Log) + { +
  • @entry
  • + } +
+ } +
+
+ +

What it does

+ + +

How it is built

+
Key press (browser)
+  -> bit-balette.js     toggle, global chords and G-then-X sequences, navigation keys
+  -> Balette            query, mode prefix, page stack, active row     (C#)
+  -> IBaletteMatcher    fzf-style score and highlight positions per field
+  -> BaletteRanker      score + priority + frecency, the best MaxResults kept
+  -> rows               groups, pinned, recents, async source blocks
+  -> <dialog>          native top layer, focus trap, aria-activedescendant
+ +

+ The browser layer is small on purpose: it decides which keys belong to the palette and hands them over. What + matches, in which order, what Enter does and which page comes next are all decided in C#, so they can be unit + tested, replaced (IBaletteMatcher, IBaletteHistoryStore, IBaletteSource) + and reasoned about like the rest of the app. +

+ +
+ This site runs on WebAssembly, so the whole search runs in the browser next to the list it fills. The same + component works on Blazor Server, where a keystroke is one round trip; it searches synchronously there instead of + deferring to the next frame, because the network already separates typing from rendering. +
+ +

Get started

+
dotnet add package Bit.Balette
+
// Program.cs - in both projects of a Blazor Web App
+builder.Services.AddBitBaletteServices();
+
<!-- App.razor (or index.html): the stylesheet is linked by the host page -->
+<link rel="stylesheet" href="_content/Bit.Balette/bit-balette.css" />
+
@@* MainLayout.razor *@@
+@@using Bit.Balette
+
+<BaletteTrigger />
+<BaletteCommands Commands="_commands" />
+<Balette />
+
+@@code {
+    private readonly BaletteCommand[] _commands =
+    [
+        BaletteCmd.Navigate("Home", "/").Shortcut("G H"),
+        BaletteCmd.Action("Say hello", () => Console.WriteLine("Hello")),
+    ];
+}
+

+ The stylesheet is not added for you: linking it yourself puts it before your own overrides in the cascade, and + leaving it out is how Unstyled starts from bare markup. The source code, the documentation and the + code of this demo are on + GitHub. +

+ +@code { + protected override void OnInitialized() => State.Changed += Refresh; + + private void Refresh() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() => State.Changed -= Refresh; +} diff --git a/src/Balette/Demo/Client/Pages/Keyboard.razor b/src/Balette/Demo/Client/Pages/Keyboard.razor new file mode 100644 index 0000000000..bdfcc0d6b9 --- /dev/null +++ b/src/Balette/Demo/Client/Pages/Keyboard.razor @@ -0,0 +1,153 @@ +@page "/keyboard" +@implements IDisposable +@inject IBaletteService Palette +@inject BaletteOptions Options + +Keyboard and shortcuts - bit Balette + +

Keyboard & shortcuts

+

+ The palette is built to be driven without a mouse. Inside it every key has a job; outside it, any command can + carry a global shortcut - a chord such as @Mod / or a sequence such as G then + S - that runs it with the palette closed. Shortcuts are written once, as text, and displayed in the + platform's own symbols. +

+ +

Inside the palette

+ + + + + + + + + + + + + + + + + + + +
KeysDoes
+ @foreach (var toggle in ToggleShortcuts) + { + + } + Open or close (the toggle shortcuts of this app)
↑ ↓, Ctrl N / P, Ctrl J / KMove, wrapping around at either end (J/K with VimBindings)
Home End PgUp PgDnJump to the first, the last, or ten rows on
EnterRun, open a page, flip a toggle, or check a row on a multi-select page
@Mod EnterApply a multi-select page, or run the row's first secondary action
Tab, → (empty box)Open the action panel of the active row
Backspace, ← (empty box)Back one page
EscCancel a confirmation, then go back, then close
@Mod Shift PPin or unpin the active row
Shift DelForget the active row from the recents
+ +

Global shortcuts in this app

+

+ Every command registered with a global shortcut, read from the registry right now - leave a page and its own + shortcuts disappear from this table, because they disappear from the palette. Close the palette and press one. +

+
+ + + + + + @foreach (var command in Palette.Commands.Where(c => c.Shortcut is not null && c.GlobalShortcut).OrderBy(c => c.Shortcut!.IsSequence).ThenBy(c => c.Group)) + { + + + + + + + } + + + + + + + +
ShortcutCommandGroupKind
@command.Title@command.Group@(command.Shortcut!.IsSequence ? "sequence" : "chord")
New issueQuick createchord, declared in markup
+
+ Last run: @(_last ?? "nothing yet") +
+
+ +

Typing is not a shortcut

+

+ A plain key pressed in a text box is typing, so G then S does nothing here. A chord with a + modifier still works - @Mod K opens the palette and Alt Z flips word + wrap - because nobody types those by accident. +

+ + +

The options behind it

+ + + + + + + + + + + +
OptionHereEffect
ToggleShortcut@Options.ToggleShortcutOpens and closes the palette; Mod is ⌘ on macOS and Ctrl elsewhere.
AdditionalToggleShortcuts@string.Join(", ", Options.AdditionalToggleShortcuts)More keys for the same.
SequenceTimeoutMs@Options.SequenceTimeoutMsHow long G waits for the next key.
SuppressShortcutsInEditableElements@Options.SuppressShortcutsInEditableElementsPlain keys in inputs are typing, as above.
VimBindings@Options.VimBindingsCtrl J / K move inside the list.
+
@ShortcutExample
+ +

Accessibility

+
    +
  • A native <dialog>: the top layer, a focus trap, focus restored on close, and the page's scroll locked.
  • +
  • The WAI-ARIA combobox pattern: the input owns a listbox, aria-activedescendant follows the active row and + aria-selected marks it, so focus never leaves the input.
  • +
  • A polite live region announces how many results there are as the query changes.
  • +
  • Shortcut hints read the platform: ⌘ ⇧ ⌥ on macOS, Ctrl Shift Alt elsewhere, and aria-keyshortcuts on the trigger.
  • +
  • Mouse hover moves the active row only after the pointer actually moves, so a list that scrolls under a resting + pointer does not steal the selection from the keyboard.
  • +
+ +@code { + private const string ShortcutExample = """ + BaletteCmd.New("Copy share link").Shortcut("Mod+Shift+C") // a chord: ⌘⇧C or Ctrl+Shift+C + BaletteCmd.Navigate("Settings", "/settings").Shortcut("G S") // a sequence: G, then S + BaletteCmd.New("Find in page").Shortcut("Mod+F", global: false) // shown as a hint only + + options.AdditionalToggleShortcuts.Add("Mod+Shift+P"); + """; + + private static readonly BaletteKeyboardShortcut NewIssueShortcut = "C"; + + private string? _last; + + private string Mod => Palette.IsMac ? "⌘" : "Ctrl"; + + private IEnumerable ToggleShortcuts + => Options.ToggleShortcut is null ? Options.AdditionalToggleShortcuts : [Options.ToggleShortcut, .. Options.AdditionalToggleShortcuts]; + + protected override void OnInitialized() + { + Palette.CommandsChanged += Refresh; + Palette.CommandExecuted += OnExecuted; + // Every hint on this page reads IsMac, which is only known once the palette has started in the browser. + Palette.PlatformDetected += Refresh; + } + + private void OnExecuted(object? sender, BaletteCommandExecutedEventArgs e) + { + _last = $"{e.Command.Title} at {DateTime.Now:HH:mm:ss}"; + Refresh(); + } + + private void Refresh() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() + { + Palette.CommandsChanged -= Refresh; + Palette.CommandExecuted -= OnExecuted; + Palette.PlatformDetected -= Refresh; + } +} diff --git a/src/Balette/Demo/Client/Pages/McpServer.razor b/src/Balette/Demo/Client/Pages/McpServer.razor new file mode 100644 index 0000000000..3d15463f0e --- /dev/null +++ b/src/Balette/Demo/Client/Pages/McpServer.razor @@ -0,0 +1,103 @@ +@page "/mcp-server" + +@* Describes the MCP server this demo's host maps at /mcp. Its route differs from the endpoint's on + purpose: two literal endpoints on /mcp would make a GET of it ambiguous. *@ + +MCP server - bit Balette + +

MCP server

+ +

+ This site hosts a Model Context Protocol server at /mcp, so an AI agent adding a command palette with + Bit.Balette reads the library itself - its compiled API documentation, its guide, its stylesheet's variables, its + real matcher and these demo pages - instead of guessing at an API it half remembers. Every tool is read-only. +

+ +
+

Connect a client

+ +

+ The endpoint is plain Streamable HTTP, so any MCP client connects with a URL and nothing else. The same tools + are also served, version by version, through the bit platform's own server at + https://bitplatform.dev/mcp, next to the other bit libraries. +

+ +
@ClientConfig
+
+ +
+

Tools

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolWhat it answers
SearchBaletteEverything at once - the guide, every public member, the theme variables, the setup guides and the demo sources - each hit with the exact follow-up call.
GetBaletteSetupGuideThe complete wiring for one render mode: server, wasm, auto, standalone-wasm or hybrid.
GetBaletteGuideSectionOne section of the reference guide, or the index of sections.
GetBaletteApiOne public type with every parameter, property and method, its real default and its documentation - or the index of types.
GetBaletteThemeVariablesEvery --bit-balette-* custom property with its light and dark value, and the data-balette attributes to style an unstyled palette by.
InspectBaletteSearchRuns the library's own matcher and ranker over a query and a list of commands, and reports each score, the highlighted positions and the final order.
GetBaletteSourceFileOne demo or library file verbatim - every page here is a working palette feature - or the index of files.
+ +

+ The guide, the API, the theme variables and the sources are also exposed as balette:// resources, + and three prompts - add-balette-to-app, implement-balette-feature and + debug-balette-palette - walk an agent through the common jobs in the right order. +

+
+ +
+

The same tools over plain HTTP

+ +

+ Every tool is also a GET under /api/mcp/...: open one in a browser tab to read + exactly what an agent is handed. +

+ + +
+ +@code { + private const string ClientConfig = """ + // .mcp.json (Claude Code, VS Code, and anything else reading this shape) + { + "mcpServers": { + "balette": { + "type": "http", + "url": "https://balette.bitplatform.dev/mcp" + } + } + } + """; +} diff --git a/src/Balette/Demo/Client/Pages/Modes.razor b/src/Balette/Demo/Client/Pages/Modes.razor new file mode 100644 index 0000000000..6934ec7da6 --- /dev/null +++ b/src/Balette/Demo/Client/Pages/Modes.razor @@ -0,0 +1,224 @@ +@page "/modes" +@implements IDisposable +@inject IBaletteService Palette +@inject BaletteOptions Options +@inject SourceLog SourceLog + +Modes and sources - bit Balette + +

Modes & sources

+

+ A mode is a prefix typed at the start of the query - > in VS Code, # on GitHub - that + scopes the palette to part of the registry, to its own asynchronous sources, or both. A source is anything that + answers a query later: an API, a database, a search index. The palette debounces it, cancels it when the query + moves on, and merges what it returns. +

+ +

The modes of this app

+
+ + + + + + @foreach (var mode in Palette.Modes) + { + + + + + + + + } + +
PrefixModeListsSources
@mode.Prefix + @mode.Name +
@mode.Description
+
@Scope(mode)@(mode.Sources.Count == 0 ? "-" : string.Join(", ", mode.Sources.Select(s => s.Name)))
+
+ +

+ On the empty query the palette lists every mode as a hint under Modes, so they can be discovered without + reading this page; picking a hint types its prefix. ShowHint = false keeps a mode out of that list. +

+ +

The sources

+
+ + + + + + @foreach (var (source, where) in AllSources()) + { + + + + + + + + + } + +
SourceAsked inFromAt mostOrderLatency
@source.Name@where@(source.MinQueryLength == 0 ? "empty box" : $"{source.MinQueryLength} char{(source.MinQueryLength == 1 ? "" : "s")}")@source.MaxResults@(source.ApplyMatcher ? "re-ranked with the registry" : "its own, as one block")@(Latency.TryGetValue(source.Name, out var ms) ? $"{ms} ms" : "-")
+
+ +
+
+

Re-ranked

+

+ With ApplyMatcher the palette scores what a source returns with its own matcher and sorts it in + with everything else. The source only has to return candidates, and its rows are highlighted like the rest. + People and Documentation work this way. +

+
+
+

Ordered

+

+ Without it, the source's order is kept and its rows are listed as one block under its name - right for a + server that already sorts by relevance. Issues puts open issues first, newest first, and asks on the + empty query too, so # opens on a list. +

+
+
+ +

Debounced and cancelled, live

+

+ Open the palette and type @@ada lovelace at your normal speed. The palette waits + @Options.DebounceMs ms after the last keystroke before it asks a source, so most keystrokes never + reach one; a search still running when the next one starts is cancelled through its token. Every call that did + reach a source is listed here, newest first. +

+ +
+
+ + + +
+ +
+ + + + + + @if (SourceLog.Calls.Count == 0) + { + + } + @foreach (var call in SourceLog.Calls) + { + + + + + + + + + } + +
StartedSourceQueryOutcomeResultsTook
No source has been asked yet.
@call.StartedAt.ToString("HH:mm:ss.fff")@call.Source@(call.Query.Length == 0 ? "(empty)" : call.Query)@call.Outcome@(call.Outcome == SourceCallOutcome.Completed ? call.ResultCount.ToString() : "-")@(call.Outcome == SourceCallOutcome.Running ? "-" : $"{call.Duration.TotalMilliseconds:0} ms")
+
+ +

Registering them

+
@RegistrationExample
+

+ Sources in options.Sources are asked in the default mode; a mode's own Sources only in that + mode. IBaletteService.RegisterMode and RegisterSource add either at runtime and return a + handle that removes it again, and AddBitBaletteSource<T>() registers a source resolved from DI. +

+ +@code { + private const string RegistrationExample = """ + builder.Services.AddBitBaletteServices(options => + { + options.Modes.Add(new BaletteCommandMode + { + Prefix = "@", + Name = "People", + Description = "Find a teammate", + HideRegistryCommands = true, // only the source's rows + Sources = [new PeopleSource()], + }); + + options.Sources.Add(new DocsSource()); // also asked in the default mode + options.DebounceMs = 150; + }); + + public sealed class PeopleSource : IBaletteSource + { + public string Name => "People"; + public bool ApplyMatcher => true; // re-rank with the palette's matcher + + public async Task> SearchAsync( + string query, BaletteSourceContext context, CancellationToken cancellationToken) + { + var people = await context.Services.GetRequiredService() + .SearchAsync(query, cancellationToken); + return [.. people.Select(p => BaletteCmd.New(p.Name).Subtitle(p.Role).Build())]; + } + } + """; + + // The delays the demo's sources simulate (Services/FakeData.cs); nothing in IBaletteSource reports one. + private static readonly Dictionary Latency = new() + { + ["People"] = 250, + ["Issues"] = 180, + ["Documentation"] = 300, + }; + + protected override void OnInitialized() + { + SourceLog.Changed += Refresh; + Palette.CommandsChanged += Refresh; + } + + private IEnumerable<(IBaletteSource Source, string Where)> AllSources() + { + foreach (var source in Palette.Sources) + { + yield return (source, "the default mode"); + } + + foreach (var mode in Palette.Modes) + { + foreach (var source in mode.Sources) + { + yield return (source, $"{mode.Prefix} {mode.Name}"); + } + } + } + + private static string Scope(BaletteCommandMode mode) + { + if (mode.HideRegistryCommands) + { + return "only its sources"; + } + + if (mode.Tags is { Count: > 0 } tags) + { + return $"commands tagged {string.Join(", ", tags.Select(t => $"“{t}”"))}"; + } + + if (mode.Groups is { Count: > 0 } groups) + { + return $"the {string.Join(", ", groups)} group"; + } + + return "every command"; + } + + private void Refresh() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() + { + SourceLog.Changed -= Refresh; + Palette.CommandsChanged -= Refresh; + } +} diff --git a/src/Balette/Demo/Client/Pages/NotFound.razor b/src/Balette/Demo/Client/Pages/NotFound.razor new file mode 100644 index 0000000000..67ce041ab0 --- /dev/null +++ b/src/Balette/Demo/Client/Pages/NotFound.razor @@ -0,0 +1,9 @@ +@page "/not-found" +@layout MainLayout + +Not found - bit Balette +

Not found

+

+ There is nothing at this address. Back to the introduction, or press Ctrl K and + search for the page you were after. +

diff --git a/src/Balette/Demo/Client/Pages/PagesAndFlows.razor b/src/Balette/Demo/Client/Pages/PagesAndFlows.razor new file mode 100644 index 0000000000..ad6d5535bf --- /dev/null +++ b/src/Balette/Demo/Client/Pages/PagesAndFlows.razor @@ -0,0 +1,167 @@ +@page "/pages" +@implements IDisposable +@inject IBaletteService Palette +@inject DemoState State + +Nested pages and flows - bit Balette + +

Nested pages & flows

+

+ A palette is more than a list: a command can open a page of further commands, ask for text, collect a selection or + ask to be confirmed, and every one of those steps happens inside the same box, with breadcrumbs above it and + Esc or Backspace to step back. Each button below opens the layout's palette on one of them. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FlowDeclared withIn this app
Static page.Children(a, b, c)Theme - three options, the current one checked
Lazy page.Children(async (ctx, ct) => ...)Assign to… - loads people for 400 ms, with a spinner
Declarative pageNested <BaletteItem>New… in Quick create
Ad-hoc pageBaletteCommandResult.Page(...)Export… - the handler decides the list, one row disabled
PromptBaletteCommandResult.Prompt(...)Rename project… - pre-filled with the current name
Multi-select.MultiSelect()Set labels… - Enter checks, Ctrl Enter applies
Confirmation.Destructive("message")Delete project - the first Enter asks, the second runs
ToastBaletteCommandResult.Toast(...)What's new - a message in the footer, palette kept open
Action panel.Actions(...)Any row: press Tab. People rows add their own two actions
+
+ +

+ Project: @State.ProjectName · Labels: @(State.Labels.Count == 0 ? "none" : string.Join(", ", State.Labels)) + · Last: @(State.Log.Count == 0 ? "nothing yet" : State.Log[0]) +

+ +

What a handler returns

+

+ A handler does its work and then tells the palette what comes next. That is what keeps a multi-step flow in one + place: the next step is a return value, not a callback wired up somewhere else. +

+ + + + + + + + + + + + + +
ResultWhat the palette does
CloseCloses. The default for Handler(Action) and for navigation.
KeepOpenStays on the current page and re-renders it - the toggles use it.
BackGoes back one page - the theme options use it to land on the list again.
ResetClears the query and returns to the root page, still open.
Page(title, commands)Pushes a page with these commands; optionally multi-select with its own submit handler.
Prompt(title, onSubmit)Turns the input into a text field; Enter calls onSubmit with ctx.PromptValue.
Toast(message)Shows a message in the footer, then closes - or stays, with keepOpen: true.
+ +

A prompt

+
@PromptExample
+ +

A multi-select page

+
@MultiSelectExample
+ +

A lazy page

+

+ The provider runs each time the page opens, with a context whose Services come from the user's + scope - which is how a real application would reach its API client from here. +

+
@LazyExample
+ +
+ OpenAsync(pageCommandId: ...) opens straight inside a registered command's page, and + BaletteTrigger takes the same PageCommandId: a "Change theme" button can be a trigger + that lands on the theme options rather than on the root list. +
+ +@code { + private const string PromptExample = """ + BaletteCmd.New("Rename project…") + .Shortcut("F2") + .Handler(_ => Task.FromResult(BaletteCommandResult.Prompt( + "New project name", + ctx => + { + state.Rename(ctx.PromptValue!.Trim()); + return Task.FromResult(BaletteCommandResult.Toast($"Renamed to {ctx.PromptValue}")); + }, + placeholder: "Project name…", + initialValue: state.ProjectName))) + """; + + private const string MultiSelectExample = """ + BaletteCmd.New("Set labels…") + .MultiSelect() + .ChildrenPlaceholder("Toggle labels, Ctrl+Enter to apply…") + .Children( + BaletteCmd.New("bug").Id("label:bug"), + BaletteCmd.New("enhancement").Id("label:enhancement")) + // Runs on Ctrl+Enter, with the checked rows. + .Handler(ctx => + { + state.SetLabels(ctx.SelectedCommands.Select(c => c.Title)); + return Task.FromResult(BaletteCommandResult.Toast($"{ctx.SelectedCommands.Count} labels applied")); + }) + """; + + private const string LazyExample = """ + BaletteCmd.New("Assign to…") + .Children(async (ctx, ct) => + { + var people = await ctx.GetRequiredService().GetAllAsync(ct); + return people.Select(p => BaletteCmd.New(p.Name).Subtitle(p.Role).Build()).ToList(); + }, placeholder: "Pick an assignee…") + """; + + protected override void OnInitialized() => State.Changed += Refresh; + + private void Refresh() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() => State.Changed -= Refresh; +} diff --git a/src/Balette/Demo/Client/Pages/Performance.razor b/src/Balette/Demo/Client/Pages/Performance.razor new file mode 100644 index 0000000000..5cc8acfdc9 --- /dev/null +++ b/src/Balette/Demo/Client/Pages/Performance.razor @@ -0,0 +1,212 @@ +@page "/performance" +@using System.Diagnostics +@using System.Globalization +@implements IDisposable +@inject IBaletteService Palette +@inject BaletteOptions Options +@inject BaletteRanker Ranker +@inject DemoState State + +Large registry - bit Balette + +

Large registry

+

+ Numbers measured here, in your browser, on this machine. Register a few thousand synthetic commands, open the + palette and type: every keystroke matches all of them. The table below times the same matcher and ranker the + palette runs, over the same registry, so you can see what a keystroke costs before you feel it. +

+ +
+ + +
+ Registry: @Palette.Commands.Count.ToString("N0", CultureInfo.InvariantCulture) commands + @if (_registration is { } registration) + { + Last registration: @Ms(registration), the palette's rebuild included + } + @if (_busy) + { + Measuring… + } +
+
+ +@if (_results.Count > 0) +{ +

Measurements

+ + + + + + @foreach (var result in _results) + { + + + + + + + + } + +
QueryMatchedMatchRankTotal
@result.Query@result.Matched.ToString("N0", CultureInfo.InvariantCulture)@Ms(result.Match)@Ms(result.Rank)@Ms(result.Match + result.Rank)
+

+ The median of @Repetitions runs over @_measuredOver.ToString("N0", CultureInfo.InvariantCulture) root commands. The + ranking here sorts every match; the palette keeps only the best @Options.MaxResults, which is cheaper still. +

+} + +
+ On WebAssembly, search and painting share the page's only thread. When a search takes longer than a frame (8 ms), + the palette lets the keystroke paint first and folds the keys typed meanwhile into one search, so the input never + lags behind the fingers. Only the best MaxResults rows are rendered, and Virtualize + renders just the visible ones when that limit is raised. +
+ +

The registration

+
@RegistrationExample
+

+ The handle comes back from Register; this page disposes it when you leave, so the registry is back to its + usual size on every other page. +

+ +@code { + private const int Repetitions = 5; + + private const string RegistrationExample = """ + var many = Enumerable.Range(1, 2000).Select(i => BaletteCmd.New($"Command {i}") + .Id($"bulk:{i}") + .Group("Bulk") + .Subtitle($"Synthetic entry #{i}") + .Priority(BaletteCommandPriority.Lowest) + .Handler(_ => Task.FromResult(BaletteCommandResult.Toast($"Ran command {i}"))) + .Build()); + + _bulk = Palette.Register(many); // ... and later: _bulk.Dispose(); + """; + + private static readonly string[] Queries = ["c12", "cmd 1234", "command", "synthetic 19", "^command 7", "zzz"]; + + private sealed record Measurement(string Query, int Matched, TimeSpan Match, TimeSpan Rank); + + private readonly List _results = []; + private IDisposable? _bulk; + private int _count = 2000; + private int _measuredOver; + private TimeSpan? _registration; + private bool _busy; + + protected override void OnInitialized() => Palette.CommandsChanged += Refresh; + + private void OnCountChanged(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out var value)) + { + _count = value; + } + } + + private void ToggleBulk() + { + var started = Stopwatch.GetTimestamp(); + if (_bulk is not null) + { + _bulk.Dispose(); + _bulk = null; + } + else + { + var count = _count; + var many = Enumerable.Range(1, count) + .Select(i => BaletteCmd.New($"Command {i}").Id($"bulk:{i}").Group("Bulk").Subtitle($"Synthetic entry #{i}") + .Priority(BaletteCommandPriority.Lowest) + .Handler(_ => Task.FromResult(BaletteCommandResult.Toast($"Ran command {i}"))) + .Build()); + _bulk = Palette.Register(many); + State.Record($"Registered {count:N0} synthetic commands"); + } + + _registration = Stopwatch.GetElapsedTime(started); + _results.Clear(); + } + + private async Task MeasureAsync() + { + _busy = true; + _results.Clear(); + StateHasChanged(); + + var candidates = Palette.Commands.Where(c => c.Parent is null).ToList(); + _measuredOver = candidates.Count; + foreach (var query in Queries) + { + // WebAssembly runs on the page's only thread: yielding between queries lets the table fill in as it goes + // instead of the whole measurement showing as one freeze. + await Task.Delay(1); + + var matchTimes = new List(Repetitions); + var rankTimes = new List(Repetitions); + var matched = 0; + for (var run = 0; run < Repetitions; run++) + { + var started = Stopwatch.GetTimestamp(); + var matches = new List(); + foreach (var command in candidates) + { + if (Options.Matcher.Match(command, query, Options.Weights) is { } match) + { + matches.Add(match); + } + } + + matchTimes.Add(Stopwatch.GetElapsedTime(started)); + + started = Stopwatch.GetTimestamp(); + Ranker.Rank(matches, hasQuery: true, _ => 0); + rankTimes.Add(Stopwatch.GetElapsedTime(started)); + matched = matches.Count; + } + + _results.Add(new Measurement(query, matched, Median(matchTimes), Median(rankTimes))); + StateHasChanged(); + } + + _busy = false; + } + + private static TimeSpan Median(List values) + { + values.Sort(); + return values[values.Count / 2]; + } + + private static string Ms(TimeSpan value) => $"{value.TotalMilliseconds.ToString("0.00", CultureInfo.InvariantCulture)} ms"; + + private void Refresh() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() + { + Palette.CommandsChanged -= Refresh; + _bulk?.Dispose(); + } +} diff --git a/src/Balette/Demo/Client/Pages/SearchAndRanking.razor b/src/Balette/Demo/Client/Pages/SearchAndRanking.razor new file mode 100644 index 0000000000..d441db36db --- /dev/null +++ b/src/Balette/Demo/Client/Pages/SearchAndRanking.razor @@ -0,0 +1,270 @@ +@page "/search" +@using System.Diagnostics +@using System.Globalization +@implements IDisposable +@inject IBaletteService Palette +@inject BaletteOptions Options +@inject BaletteRanker Ranker +@inject BaletteHistory History +@inject DemoState State + +Search and ranking - bit Balette + +

Search & ranking

+

+ Every keystroke runs two public pieces of the library: a matcher that scores each command and says which + characters to highlight, and a ranker that folds in priority and how often and how recently a command was used. + The lab below runs exactly those two over this app's registry, so the numbers are the ones the palette sorts by. +

+ +
+ + + + + + + + + + + + + + + + @if (_results.Count == 0) + { + + } + @foreach (var (match, index) in _results.Take(MaxRows).Select((m, i) => (m, i))) + { + + + + + + + + + + } + +
#CommandMatched onScorePriorityFrecencyRank
No command matches. The palette would still list the always-shown feedback row.
@(index + 1) + + @if (match.Command.Subtitle is not null) + { +
+ } +
@MatchedOn(match)@Format(match.Score)@match.Command.Priority@Format(match.Frecency)@Format(match.Rank)
+ +
+ @_candidates root commands + @_results.Count matched + matched and ranked in @_elapsed.TotalMilliseconds.ToString("0.00", CultureInfo.InvariantCulture) ms + @if (!History.IsLoaded) + { + frecency loads once the page is interactive + } +
+
+ +

Operators

+

+ A query is split on spaces and every term has to match (AND), each against whichever field scores it best. A term + can carry an fzf operator; the rest are fuzzy. +

+ + + + + + @foreach (var example in Examples) + { + + + + + + } + +
QueryMeans
@example.Query@example.Meaning
+ +

How a score is made

+
+
+

+ The fuzzy matcher is fzf's algorithm: a dynamic programme that finds the best alignment of the term inside + the text, rewarding matches at word starts, camel humps and consecutive runs, and the first character most, + while gaps cost. A lower-case term ignores case; a term with a capital letter respects it. +

+

+ Each field's score is multiplied by its weight, so a hit in the title beats the same hit in a keyword, + which beats one in the subtitle. These are this app's weights, from BaletteOptions.Weights: +

+
+ + + + + + + + + + + + +
FieldWeight
Title@Format(Options.Weights.Title)
Keywords@Format(Options.Weights.Keyword)
Subtitle@Format(Options.Weights.Subtitle)
Group@Format(Options.Weights.Group)
Description@Format(Options.Weights.Description)
Tags@Format(Options.Weights.Tag)
+
+ +

Priority and frecency

+

With a query, the match decides and the rest only nudges it:

+
rank = score + priority × @Format(Options.PriorityWeight) + ln(1 + frecency) × @Format(Options.FrecencyWeight)
+

+ On the empty query there is nothing to match, so priority leads and frecency orders what is left + (priority × 1000 + frecency), inside each group. Frecency is Firefox's idea: a capped use count weighted + by how recent the last use was. Run a command a few times from the palette and watch its row climb in the lab - + the history is kept in localStorage, so it survives a reload. +

+ +

In the palette

+
+ + +
+ +
+ The matcher is an interface: options.Matcher = BaletteSubstringMatcher.Default trades typo tolerance for + speed, and any IBaletteMatcher can replace both. Matching runs over the fields of the command only; + asynchronous sources opt into it with ApplyMatcher, or keep their own order. +
+ +@code { + private const int MaxRows = 15; + + private static readonly (string Query, string Meaning)[] Examples = + [ + ("rc", "Fuzzy: R and C in order, anywhere - word starts score highest."), + ("wrap word", "Two terms, both required, in any order."), + ("'export", "Exact: the substring itself, no gaps."), + ("^set", "Prefix: the field starts with it."), + ("project$", "Suffix: the field ends with it."), + ("show !minimap", "Negation: drops every command that contains the term."), + ("mcp", "Smart case, all lower case: case is ignored, so it matches MCP server."), + ("Mcp", "Smart case, with a capital: case now counts, so MCP server drops out."), + ]; + + private string _query = "rc"; + private string _matcherName = "fuzzy"; + private IBaletteMatcher _matcher = BaletteFuzzyMatcher.Default; + private List _results = []; + private int _candidates; + private TimeSpan _elapsed; + + protected override void OnInitialized() + { + Palette.CommandsChanged += OnChanged; + History.Changed += OnChanged; + Run(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // The history lives in localStorage, which is only reachable once the page runs in the browser. + if (firstRender && RendererInfo.IsInteractive && !History.IsLoaded) + { + await History.EnsureLoadedAsync(); + Run(); + StateHasChanged(); + } + } + + private void Run() + { + var started = Stopwatch.GetTimestamp(); + + // What the palette lists on its root page: commands without a parent that are currently visible. + var candidates = Palette.Commands.Where(c => c.Parent is null && c.IsVisible?.Invoke() != false).ToList(); + var matches = new List(candidates.Count); + foreach (var command in candidates) + { + if (_matcher.Match(command, _query, Options.Weights) is { } match) + { + matches.Add(match); + } + } + + _results = Ranker.Rank(matches, _query.Trim().Length > 0, id => History.IsLoaded ? History.Frecency(id) : 0); + _elapsed = Stopwatch.GetElapsedTime(started); + _candidates = candidates.Count; + } + + private void OnQueryInput(ChangeEventArgs e) + { + _query = e.Value?.ToString() ?? string.Empty; + Run(); + } + + private void OnMatcherChanged(ChangeEventArgs e) + { + _matcherName = e.Value?.ToString() ?? "fuzzy"; + _matcher = _matcherName switch + { + "ignore-case" => new BaletteFuzzyMatcher { IgnoreCase = true }, + "substring" => BaletteSubstringMatcher.Default, + _ => BaletteFuzzyMatcher.Default, + }; + Run(); + } + + private void SetQuery(string query) + { + _query = query; + Run(); + } + + private void OnChanged() => _ = InvokeAsync(() => + { + Run(); + StateHasChanged(); + }); + + private static string MatchedOn(BaletteMatch match) => match switch + { + { TitlePositions.Length: > 0 } => "title", + { SubtitlePositions.Length: > 0 } => "subtitle", + { MatchedKeyword: { } keyword } => $"keyword “{keyword}”", + _ when match.Score == 0 => "-", + _ => "group, description or tag", + }; + + private static string Format(double value) => double.IsNegativeInfinity(value) ? "-∞" : value.ToString("0.##", CultureInfo.InvariantCulture); + + public void Dispose() + { + Palette.CommandsChanged -= OnChanged; + History.Changed -= OnChanged; + } +} diff --git a/src/Balette/Demo/Client/Pages/Theming.razor b/src/Balette/Demo/Client/Pages/Theming.razor new file mode 100644 index 0000000000..47701663f4 --- /dev/null +++ b/src/Balette/Demo/Client/Pages/Theming.razor @@ -0,0 +1,210 @@ +@page "/theming" +@implements IDisposable +@inject IBaletteService Palette +@inject DemoState State + +Theming and templates - bit Balette + +

Theming & templates

+

+ The default look is a set of CSS custom properties, so restyling the palette means overriding variables - on + :root, on the dialog, or inline through the component's Style - with no selectors to fight. + Past that, every part of a row and of the dialog can be replaced with a template, or the styles left out entirely. + The controls below restyle the palette this site is using. +

+ +
+ + + + +
+

What the layout hands its palette right now:

+
<Balette Theme="@(State.PaletteTheme ?? "(follows the page)")"
+         Style="@(State.PaletteStyle ?? "(none)")"
+         DetailTemplate="@(State.ShowDetail ? "_detailPane" : "null")"
+         ShowFooter="@(State.ShowFooter ? "true" : "false")" />
+
+
+ +
+ The site theme is <html data-theme>, which the library's stylesheet and this site's both follow - + auto leaves it to prefers-color-scheme. The palette's Theme parameter wins over the + page for the palette alone, for an app that wants a dark palette over a light page. +
+ +

The tokens

+

The ones that change the look the most. Every one of them has a light and a dark value.

+ + + + + + + + + + + + + + + + + +
Custom propertyLight defaultControls
--bit-balette-accent#4f46e5Toggles that are on, checked rows, badges, the mode chip, the spinner
--bit-balette-accent-softrgba(79, 70, 229, .10)Behind badges, the mode chip and checked boxes
--bit-balette-mark-bgrgba(79, 70, 229, .18)Highlighted matched characters
--bit-balette-bg / -bg-elevated#ffffff / #f6f7f9The dialog, and the detail pane and footer
--bit-balette-fg / -fg-muted#111318 / #6b7280Titles, and subtitles and hints
--bit-balette-width640px (920px with a detail pane)The dialog's width; it never exceeds the viewport
--bit-balette-max-heightmin(70vh, 560px)How tall the list may grow
--bit-balette-radius14pxThe dialog's corners
--bit-balette-row-height44pxA row; set RowHeight to match when virtualizing
--bit-balette-font / -font-sizesystem UI, 14pxThe type
--bit-balette-backdroprgba(17, 19, 24, .45)The page behind the open dialog
+
@TokensExample
+ +

Templates

+

+ The detail pane on the right of this site's palette is a DetailTemplate in + Layout/MainLayout.razor; it shows the Person record a people row carries as + Data. The other parts are replaced the same way: +

+ + + + + + + + + + + + +
ParameterReceivesReplaces
ItemTemplateBaletteItemContextA row's content (the row element and its ARIA stay the library's)
IconTemplateBaletteCommandJust the icon
DetailTemplateBaletteCommandA preview pane for the active row
GroupHeaderTemplatethe group nameA group heading
EmptyTemplate / LoadingTemplatethe query / nothingThe empty and loading states
HeaderTemplate / FooterTemplateBaletteStateContextAbove the input / the hint bar
+
@ItemTemplateExample
+ +

Unstyled

+

+ Unstyled="true" drops the library's classes from the dialog, and every rule in its stylesheet is scoped + to them, so what is left is bare, accessible markup to style from scratch. The structure stays addressable through + data-balette attributes - dialog, input, list, + group-heading, item, breadcrumbs, footer, toast, + detail - and a row's state through data-active, data-checked, + data-disabled, data-destructive and data-kind. +

+
@UnstyledExample
+ +@code { + private static readonly string[] SiteThemes = ["auto", "light", "dark"]; + + private static readonly (string? Value, string Label)[] PaletteThemes = [(null, "Follows the page"), ("light", "Light"), ("dark", "Dark")]; + + private static readonly (string? Value, string Label)[] Accents = + [ + (null, "Default indigo"), + ("#0d9488", "Teal"), + ("#db2777", "Pink"), + ("#ea580c", "Orange"), + ("#16a34a", "Green"), + ]; + + private const string TokensExample = """ + /* app.css - after the library's stylesheet */ + :root { + --bit-balette-accent: #0d9488; + --bit-balette-width: 760px; + --bit-balette-radius: 8px; + } + + :root[data-theme="dark"] { + --bit-balette-accent: #2dd4bf; + } + """; + + private const string ItemTemplateExample = """ + + + @{ var command = context.Row.Command; } + + + @if (command.Shortcut is not null) + { + + } + + + + """; + + private const string UnstyledExample = """ + + + .my-palette [data-balette="item"][data-active] { background: Highlight; color: HighlightText; } + .my-palette [data-balette="item"][data-disabled] { opacity: .5; } + .my-palette [data-balette="group-heading"] { font-size: .75rem; text-transform: uppercase; } + """; + + protected override void OnInitialized() => State.Changed += Refresh; + + private void OnWidthChanged(ChangeEventArgs e) => State.SetWidth(int.TryParse(e.Value?.ToString(), out var width) ? width : null); + + private void OnRadiusChanged(ChangeEventArgs e) => State.SetRadius(int.TryParse(e.Value?.ToString(), out var radius) ? radius : null); + + private static string Capitalize(string s) => char.ToUpperInvariant(s[0]) + s[1..]; + + private void Refresh() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() => State.Changed -= Refresh; +} diff --git a/src/Balette/Demo/Client/Program.cs b/src/Balette/Demo/Client/Program.cs new file mode 100644 index 0000000000..b387466b43 --- /dev/null +++ b/src/Balette/Demo/Client/Program.cs @@ -0,0 +1,11 @@ +// The WebAssembly half of the demo: the same service registration as the server, through AddDemoServices. + +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Services.AddDemoServices(); + +// No RootComponents are registered on purpose: the host page drives the app through +// blazor.web.js render-mode markers (). +await builder.Build().RunAsync(); diff --git a/src/Balette/Demo/Client/Routes.razor b/src/Balette/Demo/Client/Routes.razor new file mode 100644 index 0000000000..105855d457 --- /dev/null +++ b/src/Balette/Demo/Client/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Balette/Demo/Client/Services/DemoCommands.cs b/src/Balette/Demo/Client/Services/DemoCommands.cs new file mode 100644 index 0000000000..daabd63886 --- /dev/null +++ b/src/Balette/Demo/Client/Services/DemoCommands.cs @@ -0,0 +1,219 @@ +namespace Bit.Balette.Demo.Client.Services; + +/// +/// The commands the layout registers for the whole app, written with the fluent builder end to end. They are +/// rebuilt from every time it changes, so a toggle's switch and a setting's value are +/// always the current ones: BaletteCommands swaps the registration when it is handed a new list. +/// +public static class DemoCommands +{ + public const string GitHubUrl = "https://github.com/bitfoundation/bitplatform/tree/develop/src/Balette"; + + public const string NuGetUrl = "https://www.nuget.org/packages/Bit.Balette"; + + // Commands tagged with it are the ones the ">" mode lists: everything that does something, nothing that only + // navigates. + public const string CommandTag = "command"; + + public static IEnumerable Global(DemoState state) => + [ + // ---- Navigation: every page, also reachable with G-then-X sequences, the way Linear and GitHub do it ---- + BaletteCmd.Navigate("Introduction", "/").Group("Navigation").Shortcut("G H").Keywords("home", "start", "landing").Priority(BaletteCommandPriority.High), + BaletteCmd.Navigate("Registering commands", "/commands").Group("Navigation").Shortcut("G R").Keywords("register", "builder", "declarative", "counter"), + BaletteCmd.Navigate("Settings", "/commands#settings").Group("Navigation").Shortcut("G S").Keywords("preferences", "options", "config"), + BaletteCmd.Navigate("Nested pages and flows", "/pages").Group("Navigation").Shortcut("G P").Keywords("prompt", "multi-select", "confirm", "toast", "actions"), + BaletteCmd.Navigate("Search and ranking", "/search").Group("Navigation").Shortcut("G F").Keywords("fuzzy", "find", "score", "frecency", "operators"), + BaletteCmd.Navigate("Modes and sources", "/modes").Group("Navigation").Shortcut("G M").Keywords("prefix", "async", "debounce"), + BaletteCmd.Navigate("Keyboard and shortcuts", "/keyboard").Group("Navigation").Shortcut("G K").Keywords("hotkeys", "sequence", "accessibility"), + BaletteCmd.Navigate("Theming and templates", "/theming").Group("Navigation").Shortcut("G T").Keywords("dark", "css", "variables", "detail"), + BaletteCmd.Navigate("Large registry", "/performance").Group("Navigation").Shortcut("G L").Keywords("performance", "benchmark", "bulk"), + BaletteCmd.Navigate("MCP server", "/mcp-server").Group("Navigation").Shortcut("G A").Keywords("ai", "agent", "model context protocol"), + BaletteCmd.New("GitHub repository").Href(GitHubUrl, newTab: true).Group("Navigation").Keywords("source", "code", "repo"), + BaletteCmd.New("NuGet package").Href(NuGetUrl, newTab: true).Group("Navigation").Keywords("install", "package", "download"), + + // ---- Actions: one of each kind of flow ---- + BaletteCmd.New("Rename project…") + .Group("Actions") + .Shortcut("F2") + .Keywords("title", "name") + .Tags(CommandTag) + // A prompt: the input turns into a text box pre-filled with the current name, and Enter submits it. + .Handler(_ => Task.FromResult(BaletteCommandResult.Prompt( + "New project name", + ctx => + { + if (!string.IsNullOrWhiteSpace(ctx.PromptValue)) + { + state.Rename(ctx.PromptValue.Trim()); + } + + return Task.FromResult(BaletteCommandResult.Toast($"Renamed to “{ctx.PromptValue}”")); + }, + placeholder: "Project name…", + initialValue: state.ProjectName))), + + BaletteCmd.New("Set labels…") + .Group("Actions") + .Keywords("tags", "categorize") + .Tags(CommandTag) + // A multi-select page: Enter or Space checks a row, Ctrl+Enter hands the checked ones to the handler. + .MultiSelect() + .ChildrenPlaceholder("Toggle labels, Ctrl+Enter to apply…") + .Children( + BaletteCmd.New("bug").Id("label:bug").Icon("demo-dot demo-dot-red"), + BaletteCmd.New("enhancement").Id("label:enhancement").Icon("demo-dot demo-dot-blue"), + BaletteCmd.New("documentation").Id("label:documentation").Icon("demo-dot demo-dot-green"), + BaletteCmd.New("performance").Id("label:performance").Icon("demo-dot demo-dot-yellow"), + BaletteCmd.New("accessibility").Id("label:accessibility").Icon("demo-dot demo-dot-purple")) + .Handler(ctx => + { + state.SetLabels(ctx.SelectedCommands.Select(c => c.Title)); + return Task.FromResult(BaletteCommandResult.Toast($"{ctx.SelectedCommands.Count} labels applied")); + }), + + BaletteCmd.New("Assign to…") + .Group("Actions") + .Keywords("owner", "person", "user") + .Tags(CommandTag) + // A lazily loaded page: the provider runs when the page opens, and the palette shows its loading state + // until it returns. + .Children(async (ctx, ct) => + { + await Task.Delay(400, ct); + return ctx.GetRequiredService().People + .Select(p => BaletteCmd.New(p.Name) + .Subtitle(p.Role) + .Icon("demo-avatar") + .Data(p) + .Handler(_ => + { + state.Record($"Assigned to {p.Name}"); + return Task.FromResult(BaletteCommandResult.Toast($"Assigned to {p.Name}")); + }) + .Build()) + .ToList(); + }, placeholder: "Pick an assignee…"), + + BaletteCmd.New("Copy share link") + .Group("Actions") + .Shortcut("Mod+Shift+C") + .Keywords("url", "clipboard") + .Tags(CommandTag) + .Handler(_ => + { + state.Record("Share link copied"); + return Task.FromResult(BaletteCommandResult.Toast("Link copied to clipboard")); + }), + + BaletteCmd.New("Export…") + .Group("Actions") + .Keywords("download", "save as") + .Tags(CommandTag) + // An ad-hoc page: the handler decides what to list when it runs, instead of the command declaring it. + .Handler(_ => Task.FromResult(BaletteCommandResult.Page("Export format", + [ + BaletteCmd.Action("CSV", () => state.Record("Exported CSV")).Subtitle("Comma separated values").Build(), + BaletteCmd.Action("JSON", () => state.Record("Exported JSON")).Subtitle("Pretty printed").Build(), + BaletteCmd.Action("PDF", () => state.Record("Exported PDF")).Subtitle("Print friendly").Badge("Pro").Disabled(true, "Requires the Pro plan").Build(), + ]))), + + // ---- Preferences: toggles keep the palette open and update in place ---- + BaletteCmd.New("Theme") + .Group("Preferences") + .Keywords("appearance", "dark", "light", "color scheme") + .Tags(CommandTag) + .Value(Capitalize(state.Theme)) + .Children( + ThemeOption(state, "auto", "Follow the operating system"), + ThemeOption(state, "light", "Always light"), + ThemeOption(state, "dark", "Always dark")), + + Toggle("Word wrap", "wordwrap", state.WordWrap, state.ToggleWordWrap, "Alt+Z", "soft wrap lines"), + Toggle("Desktop notifications", "notifications", state.Notifications, state.ToggleNotifications, null, "alerts push"), + Toggle("Show minimap", "minimap", state.ShowMinimap, state.ToggleMinimap, null, "outline overview"), + // Changes the palette it is listed in: the detail pane appears or goes while the palette stays open. + Toggle("Show detail pane", "detail", state.ShowDetail, () => state.SetShowDetail(!state.ShowDetail), null, "preview sidebar raycast"), + + // ---- Danger zone: a destructive command needs a second Enter ---- + BaletteCmd.New("Delete project") + .Group("Danger zone") + .Keywords("remove", "destroy") + .Tags(CommandTag) + .Destructive("This permanently deletes the project") + .Handler(_ => + { + state.Record("Project deleted (not really)"); + return Task.FromResult(BaletteCommandResult.Toast("Project deleted")); + }), + + BaletteCmd.New("Reset all settings") + .Group("Danger zone") + .Keywords("defaults", "restore") + .Tags(CommandTag) + .Destructive("Reset every preference to its default?") + .Handler(async _ => + { + await state.ResetPreferencesAsync(); + return BaletteCommandResult.Toast("Settings reset"); + }), + + // ---- Help: what the "?" mode lists ---- + BaletteCmd.New("Keyboard shortcuts") + .Group("Help") + .Shortcut("Mod+/") + .Keywords("hotkeys", "keys", "bindings") + .Href("/keyboard#shortcuts") + // After Href, which makes the command a navigation, so the row keeps the help icon. + .Kind(BaletteCommandKind.Help), + + BaletteCmd.New("What's new") + .Group("Help") + .Badge("New") + .Kind(BaletteCommandKind.Help) + .Handler(_ => Task.FromResult(BaletteCommandResult.Toast("You are on the latest version", keepOpen: true))), + + // Always listed, last, whatever was typed: the query it was reached with is the feedback. + BaletteCmd.New("Feedback: couldn't find a command?") + .Group("Help") + .Kind(BaletteCommandKind.Help) + .AlwaysShow() + .Priority(BaletteCommandPriority.Lowest) + .Handler(ctx => Task.FromResult(BaletteCommandResult.Toast(ctx.Query.Length > 0 ? $"Thanks! We logged “{ctx.Query}”." : "Thanks for the feedback!"))), + ]; + + private static BaletteCommand ThemeOption(DemoState state, string theme, string subtitle) => + BaletteCmd.New(Capitalize(theme)) + .Id($"theme:{theme}") + .Subtitle(subtitle) + .Checked(state.Theme == theme) + .Handler(async _ => + { + await state.SetThemeAsync(theme); + return BaletteCommandResult.Back; + }) + .Build(); + + private static BaletteCommand Toggle(string title, string id, bool value, Action toggle, string? shortcut, string keywords) + { + var builder = BaletteCmd.New(title) + .Id($"toggle:{id}") + .Group("Preferences") + .Keywords(keywords.Split(' ')) + .Tags(CommandTag) + .Checked(value) + .Handler(_ => + { + toggle(); + return Task.FromResult(BaletteCommandResult.KeepOpen); + }); + + if (shortcut is not null) + { + builder.Shortcut(shortcut); + } + + return builder.Build(); + } + + private static string Capitalize(string s) => s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..]; +} diff --git a/src/Balette/Demo/Client/Services/DemoState.cs b/src/Balette/Demo/Client/Services/DemoState.cs new file mode 100644 index 0000000000..b8f8316074 --- /dev/null +++ b/src/Balette/Demo/Client/Services/DemoState.cs @@ -0,0 +1,226 @@ +using Microsoft.JSInterop; + +namespace Bit.Balette.Demo.Client.Services; + +/// +/// The application state the demo's commands act on - a theme, three preferences, a project with labels and an +/// activity log - and the way the layout's palette is presented. It is scoped, so the layout, every page and every +/// command handler share one instance: a value changed from the palette shows on the page, and one changed on the +/// page shows in the palette the next time it renders. +/// +public sealed class DemoState +{ + // The same key the inline script in App.razor reads before the first paint. + private const string ThemeStorageKey = "bit-balette-demo.theme"; + + private readonly IJSRuntime _js; + private readonly List _log = []; + + public DemoState(IJSRuntime js) + { + _js = js; + } + + /// Raised after any value changed. The layout re-registers its commands on it, so toggles show their new state. + public event Action? Changed; + + /// auto, light or dark, written to <html data-theme> - which the site and the palette both follow. + public string Theme { get; private set; } = "auto"; + + public bool WordWrap { get; private set; } = true; + + public bool Notifications { get; private set; } + + public bool ShowMinimap { get; private set; } = true; + + public string ProjectName { get; private set; } = "Acme Dashboard"; + + public IReadOnlyList Labels { get; private set; } = ["bug"]; + + /// Newest first, capped at 30 entries. + public IReadOnlyList Log => _log; + + // ---- Presentation of the layout's palette ------------------------------------------------------------------ + + /// The palette's own Theme parameter. Null follows the page; a value wins over it. + public string? PaletteTheme { get; private set; } + + /// An accent colour poured into the palette's custom properties, or null for the stylesheet's. + public string? Accent { get; private set; } + + /// The dialog width in pixels, or null for the stylesheet's (640, or 920 with the detail pane). + public int? Width { get; private set; } + + /// The corner radius in pixels, or null for the stylesheet's. + public int? Radius { get; private set; } + + public bool ShowDetail { get; private set; } = true; + + public bool ShowFooter { get; private set; } = true; + + public bool HighlightMatches { get; private set; } = true; + + public bool GroupWhenSearching { get; private set; } = true; + + public bool Virtualize { get; private set; } + + /// + /// The palette's Style parameter: inline custom properties on the dialog, which win over every rule in the + /// stylesheet. The soft accent and the highlight are derived from the accent so one choice recolours all three. + /// + public string? PaletteStyle + { + get + { + var parts = new List(5); + if (Accent is not null) + { + parts.Add($"--bit-balette-accent:{Accent}"); + parts.Add($"--bit-balette-accent-soft:color-mix(in srgb, {Accent} 14%, transparent)"); + parts.Add($"--bit-balette-mark-bg:color-mix(in srgb, {Accent} 26%, transparent)"); + } + + if (Width is { } width) + { + parts.Add($"--bit-balette-width:{width}px"); + } + + if (Radius is { } radius) + { + parts.Add($"--bit-balette-radius:{radius}px"); + } + + return parts.Count == 0 ? null : string.Join(';', parts); + } + } + + // ---- Theme --------------------------------------------------------------------------------------------------- + + public async Task SetThemeAsync(string theme) + { + Theme = theme; + try + { + await _js.InvokeVoidAsync("document.documentElement.setAttribute", "data-theme", theme); + await _js.InvokeVoidAsync("localStorage.setItem", ThemeStorageKey, theme); + } + catch (Exception ex) when (ex is JSException or InvalidOperationException or JSDisconnectedException) + { + // Storage can be disabled; the theme still applies for this visit. + } + + Record($"Theme set to {theme}"); + } + + /// + /// Picks up the theme the inline script in App.razor already applied from storage, so a reload does not show + /// "auto" in the palette while the page is dark. Called once the layout runs interactively. + /// + public async Task RestoreThemeAsync() + { + try + { + var applied = await _js.InvokeAsync("document.documentElement.getAttribute", "data-theme"); + if (applied is "light" or "dark" && applied != Theme) + { + Theme = applied; + Changed?.Invoke(); + } + } + catch (Exception ex) when (ex is JSException or InvalidOperationException or JSDisconnectedException) + { + } + } + + // ---- Preferences --------------------------------------------------------------------------------------------- + + public void ToggleWordWrap() + { + WordWrap = !WordWrap; + Record($"Word wrap {(WordWrap ? "on" : "off")}"); + } + + public void ToggleNotifications() + { + Notifications = !Notifications; + Record($"Notifications {(Notifications ? "on" : "off")}"); + } + + public void ToggleMinimap() + { + ShowMinimap = !ShowMinimap; + Record($"Minimap {(ShowMinimap ? "shown" : "hidden")}"); + } + + public async Task ResetPreferencesAsync() + { + WordWrap = true; + Notifications = false; + ShowMinimap = true; + await SetThemeAsync("auto"); + } + + public void Rename(string name) + { + ProjectName = name; + Record($"Project renamed to “{name}”"); + } + + public void SetLabels(IEnumerable labels) + { + Labels = [.. labels]; + Record(Labels.Count == 0 ? "Labels cleared" : $"Labels: {string.Join(", ", Labels)}"); + } + + // ---- Palette presentation ------------------------------------------------------------------------------------ + + public void SetPaletteTheme(string? theme) => Set(() => PaletteTheme = theme); + + public void SetAccent(string? accent) => Set(() => Accent = accent); + + public void SetWidth(int? width) => Set(() => Width = width); + + public void SetRadius(int? radius) => Set(() => Radius = radius); + + public void SetShowDetail(bool value) => Set(() => ShowDetail = value); + + public void SetShowFooter(bool value) => Set(() => ShowFooter = value); + + public void SetHighlightMatches(bool value) => Set(() => HighlightMatches = value); + + public void SetGroupWhenSearching(bool value) => Set(() => GroupWhenSearching = value); + + public void SetVirtualize(bool value) => Set(() => Virtualize = value); + + public void ResetPresentation() => Set(() => + { + PaletteTheme = null; + Accent = null; + Width = null; + Radius = null; + ShowDetail = true; + ShowFooter = true; + HighlightMatches = true; + GroupWhenSearching = true; + Virtualize = false; + }); + + // ---- Activity log -------------------------------------------------------------------------------------------- + + public void Record(string message) + { + _log.Insert(0, $"{DateTime.Now:HH:mm:ss} {message}"); + if (_log.Count > 30) + { + _log.RemoveAt(_log.Count - 1); + } + + Changed?.Invoke(); + } + + private void Set(Action change) + { + change(); + Changed?.Invoke(); + } +} diff --git a/src/Balette/Demo/Client/Services/FakeData.cs b/src/Balette/Demo/Client/Services/FakeData.cs new file mode 100644 index 0000000000..0617071818 --- /dev/null +++ b/src/Balette/Demo/Client/Services/FakeData.cs @@ -0,0 +1,164 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Balette.Demo.Client.Services; + +public sealed record Person(string Name, string Role, string Email); + +public sealed record Issue(int Number, string Title, string State, string[] Labels); + +public sealed record Doc(string Title, string Path, string Excerpt); + +/// +/// An in-memory directory standing in for the API a real application would query. It is resolved from DI +/// wherever it is used - by the sources through , by the lazy +/// "Assign to…" page through - which is where a real HTTP client +/// would come from too. +/// +public sealed class FakeDirectory +{ + public IReadOnlyList People { get; } = + [ + new("Ada Lovelace", "Engineering", "ada@example.com"), + new("Grace Hopper", "Engineering", "grace@example.com"), + new("Alan Turing", "Research", "alan@example.com"), + new("Katherine Johnson", "Data", "katherine@example.com"), + new("Margaret Hamilton", "Engineering", "margaret@example.com"), + new("Linus Torvalds", "Platform", "linus@example.com"), + new("Barbara Liskov", "Architecture", "barbara@example.com"), + new("Dennis Ritchie", "Platform", "dennis@example.com"), + new("Hedy Lamarr", "Research", "hedy@example.com"), + new("Tim Berners-Lee", "Web", "tim@example.com"), + ]; + + public IReadOnlyList Issues { get; } = + [ + new(101, "Palette flickers when opened during navigation", "open", ["bug", "ui"]), + new(102, "Add vim keybindings", "closed", ["enhancement"]), + new(103, "Support Ctrl+Shift+P as alternative toggle", "closed", ["enhancement"]), + new(104, "Recents should survive page reload", "open", ["bug"]), + new(105, "Nested pages lose query on back", "open", ["bug"]), + new(106, "Document theming variables", "open", ["docs"]), + new(107, "Virtualize long result lists", "open", ["performance"]), + new(108, "Fuzzy match should prefer word starts", "closed", ["search"]), + new(109, "Screen reader announces result count", "closed", ["a11y"]), + new(110, "Mobile layout: full screen dialog", "open", ["ui"]), + ]; + + // Every entry points at the demo page that shows it, so a documentation hit is a working link. + public IReadOnlyList Docs { get; } = + [ + new("Getting started", "/#get-started", "Install the package, add the services, put the component in your layout."), + new("Registering commands", "/commands", "Fluent builder, declarative markup, page-scoped registration."), + new("Nested pages and prompts", "/pages", "Multi-step flows, action panels, free-text prompts, multi-select."), + new("Search operators", "/search#operators", "Exact, prefix, suffix and negated terms, combined with AND."), + new("Modes and prefixes", "/modes", "Scope the palette with > # @ ? prefixes like VS Code and GitHub."), + new("Async sources", "/modes#sources", "Debounced, cancellable searches against your APIs."), + new("Keyboard shortcuts", "/keyboard#shortcuts", "Global chords and sequences like G then I."), + new("Accessibility", "/keyboard#accessibility", "ARIA combobox pattern, focus management, live announcements."), + new("Theming", "/theming", "CSS custom properties, dark mode, templates, unstyled mode."), + new("Large registries", "/performance", "Thousands of commands, measured per keystroke."), + ]; +} + +/// +/// Simulates a remote people search with latency. is true, so the palette re-scores +/// what comes back with its own matcher: the server only has to return candidates, and the highlighting matches +/// the rest of the list. +/// +public sealed class PeopleSource(SourceLog log) : IBaletteSource +{ + public string Name => "People"; + + public int MinQueryLength => 1; + + public bool ApplyMatcher => true; + + public Task> SearchAsync(string query, BaletteSourceContext context, CancellationToken cancellationToken) + => log.TrackAsync(Name, query, async ct => + { + await Task.Delay(250, ct); + var directory = context.Services.GetRequiredService(); + return directory.People + .Where(p => p.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || p.Role.Contains(query, StringComparison.OrdinalIgnoreCase)) + .Select(p => BaletteCmd.New(p.Name) + .Id($"person:{p.Email}") + .Subtitle(p.Role) + .Keywords(p.Email) + .Icon("demo-avatar") + .Data(p) + // Tab opens these next to the built-in pin and forget actions. + .Actions( + BaletteCmd.Action("Copy e-mail", _ => Task.FromResult(BaletteCommandResult.Toast($"Copied {p.Email}"))).Build(), + BaletteCmd.Action("Mention in issue", _ => Task.FromResult(BaletteCommandResult.Toast($"Mentioned {p.Name}"))).Build()) + .Handler(_ => Task.FromResult(BaletteCommandResult.Toast($"Opening the profile of {p.Name}"))) + .Build()) + .ToList(); + }, cancellationToken); +} + +/// +/// A source whose own order is kept (ApplyMatcher is false): open issues first, newest first, the way a +/// server that already sorted by relevance would answer. The results are listed as one block under the source's name. +/// +public sealed class IssueSource(SourceLog log) : IBaletteSource +{ + public string Name => "Issues"; + + // Zero asks on the empty query too, so the mode opens on a list instead of a blank box. + public int MinQueryLength => 0; + + public Task> SearchAsync(string query, BaletteSourceContext context, CancellationToken cancellationToken) + => log.TrackAsync(Name, query, async ct => + { + await Task.Delay(180, ct); + IEnumerable hits = context.Services.GetRequiredService().Issues; + if (query.Length > 0) + { + hits = int.TryParse(query.TrimStart('#'), out var number) + ? hits.Where(i => i.Number.ToString().StartsWith(number.ToString(), StringComparison.Ordinal)) + : hits.Where(i => i.Title.Contains(query, StringComparison.OrdinalIgnoreCase) || i.Labels.Any(l => l.Contains(query, StringComparison.OrdinalIgnoreCase))); + } + + return hits + .OrderByDescending(i => i.State == "open") + .ThenByDescending(i => i.Number) + .Select(i => BaletteCmd.New($"#{i.Number} {i.Title}") + .Id($"issue:{i.Number}") + .Subtitle(string.Join(" · ", i.Labels)) + .Badge(i.State) + .Kind(BaletteCommandKind.Navigation) + .Handler(_ => Task.FromResult(BaletteCommandResult.Toast($"Opened issue #{i.Number}"))) + .Build()) + .ToList(); + }, cancellationToken); +} + +/// +/// Searched in the default mode next to the registry. It returns every document and lets the matcher decide, +/// and its low priority keeps a command ahead of an equally good documentation hit. +/// +public sealed class DocsSource(SourceLog log) : IBaletteSource +{ + public string Name => "Documentation"; + + // One letter matches nearly every document; two is where a hit starts to mean something. + public int MinQueryLength => 2; + + public bool ApplyMatcher => true; + + public Task> SearchAsync(string query, BaletteSourceContext context, CancellationToken cancellationToken) + => log.TrackAsync(Name, query, async ct => + { + await Task.Delay(300, ct); + return context.Services.GetRequiredService().Docs + .Select(d => BaletteCmd.New(d.Title) + .Id($"doc:{d.Path}") + .Subtitle(d.Excerpt) + .Href(d.Path) + // Href makes a command a navigation; the kind is set after it to keep the help icon. + .Kind(BaletteCommandKind.Help) + .Priority(BaletteCommandPriority.Low) + .Build()) + .ToList(); + }, cancellationToken); +} diff --git a/src/Balette/Demo/Client/Services/SourceLog.cs b/src/Balette/Demo/Client/Services/SourceLog.cs new file mode 100644 index 0000000000..ea49dcbfd2 --- /dev/null +++ b/src/Balette/Demo/Client/Services/SourceLog.cs @@ -0,0 +1,110 @@ +using System.Diagnostics; + +namespace Bit.Balette.Demo.Client.Services; + +/// +/// Records every call the palette makes into the demo's async sources and how it ended, so the modes page can +/// show debouncing and cancellation happening rather than describe them. A keystroke the debounce swallowed never +/// reaches a source at all; a search still in flight when the next one starts ends as cancelled. +/// +/// +/// It is a singleton because the sources are: they are created once, with the options. The sources only run in +/// the browser - the palette is never opened during prerendering - so on WebAssembly that is one log per visitor. +/// +public sealed class SourceLog +{ + private const int Capacity = 40; + + private readonly Lock _gate = new(); + private readonly List _calls = []; + + public event Action? Changed; + + /// Newest first. + public IReadOnlyList Calls + { + get + { + lock (_gate) + { + return [.. _calls]; + } + } + } + + public async Task> TrackAsync(string source, string query, Func>> search, CancellationToken cancellationToken) + { + var call = new SourceCall(source, query, DateTime.Now); + lock (_gate) + { + _calls.Insert(0, call); + if (_calls.Count > Capacity) + { + _calls.RemoveAt(_calls.Count - 1); + } + } + + Changed?.Invoke(); + var stopwatch = Stopwatch.StartNew(); + try + { + var results = await search(cancellationToken); + call.Complete(results.Count, stopwatch.Elapsed); + return results; + } + catch (OperationCanceledException) + { + call.Cancel(stopwatch.Elapsed); + throw; + } + finally + { + Changed?.Invoke(); + } + } + + public void Clear() + { + lock (_gate) + { + _calls.Clear(); + } + + Changed?.Invoke(); + } +} + +public enum SourceCallOutcome +{ + Running, + Completed, + Cancelled, +} + +public sealed class SourceCall(string source, string query, DateTime startedAt) +{ + public string Source { get; } = source; + + public string Query { get; } = query; + + public DateTime StartedAt { get; } = startedAt; + + public SourceCallOutcome Outcome { get; private set; } + + public int ResultCount { get; private set; } + + public TimeSpan Duration { get; private set; } + + internal void Complete(int count, TimeSpan duration) + { + Outcome = SourceCallOutcome.Completed; + ResultCount = count; + Duration = duration; + } + + internal void Cancel(TimeSpan duration) + { + Outcome = SourceCallOutcome.Cancelled; + Duration = duration; + } +} diff --git a/src/Balette/Demo/Client/_Imports.razor b/src/Balette/Demo/Client/_Imports.razor new file mode 100644 index 0000000000..486d49e514 --- /dev/null +++ b/src/Balette/Demo/Client/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Bit.Balette.Demo.Client +@using Bit.Balette.Demo.Client.Layout +@using Bit.Balette.Demo.Client.Services +@using Bit.Balette diff --git a/src/Balette/Demo/Server/Bit.Balette.Demo.Server.csproj b/src/Balette/Demo/Server/Bit.Balette.Demo.Server.csproj new file mode 100644 index 0000000000..ffa4d923cc --- /dev/null +++ b/src/Balette/Demo/Server/Bit.Balette.Demo.Server.csproj @@ -0,0 +1,73 @@ + + + + net10.0 + enable + enable + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BaletteSource/Library/Components/%(Filename)%(Extension) + + + + + + + + BaletteSource/Demo/Client/%(RecursiveDir)%(Filename)%(Extension) + + + + + + + BaletteSource/Demo/Server/Components/%(RecursiveDir)%(Filename)%(Extension) + + + + diff --git a/src/Balette/Demo/Server/Components/App.razor b/src/Balette/Demo/Server/Components/App.razor new file mode 100644 index 0000000000..af8fd096d0 --- /dev/null +++ b/src/Balette/Demo/Server/Components/App.razor @@ -0,0 +1,49 @@ + + + + + + + + + + + @* The library's stylesheet is linked by the host page - Bit.Balette does not add it by itself - and it + comes first: the site's own one restyles the palette through the library's custom properties, which + only works if it comes later in the cascade. *@ + + + + @* A stored theme is applied here, in the head, rather than once the app is interactive: both + stylesheets key off the data-theme attribute, and setting it after the first paint would show + the reader a flash of the other theme. "auto", or nothing stored, leaves it to the system. *@ + + + + + + + + + @* InteractiveWebAssembly rather than Auto or Server: the palette matches and ranks on every keystroke, + and in the browser that never waits on a network round trip. The pages are still prerendered, so the + first response carries them. *@ + +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + diff --git a/src/Balette/Demo/Server/Components/Pages/Error.razor b/src/Balette/Demo/Server/Components/Pages/Error.razor new file mode 100644 index 0000000000..d154c1a4eb --- /dev/null +++ b/src/Balette/Demo/Server/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/src/Balette/Demo/Server/Components/_Imports.razor b/src/Balette/Demo/Server/Components/_Imports.razor new file mode 100644 index 0000000000..5d44eb1755 --- /dev/null +++ b/src/Balette/Demo/Server/Components/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Bit.Balette.Demo.Server +@using Bit.Balette.Demo.Client +@using Bit.Balette.Demo.Client.Layout +@using Bit.Balette.Demo.Server.Components +@using Bit.Balette diff --git a/src/Balette/Demo/Server/Controllers/McpController.cs b/src/Balette/Demo/Server/Controllers/McpController.cs new file mode 100644 index 0000000000..340db8e95f --- /dev/null +++ b/src/Balette/Demo/Server/Controllers/McpController.cs @@ -0,0 +1,177 @@ +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using Bit.Balette.Demo.Server.Dtos; +using Bit.Balette.Demo.Server.Services; + +namespace Bit.Balette.Demo.Server.Controllers; + +/// +/// The Balette MCP server: the tools an AI agent calls to write command-palette code with +/// Bit.Balette without guessing at its API. +/// +/// Every tool answers from the shipped library or from this site's own content - the XML +/// documentation compiled into Bit.Balette, the README, the shipped stylesheet and component markup, +/// the library's own matcher and ranker, and the demo's source files - so an agent gets what the +/// current version actually does rather than a snapshot someone wrote down. The same methods are +/// exposed as plain HTTP GET endpoints under /api/mcp/..., which makes each of them inspectable from a +/// browser. +/// +/// +/// One rule holds across the reference tools: a tool takes the key of the thing you want and +/// answers with that thing; leave the key out and it answers with the index of what there is. That +/// is what a listing tool used to be - and a listing tool costs a name, a description and a schema +/// in every request an agent makes, forever, to say what an optional argument says for nothing. +/// +/// +/// Each tool carries the two annotations that mean something for a tool which only reads: +/// ReadOnly, which is what lets a client run it without stopping to ask a person, and +/// OpenWorld = false, which says the answers come from a closed, known body of material +/// rather than from the internet. +/// +/// +/// Only the two tools whose answer is machine-actionable - a ranked hit carrying its own follow-up +/// call, a per-command search report - set UseStructuredContent and publish an output schema. +/// The rest answer in Markdown: a structured answer goes over the wire twice, once as the object and +/// once as the JSON text the spec asks for on its behalf, and documentation read as Markdown is +/// smaller than either half. +/// +/// +[ApiController] +[McpServerToolType] +[Route("api/[controller]/[action]")] +public class McpController : ControllerBase +{ + // The README's longest sections and the demo's longest pages land far below the cap; it is + // there so a pathological answer cannot dominate a client's context window. + private const int MaxDocumentLength = 40_000; + + [HttpGet] + [McpServerTool(Name = nameof(SearchBalette), Title = "Search everything about Bit.Balette", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Searches everything known about Bit.Balette at once - the reference guide, every public type and member, the setup guides, the stylesheet's variables, classes and data-balette hooks, and the demo's source files - and returns the best matches, each with the exact follow-up call that returns its full text. Call it first whenever you do not already know which section or type holds the answer, then call the hit's tool verbatim. Example queries: 'nested page prompt', 'toggle keeps open', 'async source debounce', 'G then S shortcut', 'dark theme accent', 'Ctrl+K does nothing'.")] + public BaletteSearchResultDto SearchBalette( + [Description("What you are looking for, in the words you would use for it - a feature, a symptom, or a member name. A few words rank better than a whole sentence.")] string query, + [Description("How many hits to return. Clamped to 1..50.")] int limit = 12) + { + return BaletteSearchIndex.Search(query, limit); + } + + [HttpGet] + [McpServerTool(Name = nameof(GetBaletteSetupGuide), Title = "Setup guide for one render mode", ReadOnly = true, OpenWorld = false)] + [Description("Gets the complete wiring needed to add Bit.Balette to a Blazor app in one render mode: 'server' (InteractiveServer), 'wasm' (InteractiveWebAssembly, which is how this demo is built and comes with its real files), 'auto' (InteractiveAuto), 'standalone-wasm' or 'hybrid' (MAUI, WPF, Windows Forms). Call this before writing setup code - which DI containers register the services, where the stylesheet link goes (it is not injected), where the one goes and what Blazor Server changes differ per render mode.")] + public string GetBaletteSetupGuide( + // Declared rather than merely described: the values then sit in the JSON schema itself, + // where a model cannot pass another one past them, and a client offers them as completions. + [AllowedValues("server", "wasm", "auto", "standalone-wasm", "hybrid")] + [Description("The app's Blazor render mode.")] string renderMode) + { + return BaletteSetupGuide.Get(renderMode) + ?? $"'{renderMode}' is not a known render mode. Use one of: {string.Join(", ", BaletteSetupGuide.RenderModes)}."; + } + + [HttpGet] + [McpServerTool(Name = nameof(GetBaletteGuideSection), Title = "Read a reference-guide section", ReadOnly = true, OpenWorld = false)] + [Description("Gets one section of the Bit.Balette reference guide (the library's README) as Markdown, with its code samples - e.g. 'Getting started', 'Commands', 'Declarative markup', 'Handlers and results', 'Nested pages', 'Search and ranking', 'Modes', 'Async sources', 'Keyboard', 'Theming', 'Hosting models', 'Options', 'Troubleshooting'. Sub-sections are included, and heading matching ignores case and punctuation. Omit the heading for the index of sections with their sizes.")] + public string GetBaletteGuideSection( + [Description("The section's heading, e.g. 'Nested pages'. Case and punctuation are ignored; a heading the guide uses twice is told apart as 'Parent > Heading', as the index shows. Omit it for the index of sections.")] string? heading = null) + { + if (string.IsNullOrWhiteSpace(heading)) return BaletteSourceCatalog.RenderGuideIndex(); + + var section = BaletteSourceCatalog.GetGuideSection(heading); + + if (section is not null) return Truncate(section); + + var candidates = BaletteSourceCatalog.GuideSections + .Where(s => s.Heading.Contains(heading, StringComparison.OrdinalIgnoreCase) || heading.Contains(s.Heading, StringComparison.OrdinalIgnoreCase)) + .Select(s => $"'{s.Key}'") + .Distinct(StringComparer.Ordinal) + .Take(10) + .ToArray(); + + return candidates.Length > 0 + ? $"The guide has no section called '{heading}'. Did you mean: {string.Join(", ", candidates)}?" + : $"The guide has no section called '{heading}'. Call GetBaletteGuideSection with no heading for the index of sections, or SearchBalette to find the topic."; + } + + [HttpGet] + [McpServerTool(Name = nameof(GetBaletteApi), Title = "Public API reference", ReadOnly = true, OpenWorld = false)] + [Description("Gets the public API of Bit.Balette as Markdown, read out of the shipped assembly: one type with every Blazor parameter, property, method, event or enum value it has - each with its C# type, its real default value and its documentation. Omit the type name for the index of every public type. Call it before using a member you are unsure about, e.g. 'Balette' (the palette component), 'BaletteCommand', 'BaletteCommandBuilder', 'BaletteCmd', 'BaletteCommandResult', 'IBaletteService', 'BaletteOptions', 'BaletteCommandMode', 'IBaletteSource', 'BaletteItem'.")] + public string GetBaletteApi( + [Description("The type's name without its namespace, e.g. 'BaletteCommand' or 'BaletteOptions'. Omit it for the index of types.")] string? typeName = null) + { + if (string.IsNullOrWhiteSpace(typeName)) return BaletteApiCatalog.RenderIndex(); + + var rendered = BaletteApiCatalog.RenderType(typeName); + + if (rendered is not null) return Truncate(rendered); + + var candidates = BaletteApiCatalog.Types + .Where(t => t.Name.Contains(typeName, StringComparison.OrdinalIgnoreCase)) + .Select(t => t.Name) + .Take(10) + .ToArray(); + + return candidates.Length > 0 + ? $"Bit.Balette has no public type called '{typeName}'. Did you mean: {string.Join(", ", candidates)}?" + : $"Bit.Balette has no public type called '{typeName}'. Every public type is prefixed 'Balette' (e.g. 'BaletteCommand' rather than 'Command', 'Balette' rather than 'CommandPalette'); call GetBaletteApi with no type name for the index."; + } + + [HttpGet] + [McpServerTool(Name = nameof(GetBaletteThemeVariables), Title = "List the theme variables", ReadOnly = true, OpenWorld = false)] + [Description("Lists every CSS custom property the default Bit.Balette theme is built from - layout and palette - with its light value and its dark override, read out of the shipped stylesheet, as Markdown tables; where each override reaches (:root, dialog[data-balette=\"dialog\"], the Style and Theme parameters); the values the component sets itself; and the data-balette hooks and state attributes an Unstyled palette is styled by. Restyle the palette by overriding these variables rather than the library's selectors.")] + public string GetBaletteThemeVariables() + { + return BaletteThemeCatalog.Render(); + } + + [HttpGet] + [McpServerTool(Name = nameof(InspectBaletteSearch), Title = "Explain how a query ranks commands", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Runs Bit.Balette's own matcher and ranker on a query over a list of commands and reports what the palette makes of it: per command whether it matched and, if not, which term failed and why (a !negated term, smart case, an operator), its score, its rank, its place in the ranking and the row the palette lists it on, the field each term matched on, and the highlighted characters - with notes on operators, field weights and the priority and frecency formula. Without commands it searches this demo's registry. Use it to see why a command does not show up, or ranks below another.")] + public BaletteSearchInspectionDto InspectBaletteSearch( + [Description("The query, as typed into the palette - several terms are ANDed, and 'exact, ^prefix, suffix$ and !negate are operators. An empty string shows the empty-query order.")] string? query, + [Description("The commands to search, as a JSON array of { \"title\", \"subtitle\", \"keywords\": [], \"group\", \"description\", \"tags\": [], \"priority\" } objects (or of plain titles), or one title per line. Omit it to search this demo's own registry.")] string? commands = null, + [AllowedValues("fuzzy", "fuzzy-ignore-case", "substring")] + [Description("The matcher: 'fuzzy' (BaletteFuzzyMatcher.Default, smart case - the palette's default), 'fuzzy-ignore-case' or 'substring' (BaletteSubstringMatcher).")] string matcher = "fuzzy", + [Description("The palette's GroupWhenSearching: true (the default) lists matches under their group headings, false as one flat ranked list.")] bool groupWhenSearching = true, + [Description("How many commands to report on - the listed ones first, then those that did not match. Clamped to 1..200.")] int limit = 20) + { + return BaletteSearchLab.Inspect(query, commands, matcher, groupWhenSearching, limit); + } + + [HttpGet] + [McpServerTool(Name = nameof(GetBaletteSourceFile), Title = "Read a source file", ReadOnly = true, OpenWorld = false)] + [Description("Gets one working Bit.Balette source file verbatim - e.g. 'Demo/Client/Services/DemoCommands.cs' (a whole app's registry: navigation, prompts, multi-select, lazy pages, toggles, destructive commands), 'Demo/Client/Pages/Commands.razor' (page-scoped commands and the service API), 'Demo/Client/Pages/PagesAndFlows.razor', 'Demo/Client/Services/FakeData.cs' (async sources), 'Demo/Client/Layout/MainLayout.razor' (one palette with declarative items and a detail pane), 'Library/Styles/bit-balette.css' (the shipped stylesheet) or 'Library/Components/Balette.razor' (the markup it styles). Omit the path for the index of files. These are worked examples - for what a parameter means, GetBaletteApi answers in a fraction of the characters.")] + public string GetBaletteSourceFile( + [Description("The file's path as the index lists it, e.g. 'Demo/Client/Pages/Commands.razor'. Omit it for the index of files.")] string? path = null) + { + if (string.IsNullOrWhiteSpace(path)) return BaletteSourceCatalog.RenderSourceIndex(); + + var content = BaletteSourceCatalog.GetSourceFile(path); + + if (content is null) + { + var candidates = BaletteSourceCatalog.SourceFiles + .Where(f => f.Path.Contains(path, StringComparison.OrdinalIgnoreCase)) + .Select(f => f.Path) + .Take(10) + .ToArray(); + + return candidates.Length > 0 + ? $"No source file at '{path}'. Did you mean: {string.Join(", ", candidates)}?" + : $"No source file at '{path}'. Call GetBaletteSourceFile with no path for the index of files."; + } + + return Truncate(content); + } + + internal static string Truncate(string text) + { + if (text.Length <= MaxDocumentLength) return text; + + // Never between the two halves of a surrogate pair: half a pair is not text, and a client + // that re-encodes the answer turns it into a replacement character or rejects it outright. + var cut = char.IsHighSurrogate(text[MaxDocumentLength - 1]) ? MaxDocumentLength - 1 : MaxDocumentLength; + + return $"{text[..cut]}\n\n[truncated - the full text is longer than {MaxDocumentLength} characters]"; + } +} diff --git a/src/Balette/Demo/Server/Controllers/McpPrompts.cs b/src/Balette/Demo/Server/Controllers/McpPrompts.cs new file mode 100644 index 0000000000..4d85de5e57 --- /dev/null +++ b/src/Balette/Demo/Server/Controllers/McpPrompts.cs @@ -0,0 +1,134 @@ +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Bit.Balette.Demo.Server.Controllers; + +/// +/// Ready-made workflows for the three things people actually ask a command-palette library for: add +/// it to an app, build a palette feature with it, and work out why a palette does not behave. +/// +/// Each prompt spends its words on the order to call the tools in and on the mistakes that are +/// expensive to make - the ones that produce a palette that silently never opens, opens unstyled, or +/// shows a stale command, rather than a compiler error - because the failure mode of an agent with a +/// handful of tools is not ignorance, it is calling them in a sequence that skips the check which +/// would have caught the bug. +/// +/// +[McpServerPromptType] +public static class McpPrompts +{ + [McpServerPrompt(Name = "add-balette-to-app", Title = "Add Bit.Balette to a Blazor app")] + [Description("Walks through adding Bit.Balette to an existing Blazor app, in the right order for its render mode.")] + public static string AddBaletteToApp( + // The values go into the prompt's own argument schema, which is what a client offers as + // completions - so picking the mode is a choice from a list rather than a spelling test. + [AllowedValues("unknown", "server", "wasm", "auto", "standalone-wasm", "hybrid")] + [Description("The app's Blazor render mode: server, wasm, auto, standalone-wasm or hybrid. Pass 'unknown' to have it determined from the project first.")] string renderMode = "unknown") + { + return $""" + Add Bit.Balette to this Blazor app. Its render mode is: {renderMode}. + + Work in this order: + + 1. If the render mode is 'unknown', determine it from the project first: look for + AddInteractiveServerComponents / AddInteractiveWebAssemblyComponents in Program.cs, for a separate + .Client project, for a MAUI/WPF/Windows Forms BlazorWebView host, and for where `@rendermode` is set - + on `` (global interactivity) or per page - then continue with what you find. + 2. Call `GetBaletteSetupGuide` with that render mode and follow it. Registering the services in only one of a + Blazor Web App's two DI containers is the most common setup bug - it fails while prerendering, not at + compile time. Call `AddBitBaletteServices` in BOTH `Program.cs` files, through one shared extension method + so both get the same options. + 3. Link `` in the host page (`App.razor`, + or `wwwroot/index.html`), before the app's own stylesheet. It is NOT injected: without it the palette opens + unstyled. + 4. Render exactly one ``, in the main layout - and only if that layout is rendered interactively. + In a statically rendered layout Ctrl+K does nothing; with per-page interactivity, wrap it in a component + that has an interactive render mode. + 5. Add `@using Bit.Balette` to the `_Imports.razor` of every project with palette markup or commands. If you + hold the component in a field inside a namespace under `Bit` (other than `Bit.Balette`), type it + `Bit.Balette.Balette` - there, `Balette` alone is the namespace. + 6. Register the app-wide commands once, from the layout, with ``. + 7. Build the app and fix what the compiler says. + + Show me the diff for each file you change, and say explicitly which DI containers you registered the + services in and where the stylesheet is linked. + """; + } + + [McpServerPrompt(Name = "implement-balette-feature", Title = "Implement a command-palette feature")] + [Description("Implements a command-palette feature with Bit.Balette - commands, nested pages, prompts, multi-select, modes, async sources, shortcuts, theming - using its real API rather than a guessed one.")] + public static string ImplementBaletteFeature( + [Description("What the palette should do, in your own words - e.g. 'a rename command that asks for the new name' or 'search our API for documents as the user types'.")] string feature) + { + return $""" + Implement this with Bit.Balette: {feature} + + Work in this order: + + 1. Call `SearchBalette` with the request above. It searches the guide, every public member, the setup guides, + the stylesheet and the demo's sources at once, and every hit tells you the exact follow-up call to make. + 2. Read the best hit in full with the call it hands you, before writing any code. + 3. Call `GetBaletteApi` for every Balette type you are about to use, and match the member names, types and + defaults exactly. There is one package and one namespace, `Bit.Balette`, and every public type is prefixed + `Balette` - the component is `Balette`, not `CommandPalette`; do not borrow names from cmdk, kbar or another + palette library. + 4. Commands are immutable. Give a command an explicit `Id` whenever its title can change (a toggle's label, a + translated title): a derived id changes with the title, and recents, pins and `OpenAsync(pageCommandId: ...)` + lose it. To show a new state, hand `BaletteCommands` a new list, call `Update`, or register again - never + expect a changed field to show up. + 5. Commands a page registers leave with the page: use ``, or keep the `IDisposable` that + `Register` returns and dispose it in the page's `Dispose`. + 6. Prefer the library's own mechanism over hand-rolling one: `BaletteCommandResult.Prompt` over a dialog of your + own, `MultiSelect()` with `Ctrl+Enter` over tracking checkboxes, `Destructive("message")` (Enter twice) over + a confirmation step, `Children` or `BaletteCommandResult.Page` over a second palette, a `BaletteCommandMode` + over filtering yourself, an `IBaletteSource` - `ApplyMatcher = true` to have the palette re-rank what it + returns, false to keep the server's order - over calling your API from the query callback, `Shortcut("G S")` + over listening for keys, and the `--bit-balette-*` variables (`GetBaletteThemeVariables`) over overriding the + library's selectors. + 7. If the feature depends on what a query finds - keywords, priority, operators - check it with + `InspectBaletteSearch`, which runs the library's own matcher and ranker over your commands. + 8. Write the code, then say which tool call each non-obvious choice came from. + + If the demo already contains a working example of this, fetch it with `GetBaletteSourceFile` (the index lists + them; `Demo/Client/Services/DemoCommands.cs` holds a whole app's registry) and follow its shape rather than + inventing a new one. + """; + } + + [McpServerPrompt(Name = "debug-balette-palette", Title = "Debug a command palette")] + [Description("Diagnoses a Bit.Balette problem - Ctrl+K does nothing, the palette opens unstyled, a command is missing, ranks oddly or shows a stale state, a shortcut does not fire, a source returns nothing.")] + public static string DebugBalettePalette( + [Description("What goes wrong, with the command, the query or the page involved if you know them.")] string symptom) + { + return $""" + Diagnose this Bit.Balette problem: {symptom} + + Work in this order: + + 1. Check the setup against `GetBaletteSetupGuide` for this render mode first: `AddBitBaletteServices` in every + DI container that renders the palette (both projects of a Blazor Web App), the stylesheet linked by the host + page (it is not injected - an unstyled palette means the link is missing), exactly one ``, in a + layout that is rendered interactively (a statically rendered one is why Ctrl+K does nothing). Most "nothing + happens" reports end here. + 2. Call `SearchBalette` with the symptom, and read the guide section it points to - "Troubleshooting" lists the + common ones, "Hosting models" covers render modes, "Keyboard" covers shortcuts that do not fire in text boxes, + "Async sources" covers debouncing, cancellation and sources that throw. + 3. For a command that does not show up or ranks oddly, run the query through `InspectBaletteSearch` with the + commands involved: it runs the library's own matcher and ranker and says which term failed - a `!negated` + term, a capital letter that made a term case-sensitive, an operator - and what each field scored. A source + with `ApplyMatcher = true` is re-scored the same way; with false its own order is kept. + 4. For a stale toggle or value, remember commands are immutable: the fix is a new list for `BaletteCommands`, + `Update`, or registering again. For recents or pins that forget a command, check whether its `Id` is derived + from a title that changed. For commands still listed after leaving a page, check that the page disposes what + it registered. + 5. For a destructive command that runs at once, check it was given a message - `Destructive("...")` or `Confirm` + - since only a message makes Enter arm it first. + 6. Confirm the API you are relying on with `GetBaletteApi` before concluding it is a bug - check the actual + default of the option involved. A field typed `Balette` that does not compile inside a namespace under `Bit` + needs `Bit.Balette.Balette`. + + Tell me the cause and the fix, and cite the tool call that established it. + """; + } +} diff --git a/src/Balette/Demo/Server/Controllers/McpResources.cs b/src/Balette/Demo/Server/Controllers/McpResources.cs new file mode 100644 index 0000000000..368c529019 --- /dev/null +++ b/src/Balette/Demo/Server/Controllers/McpResources.cs @@ -0,0 +1,82 @@ +using ModelContextProtocol.Server; +using ModelContextProtocol.Protocol; +using System.ComponentModel; +using Bit.Balette.Demo.Server.Services; + +namespace Bit.Balette.Demo.Server.Controllers; + +/// +/// The same body of knowledge the tools serve, exposed as MCP resources. +/// +/// Tools are for an agent that has decided what it needs; resources are for a client that wants to +/// attach documentation to a conversation up front, or let a person browse and pin it. Both read +/// the same catalogs, so neither can go stale relative to the other - and where a tool and a +/// resource take the same argument, they resolve it through the same method, so a key that works +/// in one cannot fail in the other. +/// +/// +/// A client showing balette://guide/{heading} in a picker cannot fill it in, because nothing +/// on the wire says which headings exist. Two things close that gap - the top-level guide sections +/// are also listed as concrete resources (see ListGuideSections, wired up in Program.cs), and +/// every placeholder here is completable through completion/complete. +/// +/// +[McpServerResourceType] +public static class McpResources +{ + [McpServerResource(UriTemplate = "balette://guide", Name = "balette-guide", Title = "Bit.Balette reference guide", MimeType = "text/markdown")] + [Description("The complete Bit.Balette reference guide (the library's README), every section in one document.")] + public static string Guide() => BaletteSourceCatalog.Readme; + + [McpServerResource(UriTemplate = "balette://guide/{heading}", Name = "balette-guide-section", Title = "Guide section", MimeType = "text/markdown")] + [Description("One section of the Bit.Balette reference guide by heading, e.g. balette://guide/Nested%20pages.")] + public static string GuideSection(string heading) + => BaletteSourceCatalog.GetGuideSection(heading) ?? $"The guide has no section called '{heading}'."; + + [McpServerResource(UriTemplate = "balette://api", Name = "balette-api", Title = "Bit.Balette public API", MimeType = "text/markdown")] + [Description("Every public Bit.Balette type with its kind and summary.")] + public static string ApiList() => BaletteApiCatalog.RenderIndex(); + + [McpServerResource(UriTemplate = "balette://api/{typeName}", Name = "balette-api-type", Title = "Type reference", MimeType = "text/markdown")] + [Description("The full reference of one Bit.Balette type, e.g. balette://api/BaletteCommand.")] + public static string ApiType(string typeName) + => BaletteApiCatalog.RenderType(typeName) ?? $"Bit.Balette has no public type called '{typeName}'."; + + [McpServerResource(UriTemplate = "balette://theme", Name = "balette-theme", Title = "Theme variables", MimeType = "text/markdown")] + [Description("Every CSS custom property of the default theme with its light value and its dark override, and the data-balette hooks of an unstyled palette.")] + public static string Theme() => BaletteThemeCatalog.Render(); + + [McpServerResource(UriTemplate = "balette://source/{path}", Name = "balette-source", Title = "Source file", MimeType = "text/plain")] + [Description("One source file of the demo or the library, e.g. balette://source/Demo%2FClient%2FServices%2FDemoCommands.cs.")] + public static string Source(string path) + => BaletteSourceCatalog.GetSourceFile(path) ?? $"No source file at '{path}'."; + + /// + /// The top-level sections of the guide as resources a client can list, show and pin, rather than + /// as a placeholder someone has to already know how to fill in. + /// + /// These sit alongside the attributed resources above rather than replacing the + /// balette://guide/{heading} template: the template is what actually serves a read - the + /// exact URI is looked for first and the templates are matched afterwards - while these give + /// resources/list something a person can choose from. The sub-sections, the API types and + /// the source files stay templated, because listing hundreds of them would bury the handful + /// anyone browses to. + /// + /// + public static IEnumerable ListGuideSections() + { + return BaletteSourceCatalog.GuideSections + .Where(section => section.Level == 2) + .Select(section => new Resource + { + Uri = $"balette://guide/{Uri.EscapeDataString(section.Key)}", + Name = $"balette-guide-{Slug(section.Heading)}", + Title = $"Guide: {section.Heading}", + Description = $"The '{section.Heading}' section of the Bit.Balette reference guide ({section.Lines} lines).", + MimeType = "text/markdown" + }); + } + + private static string Slug(string heading) + => string.Join('-', heading.ToLowerInvariant().Split(heading.Where(c => char.IsLetterOrDigit(c) is false).Distinct().ToArray(), StringSplitOptions.RemoveEmptyEntries)); +} diff --git a/src/Balette/Demo/Server/Dtos/BaletteMcpDtos.cs b/src/Balette/Demo/Server/Dtos/BaletteMcpDtos.cs new file mode 100644 index 0000000000..1ce233209e --- /dev/null +++ b/src/Balette/Demo/Server/Dtos/BaletteMcpDtos.cs @@ -0,0 +1,308 @@ +namespace Bit.Balette.Demo.Server.Dtos; + +/// +/// One heading of the library's README, which doubles as its reference guide. The guide is read +/// through GetBaletteGuideSection (and the balette://guide resources); this record is how the +/// catalog, the search index and the completions describe a section to each other. +/// +public record BaletteGuideSectionDto +{ + /// The heading text, e.g. "Search and ranking". + public required string Heading { get; init; } + + /// + /// What to pass to GetBaletteGuideSection for exactly this section: the heading itself, or - for a + /// heading the README uses more than once - "Parent > Heading". + /// + public required string Key { get; init; } + + /// Markdown heading level: 2 for a top-level section, 3 or 4 for a sub-section. + public required int Level { get; init; } + + /// The heading this one sits under, or null when this entry is itself level 2. + public string? Parent { get; init; } + + /// Number of markdown lines in the section (including its sub-sections). + public required int Lines { get; init; } +} + +/// A public type of the Bit.Balette assembly. +public record BaletteApiTypeDto +{ + public required string Name { get; init; } + + /// Component, Interface, Enum, Attribute, Delegate, Static class, Class, Struct or Record. + public required string Kind { get; init; } + + public string? Summary { get; init; } +} + +/// A member (parameter, property, method or enum value) of a public Bit.Balette type. +public record BaletteApiMemberDto +{ + public required string Name { get; init; } + + /// Parameter, Property, Field, Method, Event or EnumValue. "Parameter" means a Blazor [Parameter]. + public required string Kind { get; init; } + + /// The C# type of the member, or the signature's return type for a method. + public string? Type { get; init; } + + /// The method's parameter list, e.g. "(string query = null, string pageCommandId = null)". + public string? Signature { get; init; } + + /// The value the member has on a freshly created instance, when it could be determined. + public string? Default { get; init; } + + /// True for [EditorRequired] Blazor parameters. + public bool Required { get; init; } + + public string? Summary { get; init; } + + /// The XML remarks, when the member has any - they carry the caveats. + public string? Remarks { get; init; } +} + +/// The full reference of one public Bit.Balette type. +public record BaletteApiTypeDetailsDto +{ + public required string Name { get; init; } + + public required string FullName { get; init; } + + public required string Kind { get; init; } + + public string? BaseType { get; init; } + + public string[]? Implements { get; init; } + + public string? Summary { get; init; } + + public string? Remarks { get; init; } + + public required BaletteApiMemberDto[] Members { get; init; } +} + +/// A source file this server can hand out, retrievable through GetBaletteSourceFile. +public record BaletteSourceFileDto +{ + /// The path to pass to GetBaletteSourceFile, e.g. "Demo/Client/Pages/Commands.razor". + public required string Path { get; init; } + + /// Page (a working demo page), Shared (the commands, state and sources the pages share), Hosting (the wiring of the app) or Library (a shipped file). + public required string Kind { get; init; } + + /// The file's own header comment or page introduction, when it has one. + public string? Description { get; init; } + + public required int Lines { get; init; } +} + +/// One CSS custom property of the shipped stylesheet. +public record BaletteThemeVariableDto +{ + /// The property's name, e.g. "--bit-balette-accent". + public required string Name { get; init; } + + /// The group the stylesheet files it under, e.g. "Layout" or "Light palette". + public required string Group { get; init; } + + /// The value under the default (light) theme, as declared on :root. + public required string LightValue { get; init; } + + /// The value under the dark theme, when the dark rule overrides it. + public string? DarkValue { get; init; } + + /// + /// Where else the value is set: a stylesheet rule that redeclares the property for a state (the + /// detail pane widens the dialog), or the component writing it inline from an option - in which + /// case overriding it in CSS does not reach the elements under that inline declaration. + /// + public string[]? AlsoSetBy { get; init; } +} + +/// +/// One element the shipped components mark with a data-balette attribute - the stable hook an +/// unstyled palette, an ItemTemplate or a test is written against. +/// +public record BaletteMarkupHookDto +{ + /// The attribute's value, e.g. "item" for data-balette="item". + public required string Value { get; init; } + + /// The element it sits on, e.g. "div" or "dialog". + public required string Element { get; init; } + + /// The first bit-balette-* class the element carries, which the default stylesheet styles it by. + public string? Class { get; init; } + + /// The other data-* attributes the element carries - its state, e.g. "data-active". + public required string[] States { get; init; } + + /// The component whose markup renders it, e.g. "Balette" or "BaletteTrigger". + public required string Component { get; init; } +} + +/// One result of a search across everything this MCP server knows about Bit.Balette. +public record BaletteSearchHitDto +{ + /// What was found: "Guide section", "API component", "API parameter", "Theme variable", "CSS class", "Source file", ... + public required string Kind { get; init; } + + public required string Title { get; init; } + + /// Where the hit sits: the owning section, type or group. + public string? Context { get; init; } + + /// The tool call that returns the full text of this hit - call it verbatim. + public required string Tool { get; init; } + + /// The matching text, with a little of what surrounds it. + public required string Snippet { get; init; } +} + +/// What SearchBalette answers: the ranked hits, or - when there are none - why, and what to try instead. +public record BaletteSearchResultDto +{ + /// The matches, best first. Empty when nothing matched - Message then says what to do about it. + public required BaletteSearchHitDto[] Hits { get; init; } + + /// True when the ranking was cut short at the requested limit, so a narrower query would show more. + public bool HasMore { get; init; } + + /// + /// The words the query was actually searched by - punctuation, duplicates and filler words + /// removed. Present only when nothing matched, which is the one time it explains something the + /// caller could not work out from the query it just sent. + /// + public string[]? Terms { get; init; } + + /// Set only when Hits is empty: the reason, and the tool to fall back to. + public string? Message { get; init; } + + /// Titles that nearly matched, when nothing matched outright - a query worth retrying with. + public string[]? DidYouMean { get; init; } +} + +/// One term of an inspected query, the way the palette reads it. +public record BaletteQueryTermDto +{ + /// The term as it was typed, operator included, e.g. "!delete". + public required string Text { get; init; } + + /// The text the term is matched by once its operator is taken off, e.g. "delete". + public required string Term { get; init; } + + /// fuzzy, substring, exact, prefix, suffix or negate. + public required string Operator { get; init; } + + /// True when the term only matches with the same case - a fuzzy term with a capital letter (smart case). + public required bool CaseSensitive { get; init; } +} + +/// What one term of the query scored against one command, and the field that earned it. +public record BaletteTermScoreDto +{ + public required string Term { get; init; } + + /// The term's weighted score: the best field's score times that field's weight. Null when the term failed, or is a negation. + public double? Score { get; init; } + + /// The field that scored best: title, keyword 'x', subtitle, group, description or tag 'x'; for a negation, "not present". + public string? Field { get; init; } +} + +/// One command of an inspected search: whether it matched, how it scored, and where the palette lists it. +public record BaletteInspectedCommandDto +{ + public required string Title { get; init; } + + public string? Subtitle { get; init; } + + public string? Group { get; init; } + + /// The command's id, explicit or derived from its group and title. + public required string Id { get; init; } + + /// True when every term of the query matched. + public required bool Matched { get; init; } + + /// When it did not match: the term that failed, and why. + public string? Reason { get; init; } + + /// True for an AlwaysShow command that is listed although it did not match; its score and rank are minus infinity, so both are left out. + public bool? AlwaysShown { get; init; } + + /// The match score: the sum of every term's best weighted field score; 0 on the empty query. + public double? Score { get; init; } + + /// + /// What BaletteRanker sorted by: score + priority x PriorityWeight + ln(1 + frecency) x FrecencyWeight + /// with a query, priority x 1000 + frecency without one. + /// + public double? Rank { get; init; } + + /// The command's place in the ranking, 1 being the best. + public int? Order { get; init; } + + /// The row the palette shows it on, 1 being the first, once the ranking is grouped into headings. Null when it is not listed. + public int? Row { get; init; } + + /// The command's Priority, which the rank adds in. + public required int Priority { get; init; } + + /// What the match was made on, as the palette's own search lab says it: title, subtitle, keyword 'x', or group, description or tag. + public string? MatchedOn { get; init; } + + /// The keyword shown under the title when a keyword scored best. + public string? MatchedKeyword { get; init; } + + /// The characters of the title the palette highlights. + public int[]? TitlePositions { get; init; } + + /// The characters of the subtitle the palette highlights. + public int[]? SubtitlePositions { get; init; } + + /// The title with every highlighted run in [brackets], e.g. "[G]o to [S]ettings". + public string? HighlightedTitle { get; init; } + + /// The subtitle with every highlighted run in [brackets]. + public string? HighlightedSubtitle { get; init; } + + /// What each term contributed, so a rank can be traced back to the field that earned it. + public BaletteTermScoreDto[]? Terms { get; init; } +} + +/// +/// What Bit.Balette's own matcher and ranker make of a query over a list of commands - the answer the +/// running palette gives for the same query - plus the order it lists them in. +/// +public record BaletteSearchInspectionDto +{ + /// The query split into terms, each with its operator. + public required BaletteQueryTermDto[] Terms { get; init; } + + /// The matcher that ran: fuzzy, fuzzy-ignore-case or substring. + public required string Matcher { get; init; } + + /// Where the commands came from: this demo's registry, or the list that was passed in. + public required string Commands { get; init; } + + /// The ranking formula, with the weights it was computed with. + public required string Ranking { get; init; } + + /// How many commands were searched. + public required int Searched { get; init; } + + /// How many of them matched every term. + public required int Matched { get; init; } + + /// The listed commands in the order the palette shows them, then the ones that did not match, in the order they were given. + public required BaletteInspectedCommandDto[] Results { get; init; } + + /// True when Results was cut at the requested limit. + public bool HasMore { get; init; } + + /// What the numbers mean in practice, and what to change when the outcome is not the one wanted. + public required string[] Notes { get; init; } +} diff --git a/src/Balette/Demo/Server/Program.cs b/src/Balette/Demo/Server/Program.cs new file mode 100644 index 0000000000..d68aa1a599 --- /dev/null +++ b/src/Balette/Demo/Server/Program.cs @@ -0,0 +1,106 @@ +// The host of the demo: a Blazor Web App that prerenders the client's pages and then runs them in the +// browser on WebAssembly (InteractiveWebAssembly), where every keystroke is matched and ranked next to the +// list it fills instead of a network round trip away. It also serves the MCP server at /mcp. + +using Bit.Balette.Demo.Server.Components; +using Bit.Balette.Demo.Server.Controllers; +using Bit.Balette.Demo.Server.Services; +using Microsoft.AspNetCore.HttpOverrides; +using ModelContextProtocol.Protocol; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorComponents() + .AddInteractiveWebAssemblyComponents(); + +// The same registration as the WebAssembly client, so the components get the same services while +// they are prerendered here. +builder.Services.AddDemoServices(); + +// The MCP server (Controllers/McpController.cs) and the plain HTTP endpoints that mirror it. A +// member with nothing in it is left out of the JSON, exactly as the MCP transport leaves it out, so +// the browser view of a tool is the answer an agent gets rather than that answer plus a column of nulls. +builder.Services.AddControllers() + .AddJsonOptions(options => options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull); + +builder.Services.AddMcpServer(options => +{ + // The name a person sees in their client's server list, and the version they would quote in a + // bug report - the library's, not this web app's, because the library is what the answers are about. + options.ServerInfo = new Implementation + { + Name = "bit-balette", + Title = "Bit.Balette", + Version = BaletteServerInstructions.BaletteVersion, + Description = "Documentation, public API, theme variables, setup guides and search inspection for the Bit.Balette command palette library for Blazor.", + WebsiteUrl = "https://github.com/bitfoundation/bitplatform/tree/develop/src/Balette" + }; + + // Returned from `initialize` and put in front of the model before it has called anything. + options.ServerInstructions = BaletteServerInstructions.Text; +}) + .WithHttpTransport() + .WithToolsFromAssembly() + .WithResourcesFromAssembly() + .WithPromptsFromAssembly() + // Every key this server takes - a guide heading, a type name, a source path - comes from a + // closed set nothing on the wire spells out; completions let a client type ahead into it. + .WithCompleteHandler((request, cancellationToken) => ValueTask.FromResult(new CompleteResult + { + Completion = BaletteCompletions.Complete(request.Params?.Argument?.Name, request.Params?.Argument?.Value) + })) + // Additive to the attributed resources, not a replacement for them: a template cannot be listed + // as something to click, so the guide's top-level sections are enumerated one by one as well. + .WithListResourcesHandler((request, cancellationToken) => ValueTask.FromResult(new ListResourcesResult + { + Resources = [.. McpResources.ListGuideSections()] + })); + +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.All; + options.ForwardedHostHeaderName = "X-Host"; + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); + options.ForwardLimit = 1; +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseWebAssemblyDebugging(); +} +else +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} + +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); + +app.UseForwardedHeaders(); + +app.UseHttpsRedirection(); + +app.UseAntiforgery(); + +app.MapStaticAssets(); + +// Both are literal routes, so they are matched before any component route regardless of order; +// declaring them first says so out loud. +app.MapControllers(); +app.MapMcp("/mcp"); + +app.MapRazorComponents() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(Bit.Balette.Demo.Client._Imports).Assembly); + +app.Run(); + +/// +/// Named so the MCP tests can host this exact file in-memory. Top-level statements compile to an +/// internal Program, which WebApplicationFactory cannot reach from another assembly on its own. +/// +public partial class Program; diff --git a/src/Balette/Demo/Server/Properties/launchSettings.json b/src/Balette/Demo/Server/Properties/launchSettings.json new file mode 100644 index 0000000000..427934e5e0 --- /dev/null +++ b/src/Balette/Demo/Server/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "Bit.Balette.Demo.Server": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:5213;http://localhost:5212", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Balette/Demo/Server/Services/BaletteApiCatalog.cs b/src/Balette/Demo/Server/Services/BaletteApiCatalog.cs new file mode 100644 index 0000000000..e2fdab4554 --- /dev/null +++ b/src/Balette/Demo/Server/Services/BaletteApiCatalog.cs @@ -0,0 +1,602 @@ +using System.Text; +using System.Reflection; +using System.Collections.Concurrent; +using Bit.Balette.Demo.Server.Dtos; +using Microsoft.AspNetCore.Components; + +namespace Bit.Balette.Demo.Server.Services; + +/// +/// Builds the API reference the MCP tools serve, by reflecting over the public surface of the +/// Bit.Balette assembly and pairing every type and member with its XML documentation. +/// +/// Reflection rather than a hand-maintained table: the reference then cannot drift from the +/// shipped library - a new parameter shows up the moment it is written, with the default value it +/// actually has (read off a freshly constructed instance) instead of one someone remembered to +/// copy into a document. +/// +/// +public static class BaletteApiCatalog +{ + private const BindingFlags Declared = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; + + private static readonly Assembly _assembly = typeof(BaletteOptions).Assembly; + + private static readonly Lazy _publicTypes = new(() => + [.. _assembly.GetExportedTypes() + .Where(t => t.IsNested is false && t.Name.Contains('<', StringComparison.Ordinal) is false && IsRazorImports(t) is false) + .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase)]); + + /// + /// The class the Razor compiler makes of a library's _Imports.razor. It is public because + /// every compiled .razor file is, and it is nothing anyone writes against. + /// + public static bool IsRazorImports(Type type) => type.Name == "_Imports"; + + private static readonly ConcurrentDictionary _typeDetails = new(StringComparer.Ordinal); + + private static readonly Lazy _types = new(() => + [.. _publicTypes.Value.Select(t => new BaletteApiTypeDto + { + Name = FriendlyName(t), + Kind = KindOf(t), + Summary = BaletteXmlDocs.GetSummary(DocumentationId(t)) + })]); + + /// Every public type of Bit.Balette, with its summary. + public static BaletteApiTypeDto[] Types => _types.Value; + + /// + /// The index of the public types, as GetBaletteApi answers when it is called without a name. + /// + /// A summary is cut to its first sentence here: what an index is for is choosing what to read + /// next, and the paragraph that follows the first sentence is the answer to the call the reader + /// has not made yet. Whole summaries made this list four times longer than the reference of the + /// one type anybody wanted. + /// + /// + public static string RenderIndex() + { + var builder = new StringBuilder(); + + builder.AppendLine("# Bit.Balette public API").AppendLine(); + builder.AppendLine($"{Types.Length} public types. Call `GetBaletteApi(typeName: \"...\")` for one of them in full - ") + .AppendLine("its parameters, properties, methods or enum values, with the default value each one really has.") + .AppendLine(); + + foreach (var type in Types) + { + builder.Append($"- **{type.Name}** ({type.Kind})"); + if (FirstSentence(type.Summary) is string summary) builder.Append($" - {summary}"); + builder.AppendLine(); + } + + return builder.ToString(); + } + + /// + /// The full reference of one type as Markdown, or null when no public type goes by that name. + /// + /// Markdown rather than an object, and one renderer rather than two: the tool and the + /// balette://api/{typeName} resource hand out the same text, so neither can drift from + /// the other - and a reference a model reads costs less as prose than as JSON sent twice, once + /// under an output schema and once as the text that mirrors it. + /// + /// + public static string? RenderType(string typeName) + { + var details = GetTypeDetails(typeName); + if (details is null) return null; + + var builder = new StringBuilder(); + + builder.AppendLine($"# {details.Name} ({details.Kind})").AppendLine(); + // One line: the fragments continue the FullName rather than standing on their own, so the + // line is only ended once every part of it has been written. + builder.Append($"`{details.FullName}`"); + + if (details.BaseType is not null) builder.Append($" - inherits `{details.BaseType}`"); + if (details.Implements?.Length > 0) builder.Append($" - implements {string.Join(", ", details.Implements.Select(i => $"`{i}`"))}"); + + builder.AppendLine().AppendLine(); + + if (details.Summary is not null) builder.AppendLine(details.Summary).AppendLine(); + if (details.Remarks is not null) builder.AppendLine(details.Remarks).AppendLine(); + + foreach (var group in details.Members.GroupBy(member => member.Kind)) + { + builder.AppendLine($"## {group.Key}").AppendLine(); + + foreach (var member in group) + { + builder.Append($"- **{member.Name}**{member.Signature}"); + if (member.Type is not null) builder.Append($" : `{member.Type}`"); + if (member.Default is not null) builder.Append($" = `{member.Default}`"); + if (member.Required) builder.Append(" **(required)**"); + if (member.Summary is not null) builder.Append($" - {member.Summary}"); + builder.AppendLine(); + + // The caveats live in the remarks, which is exactly the half of the documentation + // an agent writing against a member needs and the least likely to be remembered. + if (member.Remarks is not null) builder.AppendLine($" {member.Remarks.Replace("\n", "\n ", StringComparison.Ordinal)}"); + } + + builder.AppendLine(); + } + + return builder.ToString(); + } + + /// The first sentence of a summary, for a listing that is read to choose by. + private static string? FirstSentence(string? summary) + { + if (string.IsNullOrWhiteSpace(summary)) return null; + + var text = summary.ReplaceLineEndings(" ").Trim(); + + // "(e.g. search an API ...)" is not where a sentence ends. + var stop = text.IndexOf(". ", StringComparison.Ordinal); + while (stop > 0 && (text[..stop].EndsWith("e.g", StringComparison.Ordinal) || text[..stop].EndsWith("i.e", StringComparison.Ordinal))) + { + stop = text.IndexOf(". ", stop + 2, StringComparison.Ordinal); + } + + return stop > 0 ? text[..(stop + 1)] : text; + } + + /// + /// The full reference of one type - its Blazor parameters, properties, methods, events or enum + /// values - or null when no public type goes by that name. + /// + public static BaletteApiTypeDetailsDto? GetTypeDetails(string typeName) + { + var type = Find(typeName); + if (type is null) return null; + + // Every hit costs a full reflection walk plus an XML lookup per member, and the search index + // asks for all of them at once; the answer only changes when the assembly does. + return _typeDetails.GetOrAdd(type.FullName ?? type.Name, _ => BuildTypeDetails(type)); + } + + private static BaletteApiTypeDetailsDto BuildTypeDetails(Type type) + { + var instance = TryCreateInstance(type); + + // A second instance, compared against the first, is what tells a real default from a value + // made up per instance - a generated element id or a fresh Guid, for one, differs between two + // instances, and reporting one of those as "the default" would hand a caller a value nobody ever gets. + var twin = TryCreateInstance(type); + + var members = new List(); + + if (type.IsEnum) + { + members.AddRange(type.GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(field => new BaletteApiMemberDto + { + Name = field.Name, + Kind = "EnumValue", + Type = FriendlyName(type.GetEnumUnderlyingType()), + // Formatted straight off the raw constant: a [Flags] enum backed by + // ulong has members that do not fit in an Int64. + Default = Format(field.GetRawConstantValue()), + Summary = BaletteXmlDocs.GetSummary(DocumentationId(field)), + Remarks = BaletteXmlDocs.GetRemarks(DocumentationId(field)) + })); + } + else + { + members.AddRange(Properties(type, instance, twin)); + members.AddRange(Fields(type)); + members.AddRange(Methods(type)); + members.AddRange(Events(type)); + } + + return new BaletteApiTypeDetailsDto + { + Name = FriendlyName(type), + FullName = type.FullName ?? type.Name, + Kind = KindOf(type), + BaseType = type.BaseType is null || type.BaseType == typeof(object) || type.BaseType == typeof(ValueType) + ? null + : FriendlyName(type.BaseType), + Implements = [.. type.GetInterfaces() + .Where(i => i.IsPublic && i.DeclaringType is null) + .Select(FriendlyName) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase)], + Summary = BaletteXmlDocs.GetSummary(DocumentationId(type)), + Remarks = BaletteXmlDocs.GetRemarks(DocumentationId(type)), + Members = [.. members.OrderBy(m => KindOrder(m.Kind)).ThenBy(m => m.Name, StringComparer.OrdinalIgnoreCase)] + }; + } + + /// Resolves a type by its simple name, with or without a generic arity or argument list. + private static Type? Find(string typeName) + { + if (string.IsNullOrWhiteSpace(typeName)) return null; + + var name = typeName.Trim(); + + var generic = name.IndexOf('<', StringComparison.Ordinal); + if (generic > 0) name = name[..generic]; + + return _publicTypes.Value.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase)) + ?? _publicTypes.Value.FirstOrDefault(t => string.Equals(StripArity(t.Name), name, StringComparison.OrdinalIgnoreCase)) + ?? _publicTypes.Value.FirstOrDefault(t => string.Equals(t.FullName, name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// The type and every base of it that Bit.Balette itself declares. A component's inherited + /// parameters belong in its reference; the members it gets from ComponentBase do not. + /// + private static IEnumerable Hierarchy(Type type) + { + for (var current = type; current is not null && current.Assembly == _assembly; current = current.BaseType) + { + yield return current; + } + } + + private static IEnumerable Properties(Type type, object? instance, object? twin) + { + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var property in Hierarchy(type).SelectMany(t => t.GetProperties(Declared))) + { + if (property.GetIndexParameters().Length > 0) continue; + if (seen.Add(property.Name) is false) continue; + + var isParameter = property.IsDefined(typeof(ParameterAttribute), inherit: true); + + yield return new BaletteApiMemberDto + { + Name = property.Name, + Kind = isParameter ? "Parameter" : "Property", + Type = FriendlyName(property.PropertyType), + Default = DefaultValueOf(property, instance, twin), + Required = property.IsDefined(typeof(EditorRequiredAttribute), inherit: true), + Summary = Documentation(property, BaletteXmlDocs.GetSummary), + Remarks = Documentation(property, BaletteXmlDocs.GetRemarks) + }; + } + } + + private static IEnumerable Fields(Type type) + { + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var field in Hierarchy(type).SelectMany(t => t.GetFields(Declared))) + { + if (field.Name.Contains('<', StringComparison.Ordinal)) continue; + if (seen.Add(field.Name) is false) continue; + + yield return new BaletteApiMemberDto + { + Name = field.Name, + Kind = "Field", + Type = FriendlyName(field.FieldType), + Default = field.IsLiteral ? Format(field.GetRawConstantValue()) : null, + Summary = BaletteXmlDocs.GetSummary(DocumentationId(field)), + Remarks = BaletteXmlDocs.GetRemarks(DocumentationId(field)) + }; + } + } + + private static IEnumerable Methods(Type type) + { + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var method in Hierarchy(type).SelectMany(t => t.GetMethods(Declared))) + { + if (method.IsSpecialName) continue; // property/event accessors and operators + if (method.Name.Contains('<', StringComparison.Ordinal)) continue; + if (method.DeclaringType == typeof(object)) continue; + // What the compiler writes for a record - Equals, GetHashCode, ToString, Deconstruct - is the + // same on every record and documented nowhere; listing it on each of the library's many records + // would bury the members someone wrote on purpose. + if (method.IsDefined(typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), inherit: false)) continue; + // The browser layer's callbacks (OnKeyDown, OnGlobalShortcut, ...) are public only because + // JavaScript interop can call nothing else. Listed next to OpenAsync they read as API, and an + // agent calling one from C# bypasses the very checks the browser made before calling it. + if (method.IsDefined(typeof(Microsoft.JSInterop.JSInvokableAttribute), inherit: false)) continue; + + var signature = Signature(method); + if (seen.Add($"{method.Name}{signature}") is false) continue; + + yield return new BaletteApiMemberDto + { + Name = method.Name, + Kind = "Method", + Type = FriendlyName(method.ReturnType), + Signature = signature, + Summary = Documentation(method, BaletteXmlDocs.GetSummary), + Remarks = Documentation(method, BaletteXmlDocs.GetRemarks) + }; + } + } + + private static IEnumerable Events(Type type) + { + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var member in Hierarchy(type).SelectMany(t => t.GetEvents(Declared))) + { + if (seen.Add(member.Name) is false) continue; + + yield return new BaletteApiMemberDto + { + Name = member.Name, + Kind = "Event", + Type = FriendlyName(member.EventHandlerType ?? typeof(void)), + Summary = BaletteXmlDocs.GetSummary(DocumentationId(member)), + Remarks = BaletteXmlDocs.GetRemarks(DocumentationId(member)) + }; + } + } + + /// + /// The documentation of a property, or - when it has none of its own, as an interface implementation + /// usually does (BaletteService's members say nothing IBaletteService does not) - that of the + /// member it overrides or implements, which is what the compiler would show for it. + /// + private static string? Documentation(PropertyInfo property, Func read) + { + if (read(DocumentationId(property)) is string own) return own; + + var accessor = property.GetMethod ?? property.SetMethod; + var root = accessor?.GetBaseDefinition(); + + if (root is not null && root.DeclaringType != property.DeclaringType && + root.DeclaringType!.GetProperty(property.Name, Declared) is { } overridden && + read(DocumentationId(overridden)) is string inherited) return inherited; + + foreach (var contract in property.DeclaringType!.GetInterfaces()) + { + if (contract.GetProperty(property.Name) is { } declared && read(DocumentationId(declared)) is string implemented) return implemented; + } + + return null; + } + + /// The documentation of a method, falling back to the one it overrides or implements. + private static string? Documentation(MethodInfo method, Func read) + { + if (read(DocumentationId(method)) is string own) return own; + + var root = method.GetBaseDefinition(); + + if (root.DeclaringType != method.DeclaringType && read(DocumentationId(root)) is string inherited) return inherited; + + var owner = method.DeclaringType!; + if (owner.IsInterface) return null; + + foreach (var contract in owner.GetInterfaces()) + { + InterfaceMapping map; + + try + { + map = owner.GetInterfaceMap(contract); + } + catch (Exception) + { + // A generic or otherwise unmappable interface simply has nothing to lend. + continue; + } + + var index = Array.IndexOf(map.TargetMethods, method); + + if (index >= 0 && read(DocumentationId(map.InterfaceMethods[index])) is string implemented) return implemented; + } + + return null; + } + + /// + /// The value a property holds on a newly constructed instance - the default a caller gets when + /// the parameter is left unset. + /// + private static string? DefaultValueOf(PropertyInfo property, object? instance, object? twin) + { + if (property.GetMethod is null || property.GetMethod.IsPublic is false) return null; + if (property.GetMethod.IsStatic is false && instance is null) return null; + + try + { + if (property.GetMethod.IsStatic) return Format(property.GetValue(null)); + + var value = Format(property.GetValue(instance)); + + return twin is null || value == Format(property.GetValue(twin)) ? value : null; + } + catch (Exception) + { + // Members that only make sense on a mounted component (or throw by design) simply have + // no observable default; that is not a reason to fail the whole type's reference. + return null; + } + } + + private static object? TryCreateInstance(Type type) + { + if (type.IsAbstract || type.IsInterface || type.IsEnum) return null; + if (type.IsValueType is false && type.GetConstructor(Type.EmptyTypes) is null) return null; + + try + { + return Activator.CreateInstance(type); + } + catch (Exception) + { + return null; + } + } + + private static string? Format(object? value) => value switch + { + null => null, + string text => $"\"{text}\"", + bool flag => flag ? "true" : "false", + Enum enumValue => $"{enumValue.GetType().Name}.{enumValue}", + IFormattable formattable => formattable.ToString(null, System.Globalization.CultureInfo.InvariantCulture), + _ => Describe(value) + }; + + /// + /// A reference-typed default is worth reporting: a property that starts out holding an object + /// (BaletteOptions.Weights does) is not the same thing as one that starts out null. + /// + private static string Describe(object value) + { + var type = value.GetType(); + + // A default that is one of the type's own named values reads best by that name: + // BaletteFuzzyMatcher.Default, BaletteCommandResult.Close, EventCallback.Empty - rather than a dump of its fields. + foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (field.FieldType == type && field.IsInitOnly | field.IsLiteral && Equals(field.GetValue(null), value)) return $"{FriendlyName(type)}.{field.Name}"; + } + + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Static)) + { + if (property.PropertyType != type || property.GetIndexParameters().Length > 0) continue; + + try + { + if (Equals(property.GetValue(null), value)) return $"{FriendlyName(type)}.{property.Name}"; + } + catch (Exception) + { + // A static property that throws simply names nothing. + } + } + + var text = value.ToString(); + + // A ToString that is only the type's name says nothing about the value - and for a generic + // type it is the CLR spelling ("EventCallback`1[...]") no C# reader would recognise. + return string.IsNullOrEmpty(text) || text == type.FullName || text == type.Name || text.Contains('`', StringComparison.Ordinal) + ? $"new {FriendlyName(type)}()" + : text; + } + + private static string Signature(MethodInfo method) + { + var builder = new StringBuilder("("); + + var parameters = method.GetParameters(); + for (int i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + + if (i > 0) builder.Append(", "); + if (i == 0 && method.IsDefined(typeof(System.Runtime.CompilerServices.ExtensionAttribute), false)) builder.Append("this "); + if (parameter.IsOut) builder.Append("out "); + else if (parameter.ParameterType.IsByRef) builder.Append("ref "); + + builder.Append(FriendlyName(parameter.ParameterType)).Append(' ').Append(parameter.Name); + + if (parameter.HasDefaultValue) builder.Append(" = ").Append(Format(parameter.DefaultValue) ?? "null"); + } + + return builder.Append(')').ToString(); + } + + private static string KindOf(Type type) + { + if (type.IsEnum) return "Enum"; + // Before the IComponent test: an interface that extends IComponent is still an interface. + if (type.IsInterface) return "Interface"; + if (typeof(IComponent).IsAssignableFrom(type)) return "Component"; + if (typeof(Attribute).IsAssignableFrom(type)) return "Attribute"; + if (typeof(MulticastDelegate).IsAssignableFrom(type)) return "Delegate"; + if (type.IsAbstract && type.IsSealed) return "Static class"; + if (type.IsValueType) return "Struct"; + if (type.GetMethod("$", BindingFlags.Public | BindingFlags.Instance) is not null) return "Record"; + + return "Class"; + } + + private static int KindOrder(string kind) => kind switch + { + "Parameter" => 0, + "Property" => 1, + "Field" => 2, + "EnumValue" => 3, + "Method" => 4, + "Event" => 5, + _ => 6 + }; + + /// The C# spelling of a type: "int?", "string[]", "Func<BaletteCommandContext, Task<BaletteCommandResult>>". + public static string FriendlyName(Type type) + { + if (type.IsByRef) return FriendlyName(type.GetElementType()!); + if (type.IsArray) return $"{FriendlyName(type.GetElementType()!)}[]"; + + var nullable = Nullable.GetUnderlyingType(type); + if (nullable is not null) return $"{FriendlyName(nullable)}?"; + + if (type == typeof(void)) return "void"; + if (type == typeof(object)) return "object"; + if (type == typeof(string)) return "string"; + if (type == typeof(bool)) return "bool"; + if (type == typeof(int)) return "int"; + if (type == typeof(long)) return "long"; + if (type == typeof(double)) return "double"; + if (type == typeof(float)) return "float"; + if (type == typeof(decimal)) return "decimal"; + + if (type.IsGenericType is false) return type.Name; + + var arguments = string.Join(", ", type.GetGenericArguments().Select(FriendlyName)); + + return $"{StripArity(type.Name)}<{arguments}>"; + } + + private static string StripArity(string name) + { + var arity = name.IndexOf('`', StringComparison.Ordinal); + + return arity < 0 ? name : name[..arity]; + } + + private static string DocumentationId(Type type) => $"T:{type.FullName}"; + + private static string DocumentationId(PropertyInfo property) => $"P:{property.DeclaringType!.FullName}.{property.Name}"; + + private static string DocumentationId(FieldInfo field) => $"F:{field.DeclaringType!.FullName}.{field.Name}"; + + private static string DocumentationId(EventInfo member) => $"E:{member.DeclaringType!.FullName}.{member.Name}"; + + private static string DocumentationId(MethodInfo method) + { + var id = new StringBuilder("M:").Append(method.DeclaringType!.FullName).Append('.').Append(method.Name); + + if (method.IsGenericMethodDefinition) id.Append("``").Append(method.GetGenericArguments().Length); + + var parameters = method.GetParameters(); + if (parameters.Length == 0) return id.ToString(); + + id.Append('(') + .Append(string.Join(',', parameters.Select(p => DocumentationTypeName(p.ParameterType)))) + .Append(')'); + + return id.ToString(); + } + + /// Spells a type the way the C# compiler spells it inside a documentation id. + private static string DocumentationTypeName(Type type) + { + if (type.IsByRef) return $"{DocumentationTypeName(type.GetElementType()!)}@"; + if (type.IsArray) return $"{DocumentationTypeName(type.GetElementType()!)}[]"; + if (type.IsGenericParameter) return type.DeclaringMethod is null ? $"`{type.GenericParameterPosition}" : $"``{type.GenericParameterPosition}"; + + if (type.IsGenericType is false) return type.FullName ?? type.Name; + + var definition = type.GetGenericTypeDefinition().FullName ?? type.Name; + var arguments = string.Join(',', type.GetGenericArguments().Select(DocumentationTypeName)); + + return $"{StripArity(definition)}{{{arguments}}}"; + } +} diff --git a/src/Balette/Demo/Server/Services/BaletteCompletions.cs b/src/Balette/Demo/Server/Services/BaletteCompletions.cs new file mode 100644 index 0000000000..beef964766 --- /dev/null +++ b/src/Balette/Demo/Server/Services/BaletteCompletions.cs @@ -0,0 +1,94 @@ +using ModelContextProtocol.Protocol; + +namespace Bit.Balette.Demo.Server.Services; + +/// +/// Answers completion/complete: the values a client offers while someone fills in a prompt +/// argument or a resource template's placeholder. +/// +/// The protocol allows this for two things only - a prompt's arguments and a resource template's +/// placeholders - and both of those are, on this server, keys into a closed set that nothing on the +/// wire spells out: a guide heading, a public type name, an embedded source path. Without +/// completions a person picking balette://api/{typeName} in a client has to go and call a +/// listing tool first, just to learn what a type name looks like. +/// +/// +public static class BaletteCompletions +{ + /// The protocol caps one response at 100 values; anything past that is reported through hasMore. + private const int MaxValues = 100; + + // Freeform arguments cannot be completed, only started off: these are the phrasings the matching + // prompt was written for, so picking one lands in the workflow it was designed around instead of + // in a sentence the tools have nothing to say about. + private static readonly string[] _symptoms = + [ + "Ctrl+K does nothing", + "the palette opens unstyled at the top of the page", + "the app fails while prerendering because IBaletteService is not registered", + "a toggle shows its old state after it was run", + "recents and pins forget a command after its title changed", + "a page's commands are still listed after leaving the page", + "a command does not show up when I type its name", + "a command ranks below a worse match", + "a global shortcut does not fire while a text box has focus", + "results from my API source never appear", + ]; + + private static readonly string[] _features = + [ + "a command palette with navigation to every page and Ctrl+K to open it", + "commands that only exist while one page is open", + "a settings toggle that keeps the palette open and shows its new state", + "a rename command that asks for the new name", + "a multi-select page to set labels, applied with Ctrl+Enter", + "a delete command that asks for confirmation", + "search our API for documents as the user types", + "a '>' mode that lists only commands, like VS Code", + "G then S shortcuts that navigate with the palette closed", + "restyle the palette to our brand colours, in light and dark", + ]; + + /// + /// The values matching what has been typed so far. The argument's name is enough to identify the + /// set - no two arguments on this server mean different things by the same name - so one table + /// serves a prompt argument and a resource template placeholder without either knowing about the + /// other, and an argument it has nothing for is answered with an empty list rather than an error. + /// + public static Completion Complete(string? argumentName, string? typed) + { + var candidates = CandidatesFor(argumentName); + if (candidates.Length == 0) return new Completion { Values = [] }; + + var prefix = (typed ?? string.Empty).Trim(); + + var matches = prefix.Length == 0 + ? candidates + : [.. candidates + .Where(value => value.Contains(prefix, StringComparison.OrdinalIgnoreCase)) + // What was typed is a prefix far more often than it is a substring, so the values it + // starts stay above the ones it merely appears inside. + .OrderByDescending(value => value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .ThenBy(value => value, StringComparer.OrdinalIgnoreCase)]; + + return new Completion + { + Values = [.. matches.Take(MaxValues)], + Total = matches.Length, + HasMore = matches.Length > MaxValues + }; + } + + // "renderMode" is deliberately missing here: its values are declared with [AllowedValues], which + // the SDK completes from on its own, and anything this returned would be merged with that rather + // than replace it - the same value offered to someone twice. + private static string[] CandidatesFor(string? argumentName) => argumentName switch + { + "heading" => [.. BaletteSourceCatalog.GuideSections.Select(section => section.Key).Distinct(StringComparer.Ordinal)], + "typeName" => [.. BaletteApiCatalog.Types.Select(type => type.Name)], + "path" => [.. BaletteSourceCatalog.SourceFiles.Select(file => file.Path)], + "symptom" => _symptoms, + "feature" => _features, + _ => [] + }; +} diff --git a/src/Balette/Demo/Server/Services/BaletteSearchIndex.cs b/src/Balette/Demo/Server/Services/BaletteSearchIndex.cs new file mode 100644 index 0000000000..6de20dd2be --- /dev/null +++ b/src/Balette/Demo/Server/Services/BaletteSearchIndex.cs @@ -0,0 +1,491 @@ +using Bit.Balette.Demo.Server.Dtos; + +namespace Bit.Balette.Demo.Server.Services; + +/// +/// One searchable index over everything this MCP server knows: the reference guide, every public +/// type and member, the setup guides, the stylesheet's variables, classes and hooks, the search lab +/// and the demo's source files. +/// +/// Without it an agent has to guess which corpus holds the answer and what it is called there - +/// "a rename command that asks for the new name" is a guide section, a result type, a builder method +/// and a demo page all at once. Each hit therefore carries the exact follow-up tool call that returns +/// the full text, so one search is enough to know what to ask for next. +/// +/// +public static class BaletteSearchIndex +{ + private sealed record Entry(string Kind, string Title, string? Context, string Tool, string Body, string Boosted, int Weight = ReferenceWeight) + { + /// + /// The title split into words, camel-case humps included, so "GroupWhenSearching" is found by + /// "group when searching" - and so a query word only counts as a title hit when it IS one of + /// those words, rather than merely appearing inside one ("pin" inside "Spinner"). + /// + public string[] TitleWords { get; } = SplitWords(Title); + } + + private const int MaxTerms = 16; + + /// The most hits one search may return, whatever the caller asked for. + private const int MaxHits = 50; + + private const int MaxSuggestions = 8; + + /// The weight of an entry that answers a question: a guide section, a type, a member. + private const int ReferenceWeight = 10; + + /// + /// The weight of a source file. They are examples, not answers, and their titles are paths + /// whose segments ("Client", "Pages", "Server") are common words carrying no topic - without + /// this, every question phrased with one of them is answered with a directory listing. + /// + private const int ExampleWeight = 4; + + /// + /// The most an entry can earn from prose, however much of it there is. A long section mentions + /// everything, so without a ceiling the biggest document in the corpus is the top hit for every + /// query - and a name match, which is the far stronger signal, never gets ahead of it. + /// + private const int MaxBodyScore = 8; + + private static readonly Lazy _entries = new(Build); + + /// + /// The words every entry in this index carries because of what the index is about. They + /// separate nothing, and they do worse than nothing: a term that matches an entry counts + /// towards the "how many terms matched" multiplier, so left in, the longest documents win every + /// query on the strength of a word that is in all of them. + /// + /// Unlike a stop word they are dropped only when the query says something else as well, since + /// "balette" and "blazor command palette" are what a caller reaches for first and are made of + /// almost nothing but these. + /// + /// + private static readonly HashSet _ambientWords = new(StringComparer.OrdinalIgnoreCase) + { + "balette", "bit", "blazor", "library", "palette" + }; + + private static readonly HashSet _metaSections = new(StringComparer.OrdinalIgnoreCase) { "Documentation", "MCP server" }; + + private static readonly HashSet _stopWords = new(StringComparer.OrdinalIgnoreCase) + { + "how", "the", "and", "for", "with", "from", "that", "this", "what", "when", "where", "which", + "does", "did", "are", "was", "you", "your", "than", "then", "its", "but", "any", "some", + "please", "help", "about", "into", "way", "make", "want", "need", "would", "should", "could", + "there", "here", "have", "has", "get", "got", "let", "one", "two", "per", "via", "onto" + }; + + public static BaletteSearchResultDto Search(string? query, int limit) + { + var terms = Tokenize(query); + + if (terms.Length == 0) + { + // Nothing was left to search by, which is a different failure from "searched and found + // nothing" - and the fix is on the caller's side, so it has to be said rather than + // returned as an empty list the agent would read as "Balette cannot do this". + return new BaletteSearchResultDto + { + Terms = [], + Hits = [], + Message = "The query held no word longer than two letters that was not a filler word, so there was " + + "nothing to rank by. Search for the thing itself - 'prompt', 'shortcut', 'async source' - " + + "rather than for a sentence." + }; + } + + var ranked = _entries.Value + .Select(entry => (Entry: entry, Score: Score(entry, terms))) + .Where(hit => hit.Score > 0) + .OrderByDescending(hit => hit.Score) + .ThenBy(hit => hit.Entry.Title, StringComparer.OrdinalIgnoreCase) + // Two entries that would be read through the same call under the same name are one hit: + // a method with two overloads is two members and one place to read about them, and the + // second copy costs a hit out of the caller's budget to say nothing new. A call that takes + // no argument returns the same text whichever entry led to it, so its name does not count. + .DistinctBy(hit => hit.Entry.Tool.EndsWith("()", StringComparison.Ordinal) ? (string.Empty, hit.Entry.Tool) : (hit.Entry.Title, hit.Entry.Tool)) + .ToArray(); + + var take = Math.Clamp(limit, 1, MaxHits); + + var hits = Cover(ranked, terms, take).Select(hit => new BaletteSearchHitDto + { + Kind = hit.Entry.Kind, + Title = hit.Entry.Title, + Context = hit.Entry.Context, + Tool = hit.Entry.Tool, + Snippet = Snippet(hit.Entry.Body, terms) + }).ToArray(); + + if (hits.Length > 0) + { + // No echo of the query and no list of the terms it was reduced to: the caller sent that + // query one message ago, and what it was tokenized into changes nothing about a hit it + // can read for itself. Both are worth saying only when there is nothing else to say. + return new BaletteSearchResultDto + { + Hits = hits, + HasMore = ranked.Length > hits.Length + }; + } + + // A search that found nothing is where an agent is most likely to conclude the library has + // no such feature and hand-roll one. Whatever came closest is named instead, so the next + // call is a better query rather than a worse decision. + var nearby = Suggest(terms); + + return new BaletteSearchResultDto + { + Terms = terms, + Hits = [], + Message = nearby.Length > 0 + ? $"Nothing matched {string.Join(" + ", terms)}. The closest names are listed in didYouMean - " + + "search for one of those, or for a single word out of this query." + : $"Nothing matched {string.Join(" + ", terms)}. Try one word rather than several, or call " + + "GetBaletteGuideSection or GetBaletteApi with no argument at all for the index of what " + + "there is. A concept Balette does not name is often the same idea under another word - a " + + "'command menu', 'quick pick' or 'spotlight' is the palette, a 'submenu' or 'step' is a nested page, " + + "'input' or 'text box' is a prompt, 'scope' or 'filter' is a mode, 'remote results' are an async " + + "source, 'hotkey' is a shortcut, 'aliases' are keywords, 'favorites' are pins.", + DidYouMean = nearby.Length > 0 ? nearby : null + }; + } + + /// + /// The first hits, with every query word that names something kept in + /// view. A request usually joins two things - "a toggle that keeps the palette open" is a toggle AND + /// a result - and the one with more material behind it would otherwise fill every place, leaving the + /// caller to conclude the other has no answer. So for each word that is the name of some entry yet + /// names none of the top hits, the best entry it does name takes the place of the lowest-ranked hit + /// that is not itself there for that reason. The order stays the ranking's. + /// + private static (Entry Entry, int Score)[] Cover((Entry Entry, int Score)[] ranked, string[] terms, int take) + { + var top = ranked.Take(take).ToList(); + if (top.Count < take || terms.Length < 2) return [.. top]; + + var kept = new HashSet(); + + foreach (var term in terms) + { + if (top.Any(hit => hit.Entry.TitleWords.Any(word => Equivalent(word, term)))) continue; + + var best = ranked.Skip(take).FirstOrDefault(hit => hit.Entry.TitleWords.Any(word => Equivalent(word, term))); + if (best.Entry is null) continue; + + var replace = top.FindLastIndex(hit => kept.Contains(hit.Entry) is false); + if (replace < 0) break; + + top[replace] = best; + kept.Add(best.Entry); + } + + return [.. top.OrderByDescending(hit => hit.Score).ThenBy(hit => hit.Entry.Title, StringComparer.OrdinalIgnoreCase)]; + } + + /// + /// The titles that nearly matched: an entry whose name merely begins with one of the query's + /// words, which the ranking itself ignores on purpose - a prefix is far too weak a signal to + /// rank a hit by, and exactly strong enough to suggest a word to search for instead. + /// + private static string[] Suggest(string[] terms) + { + return [.. _entries.Value + .Where(entry => terms.Any(term => term.Length >= 4 + && entry.TitleWords.Any(word => word.StartsWith(term, StringComparison.OrdinalIgnoreCase) + // A one- or two-letter title word ("1", "UI") is the start of + // half the dictionary, so it suggests nothing. + || (word.Length >= 4 && term.StartsWith(word, StringComparison.OrdinalIgnoreCase))))) + .Select(entry => entry.Title) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .Take(MaxSuggestions)]; + } + + private static int Score(Entry entry, string[] terms) + { + var score = 0; + var matched = 0; + var named = 0; + + foreach (var term in terms) + { + var isTitleWord = entry.TitleWords.Any(word => Equivalent(word, term)); + + // Text is counted by the singular, which a plural query word would otherwise never find + // inside: "shortcuts" has to count the "Shortcut" of GlobalShortcut, and "pages" every "page". + var stem = Stem(term); + var inTitle = isTitleWord ? 0 : Count(entry.Title, stem); + var inBoosted = Count(entry.Boosted, stem); + var inBody = Count(entry.Body, stem); + + if (isTitleWord is false && inTitle + inBoosted + inBody == 0) continue; + + matched++; + if (isTitleWord || inTitle + inBoosted > 0) named++; + + // A term in a name is worth far more than the same term buried in prose: someone asking + // for "GroupWhenSearching" wants the option, not the paragraphs that happen to mention it. + score += (isTitleWord ? 12 : 0) + inTitle * 3 + inBoosted * 5 + Math.Min(inBody, MaxBodyScore); + } + + // Every term matching is the strongest signal a hit is the right one - and a term the entry is + // named by counts twice, since every long section mentions "command", "open" and "query" + // somewhere, and a multiplier fed only by prose hands the longest of them every query phrased in + // plain words. The weight is what keeps a worked example from outranking the reference that + // answers the question. + return matched == 0 ? 0 : score * (matched + named) * entry.Weight; + } + + /// + /// Same word, whatever form it is in - "rank" has to find the section called "Search and ranking", + /// "shortcut" the one about shortcuts, and nobody phrases a question in the number or tense the + /// heading happens to use. + /// + private static bool Equivalent(string word, string term) + { + return string.Equals(word, term, StringComparison.OrdinalIgnoreCase) + || string.Equals(Stem(word), Stem(term), StringComparison.OrdinalIgnoreCase); + } + + /// + /// A word without its plural, its -ing or -ed, and a final silent e: "ranks", "ranking" and + /// "ranked" are all "rank", "registering" and "register" both "register". Only ever cut where at + /// least three letters remain, so "css" or "used" keep the letters that make them a word. Prose is + /// counted by the stem too, which as a substring also finds every longer form of it. + /// + private static string Stem(string word) + { + var stem = word.ToLowerInvariant(); + + if (stem.Length > 4 && stem.EndsWith('s') && stem.EndsWith("ss", StringComparison.Ordinal) is false) stem = stem[..^1]; + if (stem.Length > 5 && stem.EndsWith("ing", StringComparison.Ordinal)) stem = stem[..^3]; + else if (stem.Length > 4 && stem.EndsWith("ed", StringComparison.Ordinal)) stem = stem[..^2]; + if (stem.Length > 3 && stem.EndsWith('e')) stem = stem[..^1]; + + return stem; + } + + /// + /// How often a term starts a word of the text. Only the start of a word counts - the start of the + /// text, a letter after a non-letter, or a capital after a lower-case letter, so "shortcut" counts + /// in "GlobalShortcut" - because inside a word a term is a different word: every "Balette" type name + /// contains "let", and counting it there turns "let me" into a search for the library's name. + /// The end of the word stays open, so a stem still finds its longer forms ("rank" in "ranking"). + /// + private static int Count(string text, string term) + { + if (text.Length == 0) return 0; + + var count = 0; + var index = text.IndexOf(term, StringComparison.OrdinalIgnoreCase); + + while (index >= 0) + { + if (StartsWord(text, index)) count++; + index = text.IndexOf(term, index + term.Length, StringComparison.OrdinalIgnoreCase); + } + + return count; + } + + private static bool StartsWord(string text, int index) + { + if (index == 0) return true; + + var previous = text[index - 1]; + + return char.IsLetterOrDigit(previous) is false || (char.IsUpper(text[index]) && char.IsLower(previous)); + } + + private static string Snippet(string body, string[] terms) + { + if (body.Length == 0) return string.Empty; + + var index = -1; + foreach (var term in terms) + { + index = body.IndexOf(term, StringComparison.OrdinalIgnoreCase); + if (index >= 0) break; + } + + if (index < 0) index = 0; + + // The same word starts the snippet that was counted for the score, not "let" inside "Balette". + foreach (var term in terms) + { + var at = body.IndexOf(term, StringComparison.OrdinalIgnoreCase); + while (at >= 0 && StartsWord(body, at) is false) at = body.IndexOf(term, at + term.Length, StringComparison.OrdinalIgnoreCase); + + if (at >= 0) + { + index = at; + break; + } + } + + // A snippet is a reason to make the follow-up call, not a substitute for making it: enough + // of the surrounding text to tell the right hit from the wrong one, and no more. Every extra + // character here is paid for by every hit of every search, most of which are not the one. + var start = Math.Max(0, index - 60); + var length = Math.Min(180, body.Length - start); + var snippet = body.Substring(start, length).Replace('\n', ' ').Replace('\r', ' ').Trim(); + + return $"{(start > 0 ? "..." : null)}{snippet}{(start + length < body.Length ? "..." : null)}"; + } + + /// Splits a name or heading into words, breaking camel-case humps as well as punctuation. + private static string[] SplitWords(string text) + { + var words = new List(); + var current = new System.Text.StringBuilder(); + + foreach (var c in text) + { + if (char.IsLetterOrDigit(c) is false) + { + if (current.Length > 0) { words.Add(current.ToString()); current.Clear(); } + continue; + } + + // A capital after a lowercase letter starts a new word ("GroupWhenSearching" -> group when + // searching), while a run of capitals stays together ("CSS", "MCP"). + if (char.IsUpper(c) && current.Length > 0 && char.IsLower(current[^1])) + { + words.Add(current.ToString()); + current.Clear(); + } + + current.Append(c); + } + + if (current.Length > 0) words.Add(current.ToString()); + + return [.. words]; + } + + private static string[] Tokenize(string? query) + { + if (string.IsNullOrWhiteSpace(query)) return []; + + string[] terms = [.. query.Split(['.', ',', ';', ':', '?', '!', '"', '\'', '(', ')', '[', ']', '{', '}', '/', '\\', '<', '>', '-', '_', ' ', '\t', '\n', '\r'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + // One- and two-letter words ("a", "in", "do") match everything and rank nothing - and + // the words a question is phrased with do worse than nothing: "how do I open the palette + // FROM code" would otherwise score a section whose heading merely contains "from". + .Where(term => term.Length > 2 && _stopWords.Contains(term) is false) + .Distinct(StringComparer.OrdinalIgnoreCase) + // Every term is counted in every entry's body, so the work is terms x corpus. No question + // is phrased in more words than this, while a pasted file as a query would scan for hours. + .Take(MaxTerms)]; + + return DropAmbientWords(terms); + } + + private static Entry[] Build() + { + var entries = new List(512); + + foreach (var section in BaletteSourceCatalog.GuideSections) + { + // Two sections of the README are about the README rather than about the library: the + // table of contents, which names every topic and so matches every query, and the one about + // this MCP server, whose list of tools mentions search, setup, themes and the API in a + // paragraph that answers none of them. Both stay readable through GetBaletteGuideSection; + // they are only kept out of the ranking. + if (_metaSections.Contains(section.Key)) continue; + + var body = BaletteSourceCatalog.GetGuideSection(section.Key) ?? string.Empty; + + entries.Add(new Entry("Guide section", section.Heading, section.Parent, + $"GetBaletteGuideSection(heading: \"{section.Key}\")", body, string.Empty)); + } + + foreach (var type in BaletteApiCatalog.Types) + { + entries.Add(new Entry($"API {type.Kind.ToLowerInvariant()}", type.Name, null, + $"GetBaletteApi(typeName: \"{type.Name}\")", type.Summary ?? string.Empty, string.Empty)); + + var details = BaletteApiCatalog.GetTypeDetails(type.Name); + if (details is null) continue; + + // One entry per member NAME, not per member: the overloads of Handler are several members + // and one thing to know about, and all are read by the same call anyway. Their + // documentation is searched as one body, so a term in any overload finds the member. + foreach (var overloads in details.Members.GroupBy(member => member.Name, StringComparer.Ordinal)) + { + var member = overloads.First(); + + entries.Add(new Entry($"API {member.Kind.ToLowerInvariant()}", $"{type.Name}.{member.Name}", type.Name, + $"GetBaletteApi(typeName: \"{type.Name}\")", + string.Join(' ', overloads.Select(o => $"{o.Summary} {o.Remarks}".Trim())).Trim(), + string.Join(' ', overloads.Select(o => $"{o.Type} {o.Signature}".Trim())).Trim())); + } + } + + foreach (var mode in BaletteSetupGuide.RenderModes) + { + entries.Add(new Entry("Setup guide", $"Setup: {mode}", null, + $"GetBaletteSetupGuide(renderMode: \"{mode}\")", BaletteSetupGuide.Summary(mode), + $"setup install wiring register services stylesheet render mode {mode.Replace('-', ' ')}")); + } + + entries.Add(new Entry("Search lab", "Inspect a search", null, + "InspectBaletteSearch(query: \"rc\")", + "Runs the library's own matcher and ranker over a query and a list of commands - this demo's registry by " + + "default - and reports, per command, whether it matched and why not, its score, its rank and its row, and " + + "the characters it highlights.", + "inspect debug explain why score rank ranking order highlight match fuzzy operators negate exact prefix suffix frecency priority")); + + // The table as a whole, for the question that asks for the list itself ("theme variables", + // "dark mode colours") rather than for one of its rows. + entries.Add(new Entry("Theme variable", "Theme variable catalog", null, + "GetBaletteThemeVariables()", BaletteThemeCatalog.Render(), "css theme variables colors colours dark light mode restyle unstyled")); + + foreach (var variable in BaletteThemeCatalog.Variables) + { + // A row is one property and its values. The rows share one parameterless call, so + // however many of them match, a search spends one hit on them. + var dark = variable.DarkValue is null ? null : $" (dark: {variable.DarkValue})"; + + entries.Add(new Entry("Theme variable", variable.Name, variable.Group, + "GetBaletteThemeVariables()", $"{variable.Name}: {variable.LightValue}{dark}", $"css {variable.Group}")); + } + + foreach (var hook in BaletteThemeCatalog.Hooks) + { + entries.Add(new Entry("Markup hook", $"data-balette=\"{hook.Value}\"", hook.Component, + "GetBaletteThemeVariables()", + $"The <{hook.Element}> {hook.Component} renders{(hook.Class is null ? null : $" with class {hook.Class}")}{(hook.States.Length == 0 ? null : $", carrying {string.Join(", ", hook.States)}")}.", + "unstyled data attribute hook test selector")); + } + + foreach (var rule in BaletteStylesheetClasses.Classes) + { + // The classes share one file, so however many of them match, a search spends one hit on them. + entries.Add(new Entry("CSS class", rule.Selector, "bit-balette.css", + $"GetBaletteSourceFile(path: \"{BaletteSourceCatalog.StylesheetPath}\")", rule.Description, "css class style stylesheet")); + } + + foreach (var file in BaletteSourceCatalog.SourceFiles) + { + entries.Add(new Entry("Source file", file.Path, file.Kind, + $"GetBaletteSourceFile(path: \"{file.Path}\")", file.Description ?? string.Empty, string.Empty, ExampleWeight)); + } + + return [.. entries]; + } + + /// + /// The query without the words that are true of everything here - unless that is all it said, + /// in which case they are all it can be searched by. + /// + private static string[] DropAmbientWords(string[] terms) + { + var meaningful = terms.Where(term => _ambientWords.Contains(term) is false).ToArray(); + + return meaningful.Length > 0 ? meaningful : terms; + } +} diff --git a/src/Balette/Demo/Server/Services/BaletteSearchLab.cs b/src/Balette/Demo/Server/Services/BaletteSearchLab.cs new file mode 100644 index 0000000000..199851058e --- /dev/null +++ b/src/Balette/Demo/Server/Services/BaletteSearchLab.cs @@ -0,0 +1,694 @@ +using System.Text; +using System.Text.Json; +using System.Reflection; +using System.Globalization; +using Microsoft.JSInterop; +using Bit.Balette.Demo.Server.Dtos; +using Bit.Balette.Demo.Client.Services; + +namespace Bit.Balette.Demo.Server.Services; + +/// +/// Runs Bit.Balette's own matcher and ranker over a query and a list of commands, so "why does this +/// command rank there, and why does that one not show up at all?" is answered by the code that decides +/// it at runtime rather than by a reading of the scoring rules. +/// +/// Matching goes through the public and ranking through +/// - the two +/// calls the palette makes on every keystroke - over the candidates the palette would consider: the +/// visible commands without a parent, plus the AlwaysShow ones that did not match, at minus infinity. +/// What the palette does after that is component markup rather than public API - keep the best +/// MaxResults, then lay them out under their group headings - so those two steps are written out here, +/// the same way Balette.Rebuild takes them, and labelled as the palette's layout. +/// +/// +/// Every explanation of a single command is the library's answer too: the term that failed is found by +/// matching each term on its own, and the field that earned a term's score by matching the term against +/// that field alone and comparing the weighted scores - no second copy of the scoring rules is consulted. +/// +/// +public static class BaletteSearchLab +{ + /// The most commands one inspection reads. A registry in the thousands is a performance question, not a ranking one. + public const int MaxCommands = 500; + + /// The most commands one answer reports on. + public const int MaxLimit = 200; + + /// The matchers this tool can run, as the demo's own search lab offers them. + public static readonly string[] Matchers = ["fuzzy", "fuzzy-ignore-case", "substring"]; + + private sealed record Registry(BaletteOptions Options, BaletteCommand[] Commands); + + private static readonly Lazy _demo = new(BuildDemoRegistry); + + private static readonly BaletteMatchWeights _titleOnly = new() { Title = 1 }; + + private static readonly IReadOnlyDictionary _priorityLevels = + typeof(BaletteCommandPriority).GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(field => field.IsLiteral && field.FieldType == typeof(int)) + .ToDictionary(field => field.Name, field => (int)field.GetRawConstantValue()!, StringComparer.OrdinalIgnoreCase); + + /// The root commands of this demo's app-wide registry, in registry order. + public static IReadOnlyList DemoRegistry => _demo.Value.Commands; + + /// The options this demo registers its palette with - its group order, its modes. + public static BaletteOptions DemoOptions => _demo.Value.Options; + + public static BaletteSearchInspectionDto Inspect(string? query, string? commands, string? matcher, bool groupWhenSearching, int limit) + { + query ??= string.Empty; + + var notes = new List(); + + BaletteOptions options; + List candidates; + string source; + + if (string.IsNullOrWhiteSpace(commands)) + { + options = _demo.Value.Options; + candidates = [.. _demo.Value.Commands]; + source = $"this demo's registry: the {candidates.Count} root commands Layout/MainLayout.razor registers from Services/DemoCommands.cs - the candidates the demo's own Search and ranking page runs its lab over"; + + query = ApplyMode(query, options, candidates, notes); + } + else + { + options = new BaletteOptions(); + candidates = ParseCommands(commands, notes); + source = $"the {candidates.Count} commands passed in, ranked with the default BaletteOptions"; + } + + var (matcherName, matcherInstance) = ResolveMatcher(matcher, notes); + var weights = options.Weights; + + // What the palette calls a query: the text without surrounding spaces. Only then does it decide + // whether there is one - a query of spaces is the empty query, ordered by priority alone. + var hasQuery = query.Trim().Length > 0; + var terms = Split(query).Select(raw => DescribeTerm(raw, matcherInstance)).ToArray(); + + var hidden = candidates.Where(command => command.IsVisible?.Invoke() == false).ToList(); + var visible = candidates.Where(command => command.IsVisible?.Invoke() != false).ToList(); + + var matches = new List(visible.Count); + var unmatched = new List(); + + foreach (var command in visible) + { + if (matcherInstance.Match(command, query, weights) is { } match) matches.Add(match); + else if (command.AlwaysShow) matches.Add(new BaletteMatch(command, double.NegativeInfinity, [], [], null)); + else unmatched.Add(command); + } + + // No history is kept here, so frecency is 0 for every command - exactly what a first visit sees. + var ranked = new BaletteRanker(options).Rank(matches, hasQuery, _ => 0); + var listed = ranked.Take(options.MaxResults).ToList(); + var rows = Layout(listed, hasQuery, groupWhenSearching, options); + + var order = Positions(ranked); + var row = Positions(rows); + + var results = new List(candidates.Count); + + foreach (var match in rows.Concat(ranked.Skip(options.MaxResults))) + { + results.Add(Report(match, terms, matcherInstance, weights, order[match.Command], row.GetValueOrDefault(match.Command))); + } + + foreach (var command in unmatched) results.Add(Unmatched(command, terms, matcherInstance, weights)); + + foreach (var command in hidden) + { + results.Add(new BaletteInspectedCommandDto + { + Title = command.Title, + Subtitle = command.Subtitle, + Group = command.Group, + Id = command.Id, + Matched = false, + Reason = "Hidden: its IsVisible predicate returned false, so the palette does not search it at all.", + Priority = command.Priority + }); + } + + var take = Math.Clamp(limit, 1, MaxLimit); + + Explain(notes, terms, hasQuery, groupWhenSearching, options, matcherName, ranked, listed, rows, demo: string.IsNullOrWhiteSpace(commands)); + + return new BaletteSearchInspectionDto + { + Terms = terms, + Matcher = matcherName, + Commands = source, + Ranking = hasQuery + ? FormattableString.Invariant($"rank = score + priority x {options.PriorityWeight} + ln(1 + frecency) x {options.FrecencyWeight}; frecency is 0 here") + : "rank = priority x 1000 + frecency (the empty query); frecency is 0 here", + Searched = visible.Count, + Matched = matches.Count(match => double.IsNegativeInfinity(match.Score) is false), + Results = [.. results.Take(take)], + HasMore = results.Count > take, + Notes = [.. notes] + }; + } + + /// + /// The palette's layout of the ranked list: under group headings - ungrouped commands first, then on + /// the empty query the groups in , then by their best rank, then + /// by name - or, while searching with GroupWhenSearching off, the ranking itself. Inside a group the + /// ranking's order is kept. This is the step Balette.Rebuild takes after BaletteRanker.Rank. + /// + private static List Layout(List ranked, bool hasQuery, bool groupWhenSearching, BaletteOptions options) + { + if (hasQuery && groupWhenSearching is false) return ranked; + + return [.. ranked + .GroupBy(match => match.Command.Group ?? string.Empty) + .Select(group => new { Name = group.Key, Items = group.ToList(), Best = group.Max(match => match.Rank), Order = GroupOrderIndex(options, group.Key) }) + .OrderBy(group => group.Name.Length == 0 ? 0 : 1) + .ThenBy(group => hasQuery ? 0 : group.Order) + .ThenByDescending(group => group.Best) + .ThenBy(group => group.Name, StringComparer.CurrentCultureIgnoreCase) + .SelectMany(group => group.Items)]; + } + + /// + /// Each command's 1-based place in a list. Keyed by the instance rather than by id: two commands + /// passed in with the same title share a derived id, and are still two rows. + /// + private static Dictionary Positions(IEnumerable matches) + { + var positions = new Dictionary(ReferenceEqualityComparer.Instance); + + foreach (var match in matches) positions.TryAdd(match.Command, positions.Count + 1); + + return positions; + } + + private static int GroupOrderIndex(BaletteOptions options, string group) + { + var index = options.GroupOrder.FindIndex(name => string.Equals(name, group, StringComparison.OrdinalIgnoreCase)); + + return index < 0 ? int.MaxValue : index; + } + + private static BaletteInspectedCommandDto Report(BaletteMatch match, BaletteQueryTermDto[] terms, IBaletteMatcher matcher, BaletteMatchWeights weights, int order, int row) + { + var command = match.Command; + + if (double.IsNegativeInfinity(match.Score)) + { + return new BaletteInspectedCommandDto + { + Title = command.Title, + Subtitle = command.Subtitle, + Group = command.Group, + Id = command.Id, + Matched = false, + Reason = $"AlwaysShow: it is listed whatever the query, after every match. {Why(command, terms, matcher, weights)}", + AlwaysShown = true, + Order = order, + Row = row == 0 ? null : row, + Priority = command.Priority + }; + } + + var scores = terms.Length == 0 ? [] : terms.Select(term => TermScore(command, term, matcher, weights)).ToArray(); + var fields = scores.Where(score => score.Score is not null).Select(score => score.Field!).Distinct(StringComparer.Ordinal).ToArray(); + + return new BaletteInspectedCommandDto + { + Title = command.Title, + Subtitle = command.Subtitle, + Group = command.Group, + Id = command.Id, + Matched = true, + Score = Math.Round(match.Score, 3), + Rank = Math.Round(match.Rank, 3), + Order = order, + Row = row == 0 ? null : row, + Priority = command.Priority, + MatchedOn = fields.Length == 0 ? null : string.Join(" + ", fields), + MatchedKeyword = match.MatchedKeyword, + TitlePositions = match.TitlePositions.Length == 0 ? null : match.TitlePositions, + SubtitlePositions = match.SubtitlePositions.Length == 0 ? null : match.SubtitlePositions, + HighlightedTitle = match.TitlePositions.Length == 0 ? null : Highlight(command.Title, match.TitlePositions), + HighlightedSubtitle = match.SubtitlePositions.Length == 0 || command.Subtitle is null ? null : Highlight(command.Subtitle, match.SubtitlePositions), + // One term says no more than MatchedOn and Score already do. + Terms = scores.Length > 1 ? scores : null + }; + } + + private static BaletteInspectedCommandDto Unmatched(BaletteCommand command, BaletteQueryTermDto[] terms, IBaletteMatcher matcher, BaletteMatchWeights weights) + { + return new BaletteInspectedCommandDto + { + Title = command.Title, + Subtitle = command.Subtitle, + Group = command.Group, + Id = command.Id, + Matched = false, + Reason = Why(command, terms, matcher, weights), + Priority = command.Priority + }; + } + + /// + /// Why a command did not match: every term is matched on its own - the query is an AND of its + /// terms, so the ones that fail alone are exactly the ones that fail it - and each failure is put + /// in the words of the operator it was written with. + /// + private static string Why(BaletteCommand command, BaletteQueryTermDto[] terms, IBaletteMatcher matcher, BaletteMatchWeights weights) + { + var reasons = new List(); + + foreach (var term in terms) + { + if (matcher.Match(command, term.Text, weights) is not null) continue; + + reasons.Add(term.Operator switch + { + "negate" => $"'{term.Text}' excludes it: '{term.Term}' occurs in its {string.Join(" and ", NegatedFields(command, term.Term))}.", + "exact" => $"'{term.Text}': no field contains '{term.Term}' as one unbroken run.", + "prefix" => $"'{term.Text}': no field starts with '{term.Term}'.", + "suffix" => $"'{term.Text}': no field ends with '{term.Term}'.", + _ when term.CaseSensitive && matcher.Match(command, term.Text.ToLowerInvariant(), weights) is not null => + $"'{term.Text}' has a capital letter, so it is matched case-sensitively (smart case) - and only '{term.Text.ToLowerInvariant()}' occurs, in another case.", + "substring" => $"'{term.Text}' occurs in none of its fields as one unbroken run.", + _ => $"The letters of '{term.Text}' do not occur in that order in its title, keywords, subtitle, group, description or tags." + }); + } + + return reasons.Count == 0 ? "It matches no term of the query." : string.Join(" ", reasons); + } + + /// The fields a negated term looks in - the title, the subtitle, the group and the keywords, ignoring case - that hold it. + private static IEnumerable NegatedFields(BaletteCommand command, string term) + { + const StringComparison IgnoreCase = StringComparison.OrdinalIgnoreCase; + + if (command.Title.Contains(term, IgnoreCase)) yield return "title"; + if (command.Subtitle?.Contains(term, IgnoreCase) == true) yield return "subtitle"; + if (command.Group?.Contains(term, IgnoreCase) == true) yield return "group"; + if (command.Keywords.FirstOrDefault(keyword => keyword.Contains(term, IgnoreCase)) is { } keyword) yield return $"keyword '{keyword}'"; + } + + /// + /// What one term scored against a command, and in which field. The library keeps the best weighted + /// field and reports only the title and subtitle positions and the keyword, so for a term won by the + /// group, the description or a tag, each of those is matched alone and the one whose weighted score + /// is the term's score is the field - in the library's own order, title first, which is the order its + /// ties go in. + /// + private static BaletteTermScoreDto TermScore(BaletteCommand command, BaletteQueryTermDto term, IBaletteMatcher matcher, BaletteMatchWeights weights) + { + if (term.Operator == "negate") return new BaletteTermScoreDto { Term = term.Text, Field = "not present" }; + + var match = matcher.Match(command, term.Text, weights); + if (match is null) return new BaletteTermScoreDto { Term = term.Text }; + + string field; + + if (match.TitlePositions.Length > 0) field = "title"; + else if (match.MatchedKeyword is { } keyword) field = $"keyword '{keyword}'"; + else if (match.SubtitlePositions.Length > 0) field = "subtitle"; + else + { + (string Name, string? Text, double Weight)[] others = + [ + ("group", command.Group, weights.Group), + ("description", command.Description, weights.Description), + .. command.Tags.Select(tag => ($"tag '{tag}'", (string?)tag, weights.Tag)) + ]; + + field = others.FirstOrDefault(other => other.Text is { Length: > 0 } + && matcher.Match(new BaletteCommand { Title = other.Text }, term.Text, _titleOnly) is { } alone + && Math.Abs((alone.Score * other.Weight) - match.Score) < 1e-9).Name + ?? "group, description or tag"; + } + + return new BaletteTermScoreDto { Term = term.Text, Score = Math.Round(match.Score, 3), Field = field }; + } + + /// The text with every run of highlighted characters in brackets: "[G]o to [S]ettings". + public static string Highlight(string text, int[] positions) + { + var marked = new HashSet(positions.Where(position => position >= 0 && position < text.Length)); + var builder = new StringBuilder(text.Length + (2 * marked.Count)); + + for (var i = 0; i < text.Length; i++) + { + var on = marked.Contains(i); + + if (on && (i == 0 || marked.Contains(i - 1) is false)) builder.Append('['); + builder.Append(text[i]); + if (on && (i == text.Length - 1 || marked.Contains(i + 1) is false)) builder.Append(']'); + } + + return builder.ToString(); + } + + /// The query's terms, split exactly where the palette splits them: on spaces. + private static string[] Split(string query) => query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + /// + /// One term with the operator its first or last character gives it - the syntax the guide documents + /// ('exact, ^prefix, suffix$, !negate; a lone operator character is a + /// plain term) - and whether it matches case-sensitively. + /// + private static BaletteQueryTermDto DescribeTerm(string raw, IBaletteMatcher matcher) + { + var plain = matcher is BaletteSubstringMatcher ? "substring" : "fuzzy"; + + var (term, op) = raw.Length > 1 ? raw[0] switch + { + '!' => (raw[1..], "negate"), + '\'' => (raw[1..], "exact"), + '^' => (raw[1..], "prefix"), + _ when raw[^1] == '$' => (raw[..^1], "suffix"), + _ => (raw, plain) + } : (raw, plain); + + return new BaletteQueryTermDto + { + Text = raw, + Term = term, + Operator = op, + CaseSensitive = op == "fuzzy" && matcher is BaletteFuzzyMatcher { IgnoreCase: false } && term.Any(char.IsUpper) + }; + } + + private static (string Name, IBaletteMatcher Matcher) ResolveMatcher(string? matcher, List notes) + { + switch ((matcher ?? "fuzzy").Trim().ToLowerInvariant()) + { + case "" or "fuzzy" or "default" or "smart-case": + return ("fuzzy", BaletteFuzzyMatcher.Default); + + case "fuzzy-ignore-case" or "ignore-case" or "ignorecase": + return ("fuzzy-ignore-case", new BaletteFuzzyMatcher { IgnoreCase = true }); + + case "substring": + return ("substring", BaletteSubstringMatcher.Default); + + default: + notes.Add($"'{matcher}' is not a matcher this tool runs, so the default fuzzy one was used. Use one of: {string.Join(", ", Matchers)}."); + return ("fuzzy", BaletteFuzzyMatcher.Default); + } + } + + /// + /// The palette's modes, for the demo's registry: a query that starts with a mode's prefix searches + /// that mode - only the commands its Tags and Groups admit, none at all when it hides the registry - + /// with the rest of the query. The longest prefix wins, as it does in the palette, so ">>" and ">" + /// can coexist. The mode's own sources are asynchronous calls this tool does not make. + /// + private static string ApplyMode(string query, BaletteOptions options, List candidates, List notes) + { + var mode = options.Modes.OrderByDescending(m => m.Prefix.Length).FirstOrDefault(m => query.StartsWith(m.Prefix, StringComparison.Ordinal)); + if (mode is null) return query; + + candidates.RemoveAll(command => mode.HideRegistryCommands + || (mode.Tags is { Count: > 0 } && command.Tags.Any(tag => mode.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) is false) + || (mode.Groups is { Count: > 0 } && (command.Group is null || mode.Groups.Contains(command.Group, StringComparer.OrdinalIgnoreCase) is false))); + + var scope = mode.HideRegistryCommands + ? "it hides the registry and lists only its sources" + : string.Join(" and ", new[] + { + mode.Tags is { Count: > 0 } ? $"only commands tagged {string.Join(" or ", mode.Tags.Select(tag => $"'{tag}'"))}" : null, + mode.Groups is { Count: > 0 } ? $"only the {string.Join(" and ", mode.Groups.Select(group => $"'{group}'"))} group" : null + }.Where(part => part is not null)); + + var sources = mode.Sources.Count > 0 ? $" Its sources ({string.Join(", ", mode.Sources.Select(source => source.Name))}) answer asynchronously and are not run here." : string.Empty; + + notes.Add($"'{mode.Prefix}' is the prefix of this demo's '{mode.Name}' mode, so the query is '{query[mode.Prefix.Length..].Trim()}' within that mode: {(scope.Length > 0 ? scope : "every command")}.{sources}"); + + return query[mode.Prefix.Length..]; + } + + /// + /// The commands passed in: a JSON array of objects (or of plain titles), or one title per line. Only + /// what a search reads is taken - the fields it matches, the priority it ranks by, AlwaysShow, the id. + /// + private static List ParseCommands(string text, List notes) + { + var trimmed = text.Trim(); + var commands = new List(); + + if (trimmed.StartsWith('[') is false) + { + foreach (var line in trimmed.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + commands.Add(BaletteCmd.New(line).Build()); + } + + return Capped(commands, notes); + } + + JsonDocument document; + + try + { + document = JsonDocument.Parse(trimmed, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip }); + } + catch (JsonException exception) + { + notes.Add($"commands starts with '[' but is not valid JSON ({exception.Message}), so nothing was searched. Pass a JSON array of {{ \"title\", \"subtitle\", \"keywords\", \"group\", \"description\", \"tags\", \"priority\" }} objects, or one title per line."); + return commands; + } + + using (document) + { + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + notes.Add("commands is not a JSON array, so nothing was searched."); + return commands; + } + + var ignored = new SortedSet(StringComparer.OrdinalIgnoreCase); + var index = 0; + + foreach (var element in document.RootElement.EnumerateArray()) + { + index++; + + if (element.ValueKind == JsonValueKind.String) + { + if (string.IsNullOrWhiteSpace(element.GetString()) is false) commands.Add(BaletteCmd.New(element.GetString()!.Trim()).Build()); + continue; + } + + if (element.ValueKind != JsonValueKind.Object || Text(element, "title") is not { Length: > 0 } title) + { + notes.Add($"Element {index} of commands has no title and was skipped."); + continue; + } + + var builder = BaletteCmd.New(title) + .Subtitle(Text(element, "subtitle")) + .Description(Text(element, "description")) + .Group(Text(element, "group")) + .Keywords([.. List(element, "keywords")]) + .Tags([.. List(element, "tags")]) + .Priority(Priority(element, index, notes)); + + if (Text(element, "id") is { Length: > 0 } id) builder.Id(id); + if (Property(element, "alwaysShow") is { ValueKind: JsonValueKind.True }) builder.AlwaysShow(); + + foreach (var property in element.EnumerateObject()) + { + if (_knownFields.Contains(property.Name) is false) ignored.Add(property.Name); + } + + commands.Add(builder.Build()); + } + + if (ignored.Count > 0) notes.Add($"Fields a search does not read were ignored: {string.Join(", ", ignored)}."); + } + + return Capped(commands, notes); + } + + private static readonly HashSet _knownFields = new(StringComparer.OrdinalIgnoreCase) + { + "title", "subtitle", "description", "group", "keywords", "tags", "priority", "id", "alwaysShow" + }; + + private static List Capped(List commands, List notes) + { + if (commands.Count <= MaxCommands) return commands; + + notes.Add($"Only the first {MaxCommands} of the {commands.Count} commands were searched."); + + return commands[..MaxCommands]; + } + + private static JsonElement? Property(JsonElement element, string name) + { + foreach (var property in element.EnumerateObject()) + { + if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) return property.Value; + } + + return null; + } + + private static string? Text(JsonElement element, string name) => Property(element, name) switch + { + { ValueKind: JsonValueKind.String } value => value.GetString(), + { ValueKind: JsonValueKind.Number } value => value.GetRawText(), + _ => null + }; + + /// An array of strings, or one string split on commas and spaces the way BaletteItem's Keywords parameter is. + private static IEnumerable List(JsonElement element, string name) => Property(element, name) switch + { + { ValueKind: JsonValueKind.Array } array => array.EnumerateArray().Where(item => item.ValueKind == JsonValueKind.String).Select(item => item.GetString()!).Where(item => item.Length > 0), + { ValueKind: JsonValueKind.String } value => value.GetString()!.Split([',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), + _ => [] + }; + + /// A number, or the name of a BaletteCommandPriority level ("High"). + private static int Priority(JsonElement element, int index, List notes) + { + switch (Property(element, "priority")) + { + case null: + return BaletteCommandPriority.Normal; + + case { ValueKind: JsonValueKind.Number } number when number.TryGetInt32(out var value): + return value; + + case { ValueKind: JsonValueKind.String } named when _priorityLevels.TryGetValue(named.GetString()!.Replace("BaletteCommandPriority.", string.Empty, StringComparison.Ordinal), out var level): + return level; + + default: + notes.Add($"The priority of element {index} is neither a whole number nor one of {string.Join(", ", _priorityLevels.Keys)}, so it was left at Normal (0)."); + return BaletteCommandPriority.Normal; + } + } + + private static void Explain(List notes, BaletteQueryTermDto[] terms, bool hasQuery, bool groupWhenSearching, BaletteOptions options, + string matcherName, List ranked, List listed, List rows, bool demo) + { + var weights = options.Weights; + + notes.Add(matcherName switch + { + "fuzzy-ignore-case" => "Matcher: new BaletteFuzzyMatcher { IgnoreCase = true } - fzf's algorithm (a term matches when its characters occur in order; word starts, camel humps and consecutive runs score most, gaps cost), with case never counting.", + "substring" => "Matcher: BaletteSubstringMatcher.Default - a term has to occur as one unbroken run, ignoring case: no typo tolerance, and a match at the very start scores a bonus.", + _ => "Matcher: BaletteFuzzyMatcher.Default, the palette's default - fzf's algorithm: a term matches when its characters occur in order; word starts, camel humps and consecutive runs score most, gaps cost, and a shorter text or an earlier match wins a tie. Smart case: a term in lower case ignores case, one with a capital letter only matches the same case." + }); + + if (hasQuery is false) + { + notes.Add($"The query is empty, so nothing is matched: every command scores 0 and rank = priority x 1000 + frecency - the priority decides. {(options.GroupOrder.Count > 0 ? $"The palette lists them under their groups: ungrouped first, then {string.Join(", ", options.GroupOrder)} (options.GroupOrder), then the other groups by their best rank." : "The palette lists them under their groups: ungrouped first, then the groups by their best rank, then by name.")}"); + + if (demo && options.ShowModeHints && options.Modes.Count > 0) + { + notes.Add($"On the empty query the palette also lists this demo's {options.Modes.Count} modes ({string.Join(", ", options.Modes.Select(mode => $"'{mode.Prefix}' {mode.Name}"))}) under \"Modes\", at the lowest priority; they are not commands and are left out here."); + } + } + else + { + var matching = terms.Where(term => term.Operator != "negate").ToArray(); + + if (matching.Length > 1) + { + notes.Add($"{matching.Length} terms: every one has to match (AND), each against whichever field scores it best, and the command's score is the sum of the terms' scores - Terms shows what each one earned where."); + } + + notes.Add(FormattableString.Invariant($"Field weights (BaletteOptions.Weights): title {weights.Title}, keywords {weights.Keyword}, subtitle {weights.Subtitle}, group {weights.Group}, description {weights.Description}, tags {weights.Tag} - a term's score in a field is multiplied by the field's weight and the best field counts, so the same hit scores more in the title than in a keyword. Only title and subtitle hits are highlighted; a keyword hit is shown under the title.")); + + notes.Add(FormattableString.Invariant($"rank = score + priority x {options.PriorityWeight} + ln(1 + frecency) x {options.FrecencyWeight}: the match dominates, a priority of {BaletteCommandPriority.High} (High) is worth {BaletteCommandPriority.High * options.PriorityWeight} points, and in a running palette a command used often and recently climbs by its frecency - which is 0 for every command here, since this server keeps no history.")); + + foreach (var term in terms) + { + switch (term.Operator) + { + case "negate": + notes.Add($"'{term.Text}' is a negation: it drops every command whose title, subtitle, group or keywords contain '{term.Term}', ignoring case - its description and tags are not looked at - and adds nothing to the score of the rest."); + break; + + case "exact": + notes.Add($"'{term.Text}' is exact: '{term.Term}' has to occur as one unbroken run in a field, ignoring case, and it scores like a perfect consecutive run."); + break; + + case "prefix": + notes.Add($"'{term.Text}' is a prefix: a field has to start with '{term.Term}', ignoring case."); + break; + + case "suffix": + notes.Add($"'{term.Text}' is a suffix: a field has to end with '{term.Term}', ignoring case."); + break; + + case "fuzzy" when term.CaseSensitive: + notes.Add($"'{term.Text}' has a capital letter, so it matches case-sensitively (smart case): type it in lower case to ignore case, or run the fuzzy-ignore-case matcher."); + break; + } + } + + notes.Add(groupWhenSearching + ? "GroupWhenSearching is on (the default): the palette lists the ranked commands under their group headings - ungrouped first, then the group holding the best match - so Row can differ from Order. Pass groupWhenSearching=false for one flat ranked list, where Row is Order." + : "GroupWhenSearching is off: the palette lists one flat ranked list, so Row is Order."); + } + + if (ranked.Count > listed.Count) + { + notes.Add($"Only the best {options.MaxResults} (BaletteOptions.MaxResults) are listed; the {ranked.Count - listed.Count} ranked after them have no Row."); + } + + if (rows.FirstOrDefault(match => double.IsNegativeInfinity(match.Score)) is { } always) + { + notes.Add($"'{always.Command.Title}' is AlwaysShow: it is listed whatever the query, after every match - in its group's place when grouping, last otherwise."); + } + + if (rows.FirstOrDefault(match => match.Command.Disabled) is { } disabled) + { + notes.Add($"'{disabled.Command.Title}' is disabled: it is listed and ranked like any other command, but Enter shows its DisabledReason instead of running it."); + } + + if (hasQuery && ranked.Count(match => double.IsNegativeInfinity(match.Score) is false) == 0) + { + notes.Add("Nothing matched. The palette shows its empty state (and any AlwaysShow command) - try fewer letters, a lower-case term, or a keyword: SearchBalette(query: \"keywords\") explains where a command's aliases go."); + } + } + + /// + /// The demo's own registry, built the way the demo builds it: its AddDemoServices registration, its + /// DemoState, the commands its layout registers through BaletteCommands - registered with the real + /// BaletteService, which flattens nested pages into the registry - and the root ones of those, the + /// candidates of the palette's root page. Nothing here needs a browser: DemoState only reaches for + /// JavaScript when a command runs, and no command runs here. + /// + private static Registry BuildDemoRegistry() + { + var services = new ServiceCollection(); + + services.AddSingleton(NoBrowser.Instance); + services.AddDemoServices(); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var state = scope.ServiceProvider.GetRequiredService(); + var palette = scope.ServiceProvider.GetRequiredService(); + + using var registration = palette.Register(DemoCommands.Global(state)); + + return new Registry(scope.ServiceProvider.GetRequiredService(), [.. palette.Commands.Where(command => command.Parent is null)]); + } + + /// The JavaScript runtime of a server that has no browser: DemoState never calls it, because no command is run here. + private sealed class NoBrowser : IJSRuntime + { + public static readonly NoBrowser Instance = new(); + + public ValueTask InvokeAsync(string identifier, object?[]? args) + => throw new InvalidOperationException("The MCP server inspects commands; it never runs them, so there is no browser to call."); + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) + => throw new InvalidOperationException("The MCP server inspects commands; it never runs them, so there is no browser to call."); + } +} diff --git a/src/Balette/Demo/Server/Services/BaletteServerInstructions.cs b/src/Balette/Demo/Server/Services/BaletteServerInstructions.cs new file mode 100644 index 0000000000..850b17e5ee --- /dev/null +++ b/src/Balette/Demo/Server/Services/BaletteServerInstructions.cs @@ -0,0 +1,59 @@ +using System.Reflection; + +namespace Bit.Balette.Demo.Server.Services; + +/// +/// The text the server returns as instructions from the initialize handshake. +/// +/// A client puts this in front of the model once, before any tool has been called, which makes it +/// the only place where the server can say something the model will read whether or not it decides +/// to use the tools at all. So it stays short - every word is permanently in someone's context +/// window - and spends itself on the working rules rather than on describing the library, which the +/// tools do far better. +/// +/// +public static class BaletteServerInstructions +{ + /// + /// The build the answers come from, so the text names it rather than claiming to be timeless. + /// Read off the library assembly rather than written down here: the package version is stamped + /// into it by Bit.Build.props, so there is no second copy for a release to forget. The + /// informational version's build metadata - everything after '+', usually the commit hash - is + /// dropped: it identifies nothing a caller could act on. + /// + public static readonly string BaletteVersion = ( + typeof(BaletteOptions).Assembly.GetCustomAttribute()?.InformationalVersion + ?? typeof(BaletteOptions).Assembly.GetName().Version?.ToString() + ?? "unknown").Split('+')[0]; + + public static readonly string Text = $""" + Bit.Balette is a keyboard-first command palette for Blazor: the Ctrl+K / Cmd+K box with fzf-style fuzzy search, + nested pages, prefix modes, async sources, recents and frecency, global shortcuts and sequences, multi-select, + prompts and theming. Matching, ranking and every decision are C#; the browser only owns the dialog, focus and keys. + + Every answer here is read out of Bit.Balette {BaletteVersion} as it is loaded in this server - its compiled XML + documentation, its README, its stylesheet and component markup, its matcher and ranker, and the demo's source files. + Where what you remember and what these tools say disagree, the tools are describing the build in front of you. + + Working rules: + + - Start with `SearchBalette` unless you already know the section or type name you want. It covers the guide, every + public member, the setup guides, the stylesheet's variables and classes and the demo sources at once, and every + hit names the exact follow-up call that returns its full text. + - Every reference tool takes the key of one thing and answers with that thing; leave the key out and it answers + with the index of what there is - guide sections, public types, source files. + - Do not write a Balette type or member from memory. There is one package and one namespace, `Bit.Balette`, and + every public type is prefixed `Balette` (`Balette` is the component, `BaletteCommand`, `BaletteCmd`, + `IBaletteService`, `BaletteOptions`...); `GetBaletteApi` gives the real names, types and default values. + - Call `GetBaletteSetupGuide` before writing wiring: the services go in every DI container that renders the + palette, the host page links `_content/Bit.Balette/bit-balette.css` itself (it is not injected), and the one + `` sits in an interactively rendered layout - each fails silently when missing. + - Commands are immutable: give them an explicit `Id` when the title can change, and register a new list (or call + `Update`) to change one. Dispose what a page registers. + - To see why a command ranks where it does, or does not show up at all, call `InspectBaletteSearch` - it runs the + library's own matcher and ranker. Restyle through the `--bit-balette-*` variables (`GetBaletteThemeVariables`). + + Every tool on this server is read-only and cheap: none of them changes anything, anywhere, so there is no reason + to ration them or to ask before calling one. + """; +} diff --git a/src/Balette/Demo/Server/Services/BaletteSetupGuide.cs b/src/Balette/Demo/Server/Services/BaletteSetupGuide.cs new file mode 100644 index 0000000000..9baac87072 --- /dev/null +++ b/src/Balette/Demo/Server/Services/BaletteSetupGuide.cs @@ -0,0 +1,411 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace Bit.Balette.Demo.Server.Services; + +/// +/// Assembles the wiring for one Blazor render mode: the checklist, the guide's own section on that +/// hosting model, and - where the demo is that render mode - the demo's real hosting files. +/// +/// Four things have to be in place before Ctrl+K opens anything, and each one fails quietly when it is +/// missing: the services in every DI container that renders the palette (a missing one surfaces while +/// prerendering, not at compile time), the stylesheet link (it is not injected - without it the palette +/// opens as unstyled markup at the top of the page), one <Balette /> in a layout that is +/// rendered interactively (a statically rendered one never attaches its keyboard handling), and the +/// namespace import. Handing over a known-good project instead of prose removes the guesswork about +/// which project each of them belongs in; the guide's hosting sections, quoted verbatim, say the rest in +/// the library's own words. +/// +/// +public static partial class BaletteSetupGuide +{ + public static readonly string[] RenderModes = ["server", "wasm", "auto", "standalone-wasm", "hybrid"]; + + /// The demo files that make up its wiring - the demo is a Blazor Web App using InteractiveWebAssembly. + private static readonly string[] _hostingFiles = + [ + "Demo/Client/Extensions/IServiceCollectionExtensions.cs", + "Demo/Server/Program.cs", + "Demo/Client/Program.cs", + "Demo/Server/Components/App.razor", + "Demo/Client/Routes.razor", + "Demo/Client/Layout/MainLayout.razor", + "Demo/Client/_Imports.razor", + ]; + + private sealed record Mode(string Title, string Summary, string[] GuideSections, string? Files, bool IncludeDemo); + + /// The wiring for one render mode as Markdown, or null when the mode is not one of . + public static string? Get(string? renderMode) => Describe(renderMode) is { } mode ? Compose(mode) : null; + + /// What sets one render mode apart, in a paragraph - the line the search index ranks a setup guide by. + public static string Summary(string renderMode) => Describe(renderMode) is { } mode ? $"{mode.Title}. {mode.Summary.ReplaceLineEndings(" ")}" : string.Empty; + + private static Mode? Describe(string? renderMode) + { + var mode = (renderMode ?? string.Empty).Trim().ToLowerInvariant().Replace(" ", "-", StringComparison.Ordinal); + + mode = mode switch + { + "interactiveserver" or "blazor-server" => "server", + "webassembly" or "interactivewebassembly" or "blazor-wasm" => "wasm", + "interactiveauto" => "auto", + "standalone" or "standalone-webassembly" or "standalone-wasm-app" => "standalone-wasm", + "maui" or "blazor-hybrid" or "wpf" or "winforms" => "hybrid", + _ => mode + }; + + return mode switch + { + "server" => new Mode( + "Blazor Web App - InteractiveServer", + """ + One project, one DI container per circuit: `AddBitBaletteServices` is called once, in `Program.cs`, and every + user gets their own registry, open state and history. Shortcuts, sequences and the navigation keys are + matched in the browser, so typing on the page costs nothing; what crosses the connection is the query on + every keystroke and the command that was picked. The palette therefore searches synchronously per keystroke + rather than deferring to the next frame - the network already separates typing from rendering - and the + handlers run on the server, in the circuit's scope. Matching itself is fast there; what a user feels is + the round trip each keystroke waits for before the list updates - so on connections with real latency, + render the layout with `InteractiveWebAssembly` instead. + """, + ["Blazor Server", "Blazor Web App", "Content security policy"], + ServerFiles, + IncludeDemo: false), + + "wasm" => new Mode( + "Blazor Web App - InteractiveWebAssembly", + """ + Two projects, two DI containers: the server prerenders the very same layout the browser then runs, so + `AddBitBaletteServices` must be called in BOTH `Program.cs` files - the demo does it through one shared + extension method both projects call (`AddDemoServices` below), which also keeps the options identical. + The package and the components belong in the .Client project; the stylesheet is linked by the server's + `App.razor`. Matching and ranking run in the browser, next to the list they fill. This is exactly how the + demo is built, so the files below are its own - `AddDemoServices` also registers the demo's own state and + fake directory, and the MCP lines of the server's `Program.cs` (`AddControllers`, `AddMcpServer`, + `MapControllers`, `MapMcp`) serve this site's MCP server; neither is anything the palette needs. + """, + ["Blazor Web App", "Blazor WebAssembly", "Content security policy"], + null, + IncludeDemo: true), + + "auto" => new Mode( + "Blazor Web App - InteractiveAuto", + """ + Two projects, two DI containers, and the palette may run in either: the first visit renders on the server + while the WebAssembly runtime downloads, later ones in the browser. So BOTH containers register the + services - with the SAME options, since the palette must behave the same wherever it runs - and the + registry is per circuit on the server, per runtime in the browser. The demo below uses + InteractiveWebAssembly; for InteractiveAuto add `AddInteractiveServerComponents()` and + `AddInteractiveServerRenderMode()` to the server's `Program.cs` and use `@rendermode="InteractiveAuto"` on + `` and `` in `App.razor` - nothing else about the wiring changes. Its MCP lines serve this + site's MCP server, not the palette. + """, + ["Blazor Web App", "Blazor WebAssembly", "Blazor Server", "Content security policy"], + null, + IncludeDemo: true), + + "standalone-wasm" => new Mode( + "standalone Blazor WebAssembly", + """ + One project, one DI container for the lifetime of the app, and no prerendering - the palette is interactive + from the first render and searches in the browser. + """, + ["Blazor WebAssembly", "Content security policy"], + StandaloneFiles, + IncludeDemo: false), + + "hybrid" => new Mode( + "Blazor Hybrid (MAUI, WPF, Windows Forms)", + """ + One container, in-process: the palette runs in the BlazorWebView like any other component, and the history + goes to the web view's `localStorage`. The stylesheet is linked from the web view's `wwwroot/index.html`. + """, + ["Blazor Hybrid", "Content security policy"], + HybridFiles, + IncludeDemo: false), + + _ => null + }; + } + + private static string Compose(Mode mode) + { + var builder = new StringBuilder(); + + builder.AppendLine($"# Setting Balette up - {mode.Title}").AppendLine(); + builder.AppendLine(mode.Summary).AppendLine(); + builder.AppendLine(Checklist).AppendLine(); + + if (mode.Files is not null) builder.AppendLine(mode.Files).AppendLine(); + + if (mode.IncludeDemo) + { + builder.AppendLine("## The files, from this demo").AppendLine(); + builder.AppendLine("Everything below compiles as-is once the projects and namespaces are renamed to your own - except what is the ") + .AppendLine("demo's own: `` and the `DemoState`/`DemoCommands` the layout uses stand for your navigation and your commands.") + .AppendLine(); + + foreach (var path in _hostingFiles) + { + var content = BaletteSourceCatalog.GetSourceFile(path); + if (content is null) continue; + + AppendFile(builder, path, content); + } + } + + // The guide's own words on the hosting model, so the caveats are the library's rather than a + // paraphrase of them that can drift. + builder.AppendLine("## From the guide").AppendLine(); + + foreach (var heading in mode.GuideSections) + { + var section = BaletteSourceCatalog.GetGuideSection(heading); + if (section is null) continue; + + builder.AppendLine(Demote(section)).AppendLine(); + } + + builder.AppendLine(Next); + + return builder.ToString(); + } + + private const string Checklist = """ + ## Checklist + + 1. `dotnet add package Bit.Balette` - one package, everything under the `Bit.Balette` namespace. In a Blazor Web App + with an interactive client, add it to the .Client project (the server project gets it through its reference). + 2. Call `builder.Services.AddBitBaletteServices(...)` in EVERY DI container that renders the palette. A Blazor Web App + with an interactive client has two of them, and a missing registration surfaces as a failure to resolve + `IBaletteService` while the page is prerendered. Share one extension method between the two `Program.cs` files so + both get the same options. + 3. Link the stylesheet from the host page - `App.razor` in a Web App, `wwwroot/index.html` otherwise - BEFORE your own + stylesheet, so your overrides of its `--bit-balette-*` variables win: + ``. It is NOT injected for you: without it the + palette opens as unstyled markup. + 4. Add `@using Bit.Balette` to the `_Imports.razor` of every project with palette markup or commands. + 5. Render exactly one ``, in a layout that is rendered interactively (global interactivity, as below). In a + statically rendered layout it never attaches its keyboard handling, so Ctrl+K does nothing. With per-page + interactivity, wrap it in a small component rendered with an interactive mode, and register commands from + components in that same mode - the registry is scoped to one circuit or one WebAssembly runtime. + 6. Register commands with `IBaletteService.Register(...)` - and dispose the handle it returns when the page goes - or + with ``, which does both for you; app-wide ones once, from the layout or + through `options.Commands`. Commands are immutable: to change one, hand over a new list or call `Update`. + 7. Holding the component in a field (`@ref`) inside a namespace under `Bit` other than `Bit.Balette` itself: write the + type as `Bit.Balette.Balette` - there, `Balette` alone names the namespace, and the field does not compile. + """; + + private const string ServerFiles = """ + ## The files + + ### `Program.cs` + + ```csharp + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); + + builder.Services.AddBitBaletteServices(options => + { + options.GroupOrder.AddRange(["Navigation", "Actions"]); + }); + + var app = builder.Build(); + + app.UseAntiforgery(); + app.MapStaticAssets(); + app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + + app.Run(); + ``` + + ### `Components/App.razor` (the `` and the ``) + + ```razor + + + + ... + + ``` + + ### `Components/Layout/MainLayout.razor` + + ```razor + @inherits LayoutComponentBase + +
+ +
@Body
+ + @* App-wide commands, registered while the layout is rendered. *@ + + + @* One palette for the whole app. *@ + + + @code { + private readonly BaletteCommand[] _commands = + [ + BaletteCmd.Navigate("Home", "/").Group("Navigation").Shortcut("G H"), + BaletteCmd.Navigate("Settings", "/settings").Group("Navigation").Shortcut("G S").Keywords("preferences"), + ]; + } + ``` + + ### `Components/_Imports.razor` + + ```razor + @using Bit.Balette + ``` + + Blazor Server reconnects with a new circuit, and so a new scope: the commands are registered again by the + components that render, and the history is in the browser's `localStorage`, so recents and pins survive it. + """; + + private const string StandaloneFiles = """ + ## The files + + ### `Program.cs` + + ```csharp + using Microsoft.AspNetCore.Components.Web; + using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + + var builder = WebAssemblyHostBuilder.CreateDefault(args); + + builder.RootComponents.Add("#app"); + builder.RootComponents.Add("head::after"); + + builder.Services.AddBitBaletteServices(); + + await builder.Build().RunAsync(); + ``` + + ### `wwwroot/index.html` (the ``) + + ```html + + + ``` + + ### `Layout/MainLayout.razor` + + ```razor + @inherits LayoutComponentBase + + + +
@Body
+ + + ``` + + ### `_Imports.razor` + + ```razor + @using Bit.Balette + ``` + + There is no script tag to add: the library's JavaScript is a module it imports itself, from + `_content/Bit.Balette/bit-balette.js`. + """; + + private const string HybridFiles = """ + ## The files + + ### `MauiProgram.cs` (or the WPF / Windows Forms host) + + ```csharp + builder.Services.AddMauiBlazorWebView(); + + builder.Services.AddBitBaletteServices(); + ``` + + ### `wwwroot/index.html` (the ``) + + ```html + + ``` + + ### `_Imports.razor` + + ```razor + @using Bit.Balette + ``` + + Put `` in the `MainLayout.razor` the BlazorWebView renders. + """; + + private const string Next = """ + The commands themselves are plain objects - call `GetBaletteGuideSection(heading: "Commands")` for everything a + command can be, `GetBaletteSourceFile(path: "Demo/Client/Services/DemoCommands.cs")` for the demo's whole registry, + or `GetBaletteSourceFile()` for the index of the demo's working pages. + """; + + /// + /// Pushes a quoted guide section two heading levels down, so it nests under "From the guide" + /// instead of competing with the headings of this document. + /// + private static string Demote(string section) + { + var lines = section.Split('\n'); + var fenced = false; + + for (int i = 0; i < lines.Length; i++) + { + var trimmed = lines[i].TrimStart(); + + if (trimmed.StartsWith("```", StringComparison.Ordinal) || trimmed.StartsWith("~~~", StringComparison.Ordinal)) + { + fenced = fenced is false; + continue; + } + + if (fenced is false && lines[i].StartsWith('#')) lines[i] = $"#{lines[i]}"; + } + + return string.Join('\n', lines); + } + + private static void AppendFile(StringBuilder builder, string path, string content) + { + content = content.TrimEnd(); + var fence = Fence(content); + + builder.AppendLine($"### `{path}`").AppendLine() + .AppendLine($"{fence}{Language(path)}") + .AppendLine(content) + .AppendLine(fence) + .AppendLine(); + } + + /// + /// The fence for a file's code block. It has to outrun the longest run of backticks in the file + /// itself - a file that shows Markdown would otherwise end the block in the middle of its own + /// content, and the rest of the file would render as prose. + /// + private static string Fence(string content) + { + var longest = BacktickRunRegex().Matches(content).Select(match => match.Length).DefaultIfEmpty(0).Max(); + + return new string('`', Math.Max(3, longest + 1)); + } + + private static string Language(string path) => Path.GetExtension(path).ToLowerInvariant() switch + { + ".razor" => "razor", + ".cs" => "csharp", + ".csproj" => "xml", + ".css" => "css", + ".js" => "javascript", + _ => string.Empty + }; + + [GeneratedRegex("`+")] + private static partial Regex BacktickRunRegex(); +} diff --git a/src/Balette/Demo/Server/Services/BaletteSourceCatalog.cs b/src/Balette/Demo/Server/Services/BaletteSourceCatalog.cs new file mode 100644 index 0000000000..97a49eb48d --- /dev/null +++ b/src/Balette/Demo/Server/Services/BaletteSourceCatalog.cs @@ -0,0 +1,452 @@ +using System.Text; +using System.Reflection; +using System.Collections.Frozen; +using Bit.Balette.Demo.Server.Dtos; +using System.Text.RegularExpressions; + +namespace Bit.Balette.Demo.Server.Services; + +/// +/// Serves the hand-written material the MCP tools hand out: the library's README (its reference +/// guide), its shipped stylesheet and component markup, and every source file of this demo - the +/// pages are working palette features, and the hosting files are the wiring of a real Blazor Web App. +/// +/// All of them are embedded into this assembly by the .csproj rather than read from disk, so the +/// MCP server keeps working from a published, single-folder deployment where the repository is +/// nowhere to be found. +/// +/// +public static partial class BaletteSourceCatalog +{ + private const string ReadmeResource = "BaletteDocs/README.md"; + private const string SourcePrefix = "BaletteSource/"; + + /// The path the shipped stylesheet is served under, next to the demo's files. + public const string StylesheetPath = "Library/Styles/bit-balette.css"; + + /// Separates a parent heading from a child one in the key of a heading the README uses twice. + public const string KeySeparator = " > "; + + private sealed record Section(BaletteGuideSectionDto Dto, int Start, int End); + + private static readonly Assembly _assembly = typeof(BaletteSourceCatalog).Assembly; + + private static readonly Lazy _readme = new(() => ReadResource(ReadmeResource) ?? string.Empty); + private static readonly Lazy _readmeLines = new(() => Readme.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n')); + private static readonly Lazy _sections = new(BuildSections); + private static readonly Lazy _guideSections = new(() => [.. _sections.Value.Select(section => section.Dto)]); + private static readonly Lazy> _sourceFiles = new(BuildSourceFiles); + private static readonly Lazy _sourceFileList = new(BuildSourceFileList); + + /// The library's README, in full. + public static string Readme => _readme.Value; + + /// The library's stylesheet as it is written (not minified), comments and all. + public static string Stylesheet => GetSourceFile(StylesheetPath) ?? string.Empty; + + /// Where the shipped components' markup is served, next to the stylesheet. + public const string ComponentMarkupFolder = "Library/Components/"; + + /// + /// The markup of the shipped components that render something, keyed by component name - the + /// elements, classes and data-balette attributes the theme tool reads its hooks out of. + /// + public static IEnumerable<(string Component, string Markup)> ComponentMarkup => + SourceFiles.Where(file => file.Path.StartsWith(ComponentMarkupFolder, StringComparison.OrdinalIgnoreCase) + && file.Path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase)) + .Select(file => (Path.GetFileNameWithoutExtension(file.Path), GetSourceFile(file.Path) ?? string.Empty)); + + /// The palette's code-behind, where the values some of its markup binds are computed. + public static string PaletteCodeBehind => ReadResource("BaletteInternal/Balette.razor.cs") ?? string.Empty; + + /// + /// The guide's sections, as the index GetBaletteGuideSection answers with when it is called without + /// a heading. Plain text rather than a list of objects: an index is read, not parsed, and the + /// same list as JSON costs twice the characters to say the same thing. + /// + public static string RenderGuideIndex() + { + var builder = new StringBuilder(); + + builder.AppendLine("# Bit.Balette reference guide").AppendLine(); + builder.AppendLine("The library's README, section by section. Call `GetBaletteGuideSection(heading: \"...\")` for one of them; ") + .AppendLine("a section's line count is there so a heading that runs to hundreds of lines is a deliberate choice rather than a surprise.") + .AppendLine(); + + foreach (var section in GuideSections) + { + var indent = new string(' ', (section.Level - 2) * 2); + var key = section.Key == section.Heading ? null : $" - key `{section.Key}`"; + + builder.AppendLine($"{indent}- {section.Heading} ({section.Lines} lines){key}"); + } + + return builder.ToString(); + } + + /// + /// The files GetBaletteSourceFile can hand out, as the index it answers with when it is called + /// without a path. + /// + public static string RenderSourceIndex() + { + var builder = new StringBuilder(); + + builder.AppendLine("# Bit.Balette source files").AppendLine(); + builder.AppendLine("Working code this server can hand out verbatim. Call `GetBaletteSourceFile(path: \"...\")` for one.").AppendLine(); + + foreach (var group in SourceFiles.GroupBy(file => file.Kind)) + { + builder.AppendLine($"## {group.Key}").AppendLine(); + + foreach (var file in group) + { + builder.Append($"- `{file.Path}` ({file.Lines} lines)"); + if (file.Description is not null) builder.Append($" - {file.Description}"); + builder.AppendLine(); + } + + builder.AppendLine(); + } + + return builder.ToString(); + } + + /// Every level 2, 3 and 4 heading of the README, in reading order. + public static BaletteGuideSectionDto[] GuideSections => _guideSections.Value; + + /// Every embedded source file, keyed by the path the tools expose. + public static BaletteSourceFileDto[] SourceFiles => _sourceFileList.Value; + + /// + /// The README text under a heading, including its sub-sections. Matching is case- and + /// punctuation-insensitive so "recents pins" finds "Recents, pins and the action panel"; a heading the README + /// uses more than once is told apart by its key, "Parent > Heading", and otherwise answers with + /// the first section of that name. + /// + public static string? GetGuideSection(string heading) + { + var section = FindSection(heading); + if (section is null) return null; + + return string.Join('\n', _readmeLines.Value[section.Start..section.End]).TrimEnd(); + } + + /// The content of an embedded source file, or null when the path is unknown. + public static string? GetSourceFile(string path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + + return _sourceFiles.Value.GetValueOrDefault(Normalize(path)); + } + + private static Section? FindSection(string? heading) + { + if (string.IsNullOrWhiteSpace(heading)) return null; + + var sections = _sections.Value; + + var separator = heading.LastIndexOf('>'); + if (separator > 0) + { + var parent = NormalizeHeading(heading[..separator]); + var child = NormalizeHeading(heading[(separator + 1)..]); + + var qualified = sections.FirstOrDefault(section => section.Dto.Parent is not null + && NormalizeHeading(section.Dto.Parent) == parent + && NormalizeHeading(section.Dto.Heading) == child); + if (qualified is not null) return qualified; + } + + var normalized = NormalizeHeading(heading); + + return sections.FirstOrDefault(section => NormalizeHeading(section.Dto.Heading) == normalized) + ?? sections.FirstOrDefault(section => NormalizeHeading(section.Dto.Key) == normalized); + } + + private static string? ReadResource(string name) + { + using var stream = _assembly.GetManifestResourceStream(name); + if (stream is null) return null; + + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + return reader.ReadToEnd(); + } + + private static Section[] BuildSections() + { + var lines = _readmeLines.Value; + var headings = new List<(int Index, int Level, string Text)>(); + + var fenced = false; + for (int i = 0; i < lines.Length; i++) + { + if (IsCodeFence(lines[i])) + { + fenced = fenced is false; + continue; + } + + if (fenced) continue; + + if (TryReadHeading(lines[i], out var level, out var text) && level is >= 2 and <= 4) + { + headings.Add((i, level, text)); + } + } + + var duplicated = headings.GroupBy(heading => NormalizeHeading(heading.Text)) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToHashSet(StringComparer.Ordinal); + + var sections = new List
(headings.Count); + var parents = new string?[5]; + + for (int i = 0; i < headings.Count; i++) + { + var (index, level, text) = headings[i]; + + parents[level] = text; + for (int deeper = level + 1; deeper < parents.Length; deeper++) parents[deeper] = null; + + string? parent = null; + for (int upper = level - 1; upper >= 2 && parent is null; upper--) parent = parents[upper]; + + // The section runs until the next heading of the same or a higher rank. + var end = lines.Length; + for (int j = i + 1; j < headings.Count; j++) + { + if (headings[j].Level > level) continue; + end = headings[j].Index; + break; + } + + sections.Add(new Section(new BaletteGuideSectionDto + { + Heading = text, + Key = parent is not null && duplicated.Contains(NormalizeHeading(text)) ? $"{parent}{KeySeparator}{text}" : text, + Level = level, + Parent = parent, + Lines = end - index + }, index, end)); + } + + return [.. sections]; + } + + private static FrozenDictionary BuildSourceFiles() + { + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var resource in _assembly.GetManifestResourceNames()) + { + var normalized = Normalize(resource); + + if (normalized.StartsWith(SourcePrefix, StringComparison.OrdinalIgnoreCase) is false) continue; + + var content = ReadResource(resource); + if (content is null) continue; + + files[normalized[SourcePrefix.Length..]] = content; + } + + return files.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + } + + private static BaletteSourceFileDto[] BuildSourceFileList() + { + return [.. _sourceFiles.Value + .Select(file => new BaletteSourceFileDto + { + Path = file.Key, + Kind = KindOf(file.Key), + Description = DescribeSource(file.Key, file.Value), + Lines = CountLines(file.Value) + }) + .OrderBy(file => KindOrder(file.Kind)) + .ThenBy(file => file.Path, StringComparer.OrdinalIgnoreCase)]; + } + + /// + /// What a file is for: a demo page is a working palette feature to copy, the hosting files are + /// the wiring every app needs once, a library file is shipped by the package itself, and the rest + /// is the commands, state and sources the pages share. + /// + private static string KindOf(string path) + { + if (path.StartsWith("Demo/Client/Pages/", StringComparison.OrdinalIgnoreCase)) return "Page"; + if (path.StartsWith("Library/", StringComparison.OrdinalIgnoreCase)) return "Library"; + if (path.StartsWith("Demo/Server/", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("Demo/Client/Extensions/", StringComparison.OrdinalIgnoreCase) || + path.StartsWith("Demo/Client/Layout/", StringComparison.OrdinalIgnoreCase) || + path.Equals("Demo/Client/Program.cs", StringComparison.OrdinalIgnoreCase) || + path.Equals("Demo/Client/Routes.razor", StringComparison.OrdinalIgnoreCase) || + path.Equals("Demo/Client/_Imports.razor", StringComparison.OrdinalIgnoreCase)) return "Hosting"; + + return "Shared"; + } + + private static int KindOrder(string kind) => kind switch + { + "Page" => 0, + "Shared" => 1, + "Hosting" => 2, + _ => 3 + }; + + /// + /// The number of lines in a file. A trailing newline ends the last line rather than starting + /// another one, so a file that has one is not reported a line longer than it is. + /// + private static int CountLines(string content) + { + if (content.Length == 0) return 0; + + var newlines = content.Count(c => c == '\n'); + + return content[^1] == '\n' ? newlines : newlines + 1; + } + + /// + /// A one-line description of a source file, taken from whatever the file itself already says: + /// its leading razor/C#/CSS comment, or - for a page - its <PageTitle>. + /// + private static string? DescribeSource(string path, string content) + { + // A shipped component opens with its namespace and says what it is for in the XML docs of its + // parameters, so the first summary found in it describes one parameter. What it is served here + // for is its markup, which is what the description says instead. + if (path.StartsWith(ComponentMarkupFolder, StringComparison.OrdinalIgnoreCase)) + { + return $"The markup the shipped {System.IO.Path.GetFileNameWithoutExtension(path)} component renders: its elements, the bit-balette-* classes the stylesheet styles and the data-balette attributes an unstyled palette is styled by."; + } + + // A byte order mark is not text, and it would stop every pattern anchored at the start of the file. + content = content.TrimStart(''); + + var leadingComment = LeadingRazorCommentRegex().Match(content); + if (leadingComment.Success) return Summarize(leadingComment.Groups["text"].Value); + + // A demo page opens by saying what it demonstrates, to the person reading it - which is the + // best description of it there is, far better than the first comment somewhere in its code. + var intro = PageIntroRegex().Match(content); + if (intro.Success) return Summarize(System.Net.WebUtility.HtmlDecode(XmlTagRegex().Replace(intro.Groups["text"].Value, string.Empty))); + + // A stylesheet's banner is written as "/*!" plus a column of " * " prefixes, which are layout, + // not words of the description. + var blockComment = LeadingBlockCommentRegex().Match(content); + if (blockComment.Success) return Summarize(CommentGutterRegex().Replace(blockComment.Groups["text"].Value.TrimStart('!'), " ")); + + // A comment the file opens with describes the file; an XML summary further down describes + // one type inside it (Program.cs ends with one about the partial Program class). + var lineComment = LeadingLineCommentRegex().Match(content); + if (lineComment.Success) return Summarize(lineComment.Value.Replace("//", " ", StringComparison.Ordinal)); + + var summary = XmlSummaryRegex().Match(content); + if (summary.Success) + { + // An XML summary is markup: and friends are noise in a listing. + var text = summary.Groups["text"].Value.Replace("///", " ", StringComparison.Ordinal); + + return Summarize(XmlTagRegex().Replace(text, string.Empty)); + } + + var title = PageTitleRegex().Match(content); + if (title.Success) return Summarize(title.Groups["text"].Value); + + // Nothing at the top of the file said what it is - the first commentary in it will do. + var comment = RazorCommentRegex().Match(content); + + return comment.Success ? Summarize(comment.Groups["text"].Value) : null; + } + + private static string? Summarize(string text) + { + text = WhitespaceRegex().Replace(text, " ").Trim(); + if (text.Length == 0) return null; + + // The first sentence is the description; the rest is the file's own commentary. + var stop = text.IndexOf(". ", StringComparison.Ordinal); + if (stop > 0) text = text[..(stop + 1)]; + + return text.Length <= 220 ? text : $"{text[..217]}..."; + } + + /// + /// A Markdown code fence. A '#' line inside one is a shell comment or a C# preprocessor + /// directive, not a heading, and must not start or end a guide section. + /// + private static bool IsCodeFence(string line) + { + var text = line.TrimStart(); + + return text.StartsWith("```", StringComparison.Ordinal) || text.StartsWith("~~~", StringComparison.Ordinal); + } + + private static bool TryReadHeading(string line, out int level, out string text) + { + level = 0; + text = string.Empty; + + while (level < line.Length && line[level] == '#') level++; + + if (level is 0 or > 6 || level >= line.Length || line[level] != ' ') return false; + + text = line[(level + 1)..].Trim(); + + return text.Length > 0; + } + + /// Reduces a heading to its comparable core, so "search ranking" finds "## Search and ranking". + private static string NormalizeHeading(string heading) + { + var builder = new StringBuilder(heading.Length); + + // The little words carry nothing a heading is told apart by, and they are the ones a caller + // most often drops ("search ranking") or adds ("the priority and the frecency"). + foreach (var word in heading.Split(heading.Where(c => char.IsLetterOrDigit(c) is false).Distinct().ToArray(), StringSplitOptions.RemoveEmptyEntries)) + { + if (_fillerWords.Contains(word)) continue; + + foreach (var c in word) builder.Append(char.ToLowerInvariant(c)); + } + + return builder.ToString(); + } + + private static readonly HashSet _fillerWords = new(StringComparer.OrdinalIgnoreCase) { "a", "an", "and", "the", "of" }; + + private static string Normalize(string path) => path.Replace('\\', '/').Trim('/'); + + [GeneratedRegex(@"^\s*@\*(?.*?)\*@", RegexOptions.Singleline)] + private static partial Regex LeadingRazorCommentRegex(); + + [GeneratedRegex(@"^\s*/\*(?.*?)\*/", RegexOptions.Singleline)] + private static partial Regex LeadingBlockCommentRegex(); + + [GeneratedRegex(@"@\*(?.*?)\*@", RegexOptions.Singleline)] + private static partial Regex RazorCommentRegex(); + + [GeneratedRegex(@"<[^>]+>")] + private static partial Regex XmlTagRegex(); + + [GeneratedRegex(@"///\s*(?.*?)", RegexOptions.Singleline)] + private static partial Regex XmlSummaryRegex(); + + [GeneratedRegex(@"

(?.*?)

", RegexOptions.Singleline)] + private static partial Regex PageIntroRegex(); + + [GeneratedRegex(@"(?.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex PageTitleRegex(); + + [GeneratedRegex(@"^\s*(//[^\n]*\n)+")] + private static partial Regex LeadingLineCommentRegex(); + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRegex(); + + [GeneratedRegex(@"(?m)^\s*\*+(?!/)")] + private static partial Regex CommentGutterRegex(); +} diff --git a/src/Balette/Demo/Server/Services/BaletteStylesheetClasses.cs b/src/Balette/Demo/Server/Services/BaletteStylesheetClasses.cs new file mode 100644 index 0000000000..8fab0cbe60 --- /dev/null +++ b/src/Balette/Demo/Server/Services/BaletteStylesheetClasses.cs @@ -0,0 +1,130 @@ +using System.Text.RegularExpressions; + +namespace Bit.Balette.Demo.Server.Services; + +/// +/// The rules of the stylesheet Bit.Balette ships, read out of the file itself. +/// +/// The variables have a tool of their own; the rules that read them are entries of the search index, +/// each pointing at the stylesheet, because "how do I change the active row" or "why is the dialog +/// full screen on my phone" is answered by one of its rules. Parsing the shipped file means a rule the +/// library drops is never named, and one it adds is never missing. +/// +/// +/// A rule inside a media query is kept apart from the rule of the same selector outside it, with the +/// condition in its description: "top: 0" merged into the dialog's own rule would describe a dialog +/// that is never 12vh from the top, which is what it is everywhere but on a phone. +/// +/// +public static partial class BaletteStylesheetClasses +{ + public sealed record StyleRule(string Selector, string? Media, string Description); + + private static readonly Lazy _classes = new(Build); + + /// Every rule of the stylesheet that targets a bit-balette-* class, once per selector and media condition, in file order. + public static StyleRule[] Classes => _classes.Value; + + /// + /// The rules that apply whether or not the dialog carries bit-balette-dialog - the trigger, + /// the shortcut hints and the utilities. Unstyled drops that one class, which every other rule + /// is scoped to, so these are what still applies when the stylesheet is linked anyway. + /// + public static IEnumerable Unscoped => Classes.Where(rule => rule.Selector.Split(',').Any(part => part.Trim().StartsWith(".bit-balette-dialog", StringComparison.Ordinal) is false)); + + private static StyleRule[] Build() + { + var css = BaletteSourceCatalog.Stylesheet.Replace("\r\n", "\n", StringComparison.Ordinal); + var media = MediaBlocks(css); + + var rules = new List<(string Selector, string? Media, List Comments, List Properties)>(); + + foreach (Match match in RuleRegex().Matches(css)) + { + var selector = WhitespaceRegex().Replace(match.Groups["selector"].Value, " ").Trim(); + var body = match.Groups["body"].Value; + var condition = media.FirstOrDefault(block => match.Index > block.Start && match.Index < block.End).Condition; + + var index = rules.FindIndex(r => r.Selector == selector && r.Media == condition); + if (index < 0) + { + rules.Add((selector, condition, [], [])); + index = rules.Count - 1; + } + + var rule = rules[index]; + + // A comment right above the rule, or inside it, is the stylesheet saying why the rule exists. + // A section banner ("/* ---------- List ---------- */") names a group of rules, not this one. + var before = css[..match.Index].TrimEnd(); + if (before.EndsWith("*/", StringComparison.Ordinal)) + { + var open = before.LastIndexOf("/*", StringComparison.Ordinal); + var comment = open >= 0 ? before[(open + 2)..^2] : string.Empty; + if (comment.Contains("---", StringComparison.Ordinal) is false && comment.TrimStart().StartsWith('!') is false) rule.Comments.Add(comment); + } + + foreach (Match comment in CommentRegex().Matches(body)) rule.Comments.Add(comment.Groups["text"].Value); + + foreach (Match declaration in DeclarationRegex().Matches(CommentRegex().Replace(body, string.Empty))) + { + var text = $"{declaration.Groups["name"].Value}: {declaration.Groups["value"].Value.Trim()}"; + if (rule.Properties.Contains(text) is false) rule.Properties.Add(text); + } + } + + return [.. rules.Select(rule => new StyleRule(rule.Selector, rule.Media, Describe(rule.Media, rule.Comments, rule.Properties)))]; + } + + private static string Describe(string? media, List comments, List properties) + { + var prose = WhitespaceRegex().Replace(string.Join(' ', comments), " ").Trim(); + var declarations = string.Join("; ", properties); + var description = prose.Length == 0 ? declarations : $"{prose} ({declarations})"; + + return media is null ? description : $"Under {media}: {description}"; + } + + /// + /// Where every @media block starts and ends, found by counting braces - a media query holds + /// rules, so its block cannot be matched by a pattern that stops at the first closing brace. + /// + private static List<(int Start, int End, string Condition)> MediaBlocks(string css) + { + var blocks = new List<(int, int, string)>(); + + foreach (Match match in MediaRegex().Matches(css)) + { + var open = css.IndexOf('{', match.Index); + if (open < 0) continue; + + var depth = 0; + for (var i = open; i < css.Length; i++) + { + if (css[i] == '{') depth++; + else if (css[i] == '}' && --depth == 0) + { + blocks.Add((open, i, WhitespaceRegex().Replace(css[match.Index..open], " ").Trim())); + break; + } + } + } + + return blocks; + } + + [GeneratedRegex(@"(?[^{};/]*\.bit-balette-[^{};/]*?)\s*\{(?[^{}]*)\}")] + private static partial Regex RuleRegex(); + + [GeneratedRegex(@"@media[^{]*")] + private static partial Regex MediaRegex(); + + [GeneratedRegex(@"/\*(?.*?)\*/", RegexOptions.Singleline)] + private static partial Regex CommentRegex(); + + [GeneratedRegex(@"(?-?[a-z][a-z-]*)\s*:\s*(?[^;]+);?")] + private static partial Regex DeclarationRegex(); + + [GeneratedRegex(@"\s+")] + private static partial Regex WhitespaceRegex(); +} diff --git a/src/Balette/Demo/Server/Services/BaletteThemeCatalog.cs b/src/Balette/Demo/Server/Services/BaletteThemeCatalog.cs new file mode 100644 index 0000000000..bee27a936a --- /dev/null +++ b/src/Balette/Demo/Server/Services/BaletteThemeCatalog.cs @@ -0,0 +1,328 @@ +using System.Text; +using System.Text.RegularExpressions; +using Bit.Balette.Demo.Server.Dtos; + +namespace Bit.Balette.Demo.Server.Services; + +/// +/// The CSS custom properties Bit.Balette's default theme is built from, read out of the stylesheet +/// the library ships, and the data-balette hooks its components render, read out of their markup. +/// +/// Restyling the palette means overriding these variables and nothing else: every colour, size and +/// font the stylesheet uses goes through one on :root, so an override needs no selector of its +/// own and the trigger and shortcut hints outside the dialog follow it too. Parsing the shipped files +/// rather than keeping a table means the answer cannot name a variable or a hook the library dropped, +/// or miss one it added - and that it reports what the files actually do, including the variable the +/// component writes inline, where a stylesheet override never reaches. +/// +/// +public static partial class BaletteThemeCatalog +{ + private const string RootSelector = ":root"; + + /// The selector the dark values are declared under, next to the dialog's own data-theme="dark". + private const string DarkSelector = ":root[data-theme=\"dark\"]"; + + private static readonly Lazy _variables = new(Build); + private static readonly Lazy _hooks = new(BuildHooks); + + /// Every custom property of the default theme, in the order the stylesheet declares them. + public static BaletteThemeVariableDto[] Variables => _variables.Value; + + /// Every element the shipped components mark with data-balette, once per value, in the order the markup first renders it. + public static BaletteMarkupHookDto[] Hooks => _hooks.Value; + + /// The variables as Markdown tables grouped the way the stylesheet groups them, then the ways to override them, then the hooks. + public static string Render() + { + var builder = new StringBuilder(); + + builder.AppendLine("# Bit.Balette theme variables").AppendLine(); + builder.AppendLine($"Every value of the default theme is a CSS custom property on `{RootSelector}` in `_content/Bit.Balette/bit-balette.css` - ") + .AppendLine("a stylesheet the host page links itself, it is not injected. Restyle the palette by overriding these, never by ") + .AppendLine("overriding the library's selectors:") + .AppendLine() + .AppendLine("| Where | Reaches | Example |") + .AppendLine("| --- | --- | --- |") + .AppendLine("| `:root` in your stylesheet, linked after the library's | the whole app: the dialog, `BaletteTrigger` and every `BaletteShortcutHint` | `:root { --bit-balette-accent: #0f766e; }` |") + .AppendLine("| `dialog[data-balette=\"dialog\"]` | the palette dialog alone | `dialog[data-balette=\"dialog\"] { --bit-balette-width: 760px; }` |") + .AppendLine("| the `Style` parameter | that one palette, inline, ahead of every rule | `` |") + .AppendLine("| the `Theme` parameter | which column applies to that dialog: `auto` (the default, from `options.Theme`), `light` or `dark` | `` |") + .AppendLine() + .AppendLine("The dark column applies under `prefers-color-scheme: dark` (unless ``), under ") + .AppendLine("``, and on a dialog whose `Theme` is `dark`; a dialog whose `Theme` is `light` gets the light ") + .AppendLine("column back on a dark page. To change a dark value, override it under the same selectors the stylesheet uses: ") + .AppendLine($"`{DarkSelector}, dialog[data-balette=\"dialog\"][data-theme=\"dark\"]`, and inside `@media (prefers-color-scheme: dark)` on ") + .AppendLine("`:root:not([data-theme=\"light\"])`. A value in the last column is set somewhere else as well, and where the component ") + .AppendLine("writes it inline from an option, the option is the only thing that changes it for the elements under that element.") + .AppendLine(); + + foreach (var group in Variables.GroupBy(variable => variable.Group)) + { + builder.AppendLine($"## {group.Key}").AppendLine(); + builder.AppendLine("| Variable | Light (default) | Dark | Also set by |"); + builder.AppendLine("| --- | --- | --- | --- |"); + + foreach (var variable in group) + { + var also = variable.AlsoSetBy is { Length: > 0 } ? string.Join("; ", variable.AlsoSetBy.Select(Escape)) : string.Empty; + + builder.AppendLine($"| `{variable.Name}` | `{Escape(variable.LightValue)}` | {(variable.DarkValue is null ? "same" : $"`{Escape(variable.DarkValue)}`")} | {also} |"); + } + + builder.AppendLine(); + } + + builder.AppendLine("## Unstyled: the data-balette hooks").AppendLine(); + builder.AppendLine("`Unstyled=\"true\"` drops the `bit-balette-dialog` class every dialog rule is scoped to, so none of them applies and ") + .AppendLine("the markup is yours to style - through these attributes, which are also the stable hooks for tests. The rows ") + .AppendLine("carry their state as attributes: style `[data-balette=\"item\"][data-active=\"true\"]`, not a class. An `ItemTemplate` ") + .AppendLine("replaces what is inside a row; the row element itself, with these attributes, stays the palette's.") + .AppendLine(); + builder.AppendLine("| `data-balette` | Element | Default class | State attributes | Rendered by |"); + builder.AppendLine("| --- | --- | --- | --- | --- |"); + + foreach (var hook in Hooks) + { + var states = hook.States.Length == 0 ? string.Empty : string.Join(", ", hook.States.Select(state => $"`{state}`")); + + builder.AppendLine($"| `{hook.Value}` | `<{hook.Element}>` | {(hook.Class is null ? string.Empty : $"`{hook.Class}`")} | {states} | `{hook.Component}` |"); + } + + var unscoped = BaletteStylesheetClasses.Unscoped.Select(rule => $"`{rule.Selector}`").Distinct(StringComparer.Ordinal).ToArray(); + + if (unscoped.Length > 0) + { + builder.AppendLine() + .AppendLine($"With the stylesheet still linked, the rules not scoped to the dialog keep applying under `Unstyled`: {string.Join(", ", unscoped)}. ") + .AppendLine("Leave the stylesheet out altogether to start from bare markup."); + } + + builder.AppendLine() + .AppendLine($"The rules that read these variables are in `{BaletteSourceCatalog.StylesheetPath}`, and the markup in ") + .AppendLine($"`{BaletteSourceCatalog.ComponentMarkupFolder}Balette.razor` - both through `GetBaletteSourceFile`."); + + return builder.ToString(); + } + + private static string Escape(string value) => value.Replace("|", "\\|", StringComparison.Ordinal); + + private static BaletteThemeVariableDto[] Build() + { + var css = BaletteSourceCatalog.Stylesheet.Replace("\r\n", "\n", StringComparison.Ordinal); + + var light = ReadBlock(css, RootSelector); + var dark = ReadDeclarations(FindRuleBody(css, DarkSelector) ?? string.Empty).ToDictionary(variable => variable.Name, variable => variable.Value, StringComparer.Ordinal); + var elsewhere = OtherDeclarations(css); + var inline = InlineDeclarations(); + + return [.. light.Select(variable => new BaletteThemeVariableDto + { + Name = variable.Name, + Group = variable.Group, + LightValue = variable.Value, + DarkValue = dark.TryGetValue(variable.Name, out var value) && value != variable.Value ? value : null, + AlsoSetBy = [.. elsewhere.Where(other => other.Name == variable.Name).Select(other => $"`{other.Selector}`: `{other.Value}`") + .Concat(inline.Where(other => other.Name == variable.Name).Select(other => other.Condition is null + ? $"inline on `{other.Element}` from `BaletteOptions.{other.Option}`, so set the option" + : $"inline on `{other.Element}` from `BaletteOptions.{other.Option}` while `{other.Condition}` is on, so set the option then"))] + }).Select(variable => variable.AlsoSetBy is { Length: 0 } ? variable with { AlsoSetBy = null } : variable)]; + } + + /// + /// The custom properties declared directly inside the rule whose selector is exactly + /// , each with the group comment it sits under (/* Layout */). + /// Only that rule is read, so the values a media query, a theme or a state selector re-declares do + /// not leak into the defaults. + /// + private static List<(string Name, string Value, string Group)> ReadBlock(string css, string selector) + { + var result = new List<(string, string, string)>(); + + var start = css.IndexOf($"\n{selector} {{", StringComparison.Ordinal); + if (start < 0) return result; + + var open = css.IndexOf('{', start); + var close = css.IndexOf("\n}", open, StringComparison.Ordinal); + if (close < 0) return result; + + var group = "Other"; + + foreach (var line in css[(open + 1)..close].Split('\n')) + { + var heading = GroupCommentRegex().Match(line); + if (heading.Success) + { + group = heading.Groups["name"].Value.Trim(); + continue; + } + + var declaration = DeclarationRegex().Match(line); + if (declaration.Success) result.Add((declaration.Groups["name"].Value, declaration.Groups["value"].Value.Trim(), group)); + } + + return result; + } + + /// + /// The body of the top-level rule whose selector list includes - the + /// dark rule is written for two selectors at once, across two lines, so it is found by one of them + /// rather than by the whole list. + /// + private static string? FindRuleBody(string css, string selector) + { + foreach (Match rule in TopLevelRuleRegex().Matches(css)) + { + var selectors = rule.Groups["selector"].Value.Split(',').Select(part => part.Trim()); + + if (selectors.Contains(selector, StringComparer.Ordinal)) return rule.Groups["body"].Value; + } + + return null; + } + + private static IEnumerable<(string Name, string Value)> ReadDeclarations(string body) + { + foreach (var line in body.Split('\n')) + { + var declaration = DeclarationRegex().Match(line); + if (declaration.Success) yield return (declaration.Groups["name"].Value, declaration.Groups["value"].Value.Trim()); + } + } + + /// + /// Every theme variable a rule other than the theme rules redeclares - for a state of the dialog, + /// say - which changes the value a caller sees without any theme being involved. + /// + private static List<(string Name, string Value, string Selector)> OtherDeclarations(string css) + { + var result = new List<(string, string, string)>(); + + foreach (Match rule in TopLevelRuleRegex().Matches(css)) + { + var selector = WhitespaceRegex().Replace(rule.Groups["selector"].Value, " ").Trim(); + + // The theme rules themselves: the defaults, the dark values and the light values a dialog + // forced to light gets back on a dark page. + if (selector == RootSelector || selector.Contains("data-theme", StringComparison.Ordinal)) continue; + + foreach (Match declaration in InlineDeclarationRegex().Matches(rule.Groups["body"].Value)) + { + result.Add((declaration.Groups["name"].Value, declaration.Groups["value"].Value.Trim(), selector)); + } + } + + return result; + } + + /// + /// The theme variables a component writes into an element's inline style from an option, either + /// straight in its markup (style="--bit-balette-x:@(Options.X)") or through a property of the + /// palette's code-behind that the markup binds (style="@RowHeightStyle", which writes + /// --bit-balette-row-height from Options.RowHeight while Virtualize is on). An inline + /// declaration is inherited by everything under that element ahead of any stylesheet value, so for those + /// elements - and while its condition holds - the option is the only thing that decides it. + /// + private static List<(string Name, string Option, string Element, string? Condition)> InlineDeclarations() + { + var result = new List<(string, string, string, string?)>(); + var computed = new Dictionary(StringComparer.Ordinal); + foreach (Match match in ComputedStyleRegex().Matches(BaletteSourceCatalog.PaletteCodeBehind)) + { + computed[match.Groups["property"].Value] = match; + } + + foreach (var (_, markup) in BaletteSourceCatalog.ComponentMarkup) + { + foreach (Match tag in TagRegex().Matches(markup)) + { + var attributes = tag.Groups["attributes"].Value; + var hook = DataBaletteRegex().Match(attributes); + var element = hook.Success ? $"[data-balette=\"{hook.Groups["value"].Value}\"]" : tag.Groups["tag"].Value; + + foreach (Match declaration in InlineOptionRegex().Matches(attributes)) + { + result.Add((declaration.Groups["name"].Value, declaration.Groups["option"].Value, element, null)); + } + + if (BoundStyleRegex().Match(attributes) is { Success: true } bound && computed.TryGetValue(bound.Groups["property"].Value, out var style)) + { + result.Add((style.Groups["name"].Value, style.Groups["option"].Value, element, style.Groups["condition"].Value)); + } + } + } + + return result; + } + + private static BaletteMarkupHookDto[] BuildHooks() + { + var hooks = new List(); + + foreach (var (component, markup) in BaletteSourceCatalog.ComponentMarkup.OrderBy(file => file.Component == "Balette" ? 0 : 1)) + { + foreach (Match tag in TagRegex().Matches(markup)) + { + var attributes = tag.Groups["attributes"].Value; + var hook = DataBaletteRegex().Match(attributes); + if (hook.Success is false) continue; + + var value = hook.Groups["value"].Value; + if (hooks.Any(existing => existing.Value == value)) continue; + + hooks.Add(new BaletteMarkupHookDto + { + Value = value, + Element = tag.Groups["tag"].Value, + Class = ClassRegex().Match(attributes) is { Success: true } css ? css.Value : null, + States = [.. StateAttributeRegex().Matches(attributes).Select(state => state.Groups["name"].Value).Where(name => name != "data-balette").Distinct(StringComparer.Ordinal)], + Component = component + }); + } + } + + return [.. hooks]; + } + + [GeneratedRegex(@"^\s*/\*\s*-*\s*(?.+?)\s*-*\s*\*/\s*$")] + private static partial Regex GroupCommentRegex(); + + [GeneratedRegex(@"^\s*(?--bit-balette-[a-z0-9-]+)\s*:\s*(?[^;]+);")] + private static partial Regex DeclarationRegex(); + + [GeneratedRegex(@"(?--bit-balette-[a-z0-9-]+)\s*:\s*(?[^;]+);?")] + private static partial Regex InlineDeclarationRegex(); + + // A rule that starts a line: its selector list may span lines, and the media query's rules are + // indented, so neither a nested rule nor the media query itself is taken for a top-level rule. + [GeneratedRegex(@"(?m)^(?[^\s@}{/][^{}]*?)\{(?[^{}]*)\}")] + private static partial Regex TopLevelRuleRegex(); + + // An element's opening tag. Attribute values are quoted, and a quoted value may hold a '>' (an + // event handler's lambda), so quotes are consumed whole rather than scanned for the closing bracket. + [GeneratedRegex(@"<(?[a-z][a-z0-9]*)\b(?(?:""[^""]*""|[^>""])*)>")] + private static partial Regex TagRegex(); + + [GeneratedRegex(@"\bdata-balette=""(?[a-z-]+)""")] + private static partial Regex DataBaletteRegex(); + + [GeneratedRegex(@"\b(?data-[a-z-]+)=")] + private static partial Regex StateAttributeRegex(); + + [GeneratedRegex(@"\bbit-balette-[a-z0-9-]+")] + private static partial Regex ClassRegex(); + + [GeneratedRegex(@"(?--bit-balette-[a-z0-9-]+):@\(Options\.(?