feat(ui): Cheat-Sheet-Fenster im Hilfe-Menue

Sechs Markdown-Sheets (git, Conventional Commits, Claude CLI, Claude-Config,
dotnet CLI, PowerShell) als EmbeddedResource, gerendert im modelosen, per
Topmost anpinnbaren CheatSheetsWindow. MarkdownView bekommt dafuer das Opt-in
CommandRows: `befehl` - beschreibung wird zu Mono-Chip + Copy-Button, jede
##-Sektion landet in einem Expander. Enthaelt ausserdem: Mission Control
wird beim Oeffnen aus dem Minimiert-Zustand geholt, nicht nur aktiviert.
This commit is contained in:
mika kuns
2026-09-07 08:17:37 +02:00
parent 7c7c33d677
commit 0db9fadee9
20 changed files with 749 additions and 7 deletions
@@ -0,0 +1,63 @@
# Claude CLI
## Session starten
- `claude` — interaktive Session im aktuellen Verzeichnis
- `claude "<prompt>"` — Session mit erstem Prompt starten
- `claude -c` — letzte Session in diesem Verzeichnis fortsetzen
- `claude -r` — Session aus einer Auswahlliste fortsetzen
- `claude -r <session-id>` — bestimmte Session fortsetzen
- `claude --fork-session -r <id>` — Session unter neuer ID fortsetzen
- `claude -n "<name>"` — Anzeigename für die Session setzen
## Modell und Aufwand
- `claude --model sonnet` — Alias oder voller Name (`fable`, `opus`, `sonnet`, `claude-opus-5`)
- `claude --effort high` — Denkaufwand: `low`, `medium`, `high`, `xhigh`, `max`
- `claude --fallback-model sonnet` — Ausweichmodell bei Überlast (nur mit `-p`)
## Nicht interaktiv
- `claude -p "<prompt>"` — antworten und beenden
- `claude -p --output-format json` — ein JSON-Ergebnis
- `claude -p --output-format stream-json` — Streaming-JSON für Skripte
- `claude -p --include-partial-messages` — Teil-Chunks mitstreamen
- `claude -p --max-budget-usd 5` — Kostendeckel für den Lauf
- `claude -p --json-schema '<schema>'` — strukturierte Ausgabe validieren
## Berechtigungen und Tools
- `claude --permission-mode <mode>` — `acceptEdits`, `auto`, `plan`, `manual`, `dontAsk`, `bypassPermissions`
- `claude --allowed-tools "Bash(git *)" Edit` — nur diese Tools erlauben
- `claude --disallowed-tools "Bash(rm *)"` — diese Tools sperren
- `claude --tools "Bash,Edit,Read"` — Built-in-Toolset festlegen
- `claude --add-dir <pfad>` — weiteres Arbeitsverzeichnis freigeben
- `claude --restricted` — ohne befehlsausführende Tools und ohne lokale Settings
## Kontext und Konfiguration
- `claude --append-system-prompt "<text>"` — Systemprompt ergänzen
- `claude --system-prompt "<text>"` — Systemprompt ersetzen
- `claude --settings <datei|json>` — zusätzliche Settings laden
- `claude --agents '<json>'` — eigene Subagents für diese Session
- `claude --mcp-config <datei>` — MCP-Server aus JSON laden
- `claude --strict-mcp-config` — nur diese MCP-Server, alle anderen ignorieren
- `claude --safe-mode` — alle Anpassungen aus, zum Fehlersuchen
- `claude --bare` — Minimalmodus ohne Hooks, Plugins, CLAUDE.md
## Hintergrund-Sessions
- `claude --bg "<prompt>"` — im Hintergrund starten, gibt die Kurz-ID aus
- `claude agents` — laufende Hintergrund-Sessions auflisten
- `claude attach <id>` — Hintergrund-Session in dieses Terminal holen
- `claude logs <id>` — letzte Ausgabe einer Hintergrund-Session
- `claude stop <id>` — Session stoppen, Verlauf bleibt
- `claude rm <id>` — Session löschen
## Worktree
- `claude -w` — neuen Git-Worktree für diese Session anlegen
- `claude -w <name>` — Worktree mit eigenem Namen
## Verwaltung
- `claude doctor` — Installation prüfen
- `claude update` — auf neue Version aktualisieren
- `claude mcp` — MCP-Server konfigurieren
- `claude plugin` — Plugins verwalten
- `claude auth` — Authentifizierung verwalten
- `claude setup-token` — Langzeit-Token einrichten
- `claude -d api,hooks` — Debugmodus mit Kategoriefilter
- `claude -v` — Version ausgeben
@@ -0,0 +1,48 @@
# Claude Konfiguration
## Dateien und Orte
- `~/.claude/settings.json` — globale Settings für alle Projekte
- `.claude/settings.json` — Projekt-Settings, eingecheckt fürs Team
- `.claude/settings.local.json` — persönliche Projekt-Settings, nicht eingecheckt
- `Rangfolge: managed > local > projekt > global` — spezifischer gewinnt
- `/config` — Settings interaktiv in der Session ansehen und ändern
- `claude doctor` — Konfiguration auf Fehler prüfen
## CLAUDE.md
- `~/.claude/CLAUDE.md` — globale Anweisungen für alle Projekte
- `CLAUDE.md` — im Projekt-Root, wird bei Start automatisch geladen
- `<unterordner>/CLAUDE.md` — wird geladen, sobald dort gearbeitet wird
- `@pfad/zur/datei.md` — Import einer weiteren Datei in eine CLAUDE.md
- `# <notiz>` — in der Session: Notiz in die CLAUDE.md übernehmen
- `/init` — CLAUDE.md für ein Projekt generieren lassen
## Berechtigungen
- `"permissions": { "allow": [...], "deny": [...], "ask": [...] }` — Struktur in settings.json
- `Bash(git *)` — Bash-Befehle per Muster erlauben oder sperren
- `Edit(src/**)` — Dateizugriff per Glob einschränken
- `WebFetch(domain:example.com)` — Webzugriff auf eine Domain begrenzen
- `mcp__<server>__<tool>` — Regel für ein einzelnes MCP-Tool
- `/permissions` — Regeln in der Session ansehen und ergänzen
## MCP-Server
- `claude mcp add <name> -- <befehl>` — lokalen stdio-Server anlegen
- `claude mcp add --transport http <name> <url>` — HTTP-Server anlegen
- `claude mcp add --scope project <name> ...` — in `.mcp.json` fürs Team
- `claude mcp list` — konfigurierte Server anzeigen
- `claude mcp remove <name>` — Server entfernen
- `/mcp` — Serverstatus in der Session, OAuth-Login
## Hooks
- `"hooks": { "PreToolUse": [...] }` — Shell-Befehle an Lifecycle-Events hängen
- `PreToolUse / PostToolUse` — vor und nach jedem Tool-Aufruf
- `UserPromptSubmit` — beim Absenden eines Prompts
- `SessionStart / Stop` — bei Sessionbeginn und -ende
- `Exit-Code 2` — blockiert die Aktion, stderr geht an Claude
- `/hooks` — Hooks interaktiv verwalten
## Erweiterungen
- `.claude/commands/<name>.md` — eigener Slash-Command `/name`
- `.claude/skills/<name>/SKILL.md` — Skill, wird bei passender Aufgabe geladen
- `.claude/agents/<name>.md` — eigener Subagent-Typ
- `~/.claude/...` — dieselben Ordner global statt pro Projekt
- `claude plugin` — Plugins installieren und verwalten
@@ -0,0 +1,34 @@
# Conventional Commits
## Format
- `<type>(<scope>): <beschreibung>` — Grundform, Scope optional
- `feat(auth): add login endpoint` — Beispiel mit Scope
- `fix: prevent crash on empty input` — Beispiel ohne Scope
- `imperativ, klein, kein Punkt am Ende` — Stil der Beschreibung
## Types
- `feat` — neues Feature
- `fix` — Bugfix
- `refactor` — Umbau ohne Verhaltensänderung
- `perf` — Performance-Verbesserung
- `docs` — nur Dokumentation
- `test` — Tests hinzufügen oder korrigieren
- `style` — Formatierung, kein Code-Effekt
- `build` — Build-System oder Dependencies
- `ci` — CI-Konfiguration
- `chore` — Sonstiges ohne src- oder Test-Änderung
## Breaking Changes
- `feat(api)!: remove legacy endpoint` — `!` markiert einen Breaking Change
- `BREAKING CHANGE: <erklärung>` — alternativ als Footer im Commit-Body
## Body und Footer
- `Leerzeile nach der Betreffzeile` — dann optionaler Body mit dem Warum
- `Refs: #123` — Footer für Ticket-Referenzen
- `Reverts: <hash>` — Footer für Reverts
## Warum das Ganze
- `feat` — löst ein Minor-Release aus (SemVer)
- `fix` — löst ein Patch-Release aus (SemVer)
- `!` oder `BREAKING CHANGE` — löst ein Major-Release aus
- `git log --oneline --grep "^feat"` — Verlauf wird filterbar
@@ -0,0 +1,49 @@
# dotnet CLI
## Projekt anlegen
- `dotnet new list` — verfügbare Vorlagen anzeigen
- `dotnet new console -n <name>` — Projekt aus Vorlage anlegen
- `dotnet new sln` — Solution anlegen
- `dotnet sln add <projekt>` — Projekt in die Solution aufnehmen
## Bauen
- `dotnet restore` — NuGet-Pakete auflösen
- `dotnet build <projekt> -c Release` — Release bauen
- `dotnet build --no-restore` — ohne Restore, wenn Pakete aktuell sind
- `dotnet build -v minimal` — knappere Ausgabe
- `dotnet clean -c Release` — Build-Ausgabe löschen
## Laufen lassen
- `dotnet run --project <projekt> -c Release` — starten
- `dotnet run --project <projekt> -- <args>` — Argumente an die App
- `dotnet watch --project <projekt> run` — bei Dateiänderung neu starten
## Testen
- `dotnet test <projekt> -c Release --no-build` — Tests ohne Neubau
- `dotnet test --filter "FullyQualifiedName~<name>"` — nur passende Tests
- `dotnet test --filter "FullyQualifiedName!~<name>"` — Tests ausschließen
- `dotnet test --logger "console;verbosity=detailed"` — Details pro Test
- `dotnet test --blame-hang-timeout 60s` — hängende Tests aufspüren
## Pakete
- `dotnet add <projekt> package <name>` — Paket hinzufügen
- `dotnet add <projekt> package <name> --version 1.2.3` — feste Version
- `dotnet remove <projekt> package <name>` — Paket entfernen
- `dotnet list <projekt> package --outdated` — veraltete Pakete
- `dotnet nuget locals all --clear` — NuGet-Caches leeren
## EF Core Migrationen
- `dotnet ef migrations add <name> -p <daten-projekt>` — Migration anlegen
- `dotnet ef migrations list -p <daten-projekt>` — Migrationen anzeigen
- `dotnet ef migrations remove -p <daten-projekt>` — letzte Migration zurücknehmen
- `dotnet ef database update -p <daten-projekt>` — Schema anwenden
## Veröffentlichen
- `dotnet publish <projekt> -c Release -r win-x64 --self-contained` — eigenständiges Build
- `dotnet publish <projekt> -c Release -p:PublishSingleFile=true` — eine Exe
## Diagnose
- `dotnet --info` — SDKs, Runtimes, RID
- `dotnet --list-sdks` — installierte SDKs
- `dotnet format <projekt>` — Formatierung anwenden
- `dotnet build -p:TreatWarningsAsErrors=true` — Warnungen als Fehler
+80
View File
@@ -0,0 +1,80 @@
# Git
## Start
- `git clone <url>` — Repository klonen
- `git init` — neues Repository anlegen
- `git remote add origin <url>` — Remote verbinden
## Stand ansehen
- `git status -sb` — Kurzstatus mit Branch-Zeile
- `git diff` — nicht gestagete Änderungen
- `git diff --staged` — gestagete Änderungen
- `git diff main...HEAD` — alles, was dieser Branch gegenüber main bringt
- `git diff --stat` — nur Datei- und Zeilenzähler
- `git log --oneline --graph --decorate -20` — kompakter Verlauf
- `git log -p -- <pfad>` — Verlauf einer Datei mit Diffs
- `git show <commit>` — ein Commit im Detail
## Branch
- `git switch <branch>` — Branch wechseln
- `git switch -c <name>` — Branch anlegen und wechseln
- `git switch -` — zurück zum vorherigen Branch
- `git branch -vv` — Branches mit Upstream und letztem Commit
- `git branch -d <name>` — gemergten Branch löschen (`-D` erzwingt)
## Committen
- `git add <pfad>` — Änderungen stagen
- `git add -p` — Änderungen hunk-weise stagen
- `git commit -m "<text>"` — gestagete Änderungen committen
- `git commit -- <pfad>` — nur diese Pfade committen
- `git commit --amend --no-edit` — letzten Commit um die Staging-Area erweitern
- `git commit --fixup <commit>` — Fixup für späteres Autosquash
## Remote
- `git fetch --all --prune` — alles holen, gelöschte Remote-Branches aufräumen
- `git pull --rebase` — holen und eigene Commits obendrauf setzen
- `git push -u origin <name>` — pushen und Upstream setzen
- `git push --force-with-lease` — sicheres Force-Push, nie plain `--force`
## Rebase und Merge
- `git rebase main` — Branch auf aktuellen main setzen
- `git rebase -i --autosquash main` — interaktiv aufräumen
- `git rebase --continue` — nach gelöstem Konflikt weiter
- `git rebase --abort` — Rebase abbrechen, Ausgangszustand zurück
- `git merge --no-ff <branch>` — Merge-Commit erzwingen
- `git merge --abort` — laufenden Merge abbrechen
- `git cherry-pick <commit>` — einzelnen Commit übernehmen
## Konflikte
- `git diff --name-only --diff-filter=U` — konfliktbehaftete Dateien
- `git checkout --ours <pfad>` — eigene Seite übernehmen
- `git checkout --theirs <pfad>` — fremde Seite übernehmen
- `git mergetool` — konfiguriertes Merge-Tool starten
## Rückgängig
- `git restore <pfad>` — Datei auf HEAD zurücksetzen, Änderungen weg
- `git restore --staged <pfad>` — unstagen, Änderungen behalten
- `git reset --soft HEAD~1` — letzten Commit auflösen, Änderungen gestaged behalten
- `git reset --hard <commit>` — alles verwerfen, nicht rückholbar
- `git revert <commit>` — Commit durch Gegen-Commit aufheben
- `git reflog` — Referenzverlauf, um verlorene Commits wiederzufinden
## Stash
- `git stash push -m "<text>"` — Änderungen wegpacken
- `git stash push -u` — inklusive unversionierter Dateien
- `git stash list` — Stashes anzeigen
- `git stash pop` — letzten Stash zurückholen und entfernen
- `git stash apply <stash>` — anwenden, aber behalten
## Worktrees
- `git worktree list` — alle Worktrees
- `git worktree add <pfad> <branch>` — Worktree für bestehenden Branch
- `git worktree add -b <name> <pfad>` — Worktree mit neuem Branch
- `git worktree remove <pfad>` — Worktree entfernen
- `git worktree prune` — verwaiste Einträge aufräumen
## Suchen
- `git grep -n "<text>"` — im Arbeitsbaum suchen
- `git log -S"<text>"` — Commits, die den Text einfügen oder entfernen
- `git blame -L 40,60 <pfad>` — Zeilenbereich zuordnen
- `git bisect start` — binäre Suche nach dem Fehler-Commit
@@ -0,0 +1,52 @@
# PowerShell
## Navigieren und Dateien
- `Get-ChildItem` — Verzeichnis auflisten (`ls`, `-Force` zeigt versteckte)
- `Set-Location <pfad>` — Verzeichnis wechseln (`cd`)
- `New-Item -ItemType Directory <pfad>` — Ordner anlegen (`mkdir`)
- `Copy-Item <quelle> <ziel> -Recurse` — kopieren
- `Move-Item <quelle> <ziel>` — verschieben oder umbenennen
- `Remove-Item <pfad> -Recurse -Force` — löschen, endgültig
- `Test-Path <pfad>` — existiert die Datei oder der Ordner?
## Ansehen und Suchen
- `Get-Content <datei> -Tail 50` — letzte Zeilen (`-Wait` folgt wie tail -f)
- `Select-String "<text>" <dateien> -List` — in Dateien suchen (grep)
- `Get-ChildItem -Recurse -Filter *.log` — Dateien rekursiv finden
- `<befehl> | Select-Object -First 10` — Ausgabe begrenzen (head)
- `<befehl> | Where-Object Name -like "*test*"` — Ausgabe filtern
- `<befehl> | Measure-Object` — zählen (wc)
- `<befehl> | Out-GridView` — Ausgabe in sortierbarem Fenster
## Prozesse und Dienste
- `Get-Process | Sort-Object CPU -Descending | Select-Object -First 10` — Top-Prozesse
- `Stop-Process -Name <name>` — Prozess beenden (`-Id <pid>` per PID)
- `Get-Service <name>` — Dienststatus
- `Restart-Service <name>` — Dienst neu starten (Admin nötig)
## Netzwerk
- `Test-NetConnection <host> -Port 443` — Erreichbarkeit und Port testen
- `Get-NetTCPConnection -LocalPort <port>` — wer belegt den Port (OwningProcess = PID)
- `Invoke-RestMethod <url>` — HTTP-Aufruf, JSON kommt als Objekt zurück
- `Resolve-DnsName <host>` — DNS-Auflösung prüfen
## Umgebung
- `$env:PATH -split ";"` — PATH lesbar ausgeben
- `$env:NAME = "wert"` — Variable für diese Session setzen
- `[Environment]::SetEnvironmentVariable("NAME", "wert", "User")` — dauerhaft setzen
- `Get-Command <name>` — wo liegt ein Befehl (which)
- `Get-History` — bisherige Befehle dieser Session
## Windows-Spezifisches
- `explorer .` — aktuellen Ordner im Explorer öffnen
- `Start-Process <datei>` — mit Standardprogramm öffnen
- `Start-Process pwsh -Verb RunAs` — Terminal als Administrator
- `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` — lokale Skripte erlauben
- `winget search <name>` — Software im Paketmanager suchen
- `winget install <id>` — installieren
- `winget upgrade --all` — alles aktualisieren
## Hilfe
- `Get-Help <befehl> -Examples` — Beispiele zu einem Cmdlet
- `Get-Member` — Eigenschaften eines Pipeline-Objekts anzeigen
- `Get-Alias <kurzform>` — welches Cmdlet steckt hinter einem Alias
+16 -1
View File
@@ -58,13 +58,25 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. | | `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. | | `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). | | `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). The **analytics** part (models/tasks/cost) comes from the external TokenTracker export, not from us: without the CLI the modal shows a hint card with an install button (gated on Node ≥ 20) instead of the totals header. Gauges, gate and throttle are unaffected by it. | | `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). The **analytics** part (models/tasks/cost) comes from the external TokenTracker export, not from us: without the CLI the modal shows a hint card with an install button (gated on Node ≥ 20) instead of the totals header. Gauges, gate and throttle are unaffected by it. The modal is **display + threshold drag only** — the PowerUse switch/window/handoff % it used to host moved to Settings → Execution (`GeneralSettingsTabViewModel`), and "Refresh now" is an icon in the titlebar via `ModalShell.HeaderActions` rather than a body row. |
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`, repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
`UnfinishedPlanningModalViewModel`, `LogVisualizerViewModel` (last 30 min, all levels + a `UnfinishedPlanningModalViewModel`, `LogVisualizerViewModel` (last 30 min, all levels + a
warn/error filter), `WorkerConnectionModalViewModel`, `AboutModalViewModel`. warn/error filter), `WorkerConnectionModalViewModel`, `AboutModalViewModel`.
`CheatSheetsViewModel` backs the modeless `CheatSheetsWindow` (Help → Cheat Sheets): content is
markdown under `Assets/CheatSheets/*.md` as **EmbeddedResource** (not `AvaloniaResource` —
`AssetLoader` throws without an initialized Avalonia app, which would make the VM unconstructible
in tests), rendered through `MarkdownView`, with a `Topmost` pin so it can sit docked beside the
shell. A new sheet = a new `.md` file + its key in `CheatSheetsViewModel.Keys` (+ a menu entry if
it should be directly reachable). `MarkdownView` has no table/fenced-code support, so sheets are
bullet lists with inline code — rendered via its opt-in `CommandRows` property, which turns a
`` `command` — description `` bullet into a mono chip + copy button + description and wraps each
`##` section in a collapsible `Expander` (blocks are added to the open section's panel, not to the
host). The flag is opt-in because the other three `MarkdownView` call sites (prompt-file viewer,
weekly report, task-description preview) render prose where copy buttons would be noise.
## Diff & Conflicts ## Diff & Conflicts
`UnifiedDiffParser` (static) + `DiffModels.cs` shared types + `DiffViewerViewModel` (one unified `UnifiedDiffParser` (static) + `DiffModels.cs` shared types + `DiffViewerViewModel` (one unified
@@ -106,6 +118,9 @@ There is no null→bool converter — bind
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog
sets the result on save/cancel, the caller awaits the TCS. sets the result on save/cancel, the caller awaits the TCS.
`ModalShell.HeaderActions` is the titlebar slot left of maximise/close — for a window-level action
that should stay visible without costing a body row (currently only the usage monitor's refresh).
`ModalShell` also owns the window chrome for every modal: the titlebar drag goes through `ModalShell` also owns the window chrome for every modal: the titlebar drag goes through
`Window.BeginMoveDrag` (so Windows snap works — drag to the top edge to maximise; the OS only `Window.BeginMoveDrag` (so Windows snap works — drag to the top edge to maximise; the OS only
snaps `CanResize="True"` windows, which is the opt-in), and it insets itself by the window's snaps `CanResize="True"` windows, which is the opt-in), and it insets itself by the window's
+3
View File
@@ -36,6 +36,9 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<!-- EmbeddedResource, not AvaloniaResource: reading these needs no initialized Avalonia
app, so the VM stays constructible in tests. -->
<EmbeddedResource Include="Assets/CheatSheets/*.md" />
<AvaloniaResource Include="Assets/Fonts/*.ttf" /> <AvaloniaResource Include="Assets/Fonts/*.ttf" />
<AvaloniaResource Include="Assets/Fonts/OFL-InterTight.txt" /> <AvaloniaResource Include="Assets/Fonts/OFL-InterTight.txt" />
<AvaloniaResource Include="Assets/Fonts/OFL-JetBrainsMono.txt" /> <AvaloniaResource Include="Assets/Fonts/OFL-JetBrainsMono.txt" />
@@ -36,4 +36,7 @@ public interface IDialogService
/// <summary>Show (or re-show + focus) the modeless Mission Control window. Lazily created; hides on close.</summary> /// <summary>Show (or re-show + focus) the modeless Mission Control window. Lazily created; hides on close.</summary>
void ShowMissionControl(MissionControlViewModel vm); void ShowMissionControl(MissionControlViewModel vm);
/// <summary>Show (or re-show + focus) the modeless cheat-sheet window for the shell's shared VM.</summary>
void ShowCheatSheets(CheatSheetsViewModel vm);
} }
@@ -515,6 +515,19 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm); if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm);
} }
// One shared VM so the sheets are parsed once and the window keeps its selection/pin
// across reopens; the window itself is single-instance in the dialog service.
private CheatSheetsViewModel? _cheatSheets;
[RelayCommand]
private void OpenCheatSheet(string? key)
{
if (Dialogs is null) return;
_cheatSheets ??= new CheatSheetsViewModel();
if (!string.IsNullOrEmpty(key)) _cheatSheets.Select(key);
Dialogs.ShowCheatSheets(_cheatSheets);
}
[RelayCommand] [RelayCommand]
private async Task OpenLogVisualizer() private async Task OpenLogVisualizer()
{ {
@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using ClaudeDo.Ui.Localization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals;
/// <summary>One cheat sheet: title from the file's leading <c># </c> heading, body is the rest.</summary>
public sealed record CheatSheet(string Key, string Title, string Body);
/// <summary>
/// Backs the non-modal cheat-sheet window (Help → Cheat Sheets). Content lives as markdown
/// under <c>Assets/CheatSheets/*.md</c> (embedded resources) and is rendered by the existing
/// <c>MarkdownView</c>,
/// so a new sheet is a new file — but the key list here stays explicit to keep the order
/// deterministic and a missing resource visible instead of silently absent.
/// </summary>
public partial class CheatSheetsViewModel : ViewModelBase
{
private static readonly string[] Keys =
["git", "conventional-commits", "claude-cli", "claude-config", "dotnet-cli", "powershell"];
public ObservableCollection<CheatSheet> Sheets { get; } = [];
[ObservableProperty]
private CheatSheet? _selectedSheet;
/// <summary>Keeps the window above the shell so it can sit docked at a screen edge.</summary>
[ObservableProperty]
private bool _isPinned;
public Action? CloseAction { get; set; }
public CheatSheetsViewModel()
{
foreach (var key in Keys) Sheets.Add(Load(key));
SelectedSheet = Sheets.FirstOrDefault();
}
/// <summary>Preselects a sheet by key; unknown keys keep the current selection.</summary>
public void Select(string key)
{
var sheet = Sheets.FirstOrDefault(s => s.Key == key);
if (sheet is not null) SelectedSheet = sheet;
}
[RelayCommand]
private void Close() => CloseAction?.Invoke();
private static CheatSheet Load(string key)
{
var assembly = typeof(CheatSheetsViewModel).Assembly;
// Match by suffix rather than composing the logical name from RootNamespace — the
// prefix is a build detail, and a rename shouldn't silently blank the sheet.
var name = assembly.GetManifestResourceNames()
.FirstOrDefault(n => n.EndsWith($".CheatSheets.{key}.md", StringComparison.Ordinal));
var stream = name is null ? null : assembly.GetManifestResourceStream(name);
if (stream is null)
return new CheatSheet(key, key, Loc.T("modals.cheatSheets.missing", $"{key}.md"));
using var reader = new StreamReader(stream);
var text = reader.ReadToEnd().Replace("\r\n", "\n");
// The window shows the title in its own selector, so strip the leading heading
// rather than rendering it a second time inside the body.
var lines = new List<string>(text.Split('\n'));
var title = key;
var first = lines.FindIndex(l => !string.IsNullOrWhiteSpace(l));
if (first >= 0 && lines[first].StartsWith("# ", StringComparison.Ordinal))
{
title = lines[first][2..].Trim();
lines.RemoveRange(0, first + 1);
}
return new CheatSheet(key, title, string.Join('\n', lines).Trim());
}
}
@@ -1,10 +1,14 @@
using System;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Documents; using Avalonia.Controls.Documents;
using Avalonia.Input.Platform;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Threading;
using ClaudeDo.Ui.Localization;
namespace ClaudeDo.Ui.Views.Controls; namespace ClaudeDo.Ui.Views.Controls;
@@ -13,12 +17,27 @@ public partial class MarkdownView : UserControl
public static readonly StyledProperty<string?> MarkdownProperty = public static readonly StyledProperty<string?> MarkdownProperty =
AvaloniaProperty.Register<MarkdownView, string?>(nameof(Markdown)); AvaloniaProperty.Register<MarkdownView, string?>(nameof(Markdown));
/// <summary>
/// Renders a bullet that starts with inline code as a command row: the command as a
/// monospace chip with a copy button, the rest as a description below it, and a rule under
/// each <c>##</c> section. Opt-in — the prompt-file viewer and the weekly report render
/// prose where nearly every bullet carries inline code, and copy buttons there are noise.
/// </summary>
public static readonly StyledProperty<bool> CommandRowsProperty =
AvaloniaProperty.Register<MarkdownView, bool>(nameof(CommandRows));
public string? Markdown public string? Markdown
{ {
get => GetValue(MarkdownProperty); get => GetValue(MarkdownProperty);
set => SetValue(MarkdownProperty, value); set => SetValue(MarkdownProperty, value);
} }
public bool CommandRows
{
get => GetValue(CommandRowsProperty);
set => SetValue(CommandRowsProperty, value);
}
private StackPanel? _host; private StackPanel? _host;
public MarkdownView() public MarkdownView()
@@ -34,6 +53,8 @@ public partial class MarkdownView : UserControl
base.OnPropertyChanged(change); base.OnPropertyChanged(change);
if (change.Property == MarkdownProperty) if (change.Property == MarkdownProperty)
Render(change.GetNewValue<string?>()); Render(change.GetNewValue<string?>());
else if (change.Property == CommandRowsProperty)
Render(Markdown);
} }
private static readonly Regex NumberedItem = new(@"^\s*(\d+)\.\s+(.*)$", RegexOptions.Compiled); private static readonly Regex NumberedItem = new(@"^\s*(\d+)\.\s+(.*)$", RegexOptions.Compiled);
@@ -48,6 +69,9 @@ public partial class MarkdownView : UserControl
return; return;
var lines = md.Replace("\r\n", "\n").Split('\n'); var lines = md.Replace("\r\n", "\n").Split('\n');
// In CommandRows mode every "## " opens a collapsible section and blocks land inside it;
// otherwise this stays the host for the whole document.
var target = _host;
int i = 0; int i = 0;
while (i < lines.Length) while (i < lines.Length)
{ {
@@ -56,19 +80,22 @@ public partial class MarkdownView : UserControl
if (line.StartsWith("### ")) if (line.StartsWith("### "))
{ {
_host.Children.Add(Heading(line[4..], 13)); target.Children.Add(Heading(line[4..], 13));
i++; i++;
continue; continue;
} }
if (line.StartsWith("## ")) if (line.StartsWith("## "))
{ {
_host.Children.Add(Heading(line[3..], 15)); if (CommandRows)
target = OpenSection(line[3..]);
else
target.Children.Add(Heading(line[3..], 15));
i++; i++;
continue; continue;
} }
if (line.StartsWith("# ")) if (line.StartsWith("# "))
{ {
_host.Children.Add(Heading(line[2..], 17)); target.Children.Add(Heading(line[2..], 17));
i++; i++;
continue; continue;
} }
@@ -77,7 +104,10 @@ public partial class MarkdownView : UserControl
{ {
while (i < lines.Length && (lines[i].StartsWith("- ") || lines[i].StartsWith("* "))) while (i < lines.Length && (lines[i].StartsWith("- ") || lines[i].StartsWith("* ")))
{ {
_host.Children.Add(Bullet(lines[i][2..])); var item = lines[i][2..];
target.Children.Add(CommandRows && TrySplitCommand(item, out var cmd, out var desc)
? CommandRow(cmd, desc)
: Bullet(item));
i++; i++;
} }
continue; continue;
@@ -89,7 +119,7 @@ public partial class MarkdownView : UserControl
while (i < lines.Length && NumberedItem.IsMatch(lines[i])) while (i < lines.Length && NumberedItem.IsMatch(lines[i]))
{ {
var m = NumberedItem.Match(lines[i]); var m = NumberedItem.Match(lines[i]);
_host.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value)); target.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value));
i++; i++;
} }
continue; continue;
@@ -104,7 +134,7 @@ public partial class MarkdownView : UserControl
paragraph.Append(lines[i].Trim()); paragraph.Append(lines[i].Trim());
i++; i++;
} }
_host.Children.Add(Paragraph(paragraph.ToString())); target.Children.Add(Paragraph(paragraph.ToString()));
} }
} }
@@ -126,6 +156,120 @@ public partial class MarkdownView : UserControl
return tb; return tb;
} }
/// <summary>
/// Adds a collapsible section for a <c>##</c> heading and returns the panel its blocks go
/// into. Native <see cref="Expander"/> — it carries the chevron, keyboard toggle and
/// animation, and the app already uses it unstyled in the worktrees overview.
/// </summary>
private StackPanel OpenSection(string title)
{
var header = new TextBlock
{
FontSize = 15,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap,
};
AppendInlines(header.Inlines!, title);
var body = new StackPanel { Spacing = 6 };
_host!.Children.Add(new Expander
{
Header = header,
Content = body,
IsExpanded = true,
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Stretch,
});
return body;
}
/// <summary>
/// Splits <c>`command` — description</c> into its two parts. False when the bullet doesn't
/// open with inline code, so prose bullets keep the plain renderer.
/// </summary>
internal static bool TrySplitCommand(string text, out string command, out string description)
{
command = "";
description = "";
if (text.Length < 3 || text[0] != '`') return false;
var close = text.IndexOf('`', 1);
if (close <= 1) return false;
command = text[1..close];
description = text[(close + 1)..].TrimStart(' ', '—', '–', '-', ':').Trim();
return true;
}
private Control CommandRow(string command, string description)
{
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
Margin = new Thickness(0, 2, 0, 2),
};
var text = new SelectableTextBlock { Text = command, TextWrapping = TextWrapping.Wrap };
// Bound, not resolved: Render can run while detached, where a resource lookup finds
// nothing and would freeze on the fallback.
text.Bind(TextBlock.FontFamilyProperty, this.GetResourceObservable("MonoFont"));
text.Bind(TextBlock.FontSizeProperty, this.GetResourceObservable("FontSizeMono"));
var chip = new Border
{
CornerRadius = new CornerRadius(4),
Padding = new Thickness(7, 4),
HorizontalAlignment = HorizontalAlignment.Left,
Child = text,
};
chip.Bind(Border.BackgroundProperty, this.GetResourceObservable("Surface2Brush"));
var stack = new StackPanel { Spacing = 3, Children = { chip } };
if (description.Length > 0)
{
var desc = new TextBlock
{
Classes = { "body" },
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(2, 0, 0, 0),
};
AppendInlines(desc.Inlines!, description);
stack.Children.Add(desc);
}
Grid.SetColumn(stack, 0);
grid.Children.Add(stack);
var copy = CopyButton(command);
Grid.SetColumn(copy, 1);
grid.Children.Add(copy);
return grid;
}
private Button CopyButton(string command)
{
var copyIcon = new PathIcon { Width = 11, Height = 11 };
copyIcon.Bind(PathIcon.DataProperty, this.GetResourceObservable("Icon.Copy"));
var checkIcon = new PathIcon { Width = 11, Height = 11 };
checkIcon.Bind(PathIcon.DataProperty, this.GetResourceObservable("Icon.Check"));
var button = new Button
{
Classes = { "icon-btn" },
Content = copyIcon,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(6, 0, 0, 0),
};
ToolTip.SetTip(button, Loc.T("controls.markdown.copy"));
button.Click += async (_, _) =>
{
if (TopLevel.GetTopLevel(this)?.Clipboard is not { } clipboard) return;
await clipboard.SetTextAsync(command);
// The clipboard gives no feedback of its own; a brief check mark is the confirmation.
button.Content = checkIcon;
DispatcherTimer.RunOnce(() => button.Content = copyIcon, TimeSpan.FromSeconds(1.5));
};
return button;
}
private static Control Paragraph(string text) private static Control Paragraph(string text)
{ {
var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap }; var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap };
+14
View File
@@ -80,6 +80,20 @@
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}" <MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
Command="{Binding CheckForUpdatesCommand}"/> Command="{Binding CheckForUpdatesCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.weeklyReport}" Command="{Binding OpenWeeklyReportCommand}"/> <MenuItem Header="{loc:Tr shell.menu.weeklyReport}" Command="{Binding OpenWeeklyReportCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.cheatSheets}">
<MenuItem Header="{loc:Tr shell.menu.cheatSheetGit}"
Command="{Binding OpenCheatSheetCommand}" CommandParameter="git"/>
<MenuItem Header="{loc:Tr shell.menu.cheatSheetCommits}"
Command="{Binding OpenCheatSheetCommand}" CommandParameter="conventional-commits"/>
<MenuItem Header="{loc:Tr shell.menu.cheatSheetClaude}"
Command="{Binding OpenCheatSheetCommand}" CommandParameter="claude-cli"/>
<MenuItem Header="{loc:Tr shell.menu.cheatSheetClaudeConfig}"
Command="{Binding OpenCheatSheetCommand}" CommandParameter="claude-config"/>
<MenuItem Header="{loc:Tr shell.menu.cheatSheetDotnet}"
Command="{Binding OpenCheatSheetCommand}" CommandParameter="dotnet-cli"/>
<MenuItem Header="{loc:Tr shell.menu.cheatSheetPowershell}"
Command="{Binding OpenCheatSheetCommand}" CommandParameter="powershell"/>
</MenuItem>
<MenuItem Header="{loc:Tr shell.menu.about}" Command="{Binding OpenAboutCommand}"/> <MenuItem Header="{loc:Tr shell.menu.about}" Command="{Binding OpenAboutCommand}"/>
</MenuItem> </MenuItem>
</Menu> </Menu>
@@ -0,0 +1,45 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Modals.CheatSheetsWindow"
x:DataType="vm:CheatSheetsViewModel"
Title="{loc:Tr modals.cheatSheets.windowTitle}"
Width="480" Height="820" MinWidth="320" MinHeight="300"
CanResize="True"
Topmost="{Binding IsPinned}"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<ctl:ModalShell Title="{loc:Tr modals.cheatSheets.title}" CloseCommand="{Binding CloseCommand}">
<DockPanel>
<!-- Sheet picker + pin. Pin keeps the window above the shell while it sits at a screen edge. -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="16,10,16,0">
<ComboBox ItemsSource="{Binding Sheets}"
SelectedItem="{Binding SelectedSheet}"
DisplayMemberBinding="{Binding Title}"
MinWidth="180"
VerticalAlignment="Center"/>
<ToggleButton Content="{loc:Tr modals.cheatSheets.pin}"
IsChecked="{Binding IsPinned}"
VerticalAlignment="Center"
ToolTip.Tip="{loc:Tr modals.cheatSheets.pinTooltip}"/>
</StackPanel>
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
<!-- Inset on the content, not the ScrollViewer: Avalonia 12 leaves ScrollViewer.Padding
out of the Extent, which clips the last lines at max scroll. -->
<ctl:MarkdownView Markdown="{Binding SelectedSheet.Body}" CommandRows="True"
Margin="16,12,16,16"/>
</ScrollViewer>
</DockPanel>
</ctl:ModalShell>
</Window>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace ClaudeDo.Ui.Views.Modals;
public partial class CheatSheetsWindow : Window
{
public CheatSheetsWindow() => InitializeComponent();
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
}
@@ -28,6 +28,7 @@ public sealed class WindowDialogService : IDialogService
private readonly Window _owner; private readonly Window _owner;
private MissionControlWindow? _missionControl; private MissionControlWindow? _missionControl;
private DiffViewerView? _diffViewerWindow; private DiffViewerView? _diffViewerWindow;
private CheatSheetsWindow? _cheatSheets;
public WindowDialogService(Window owner) => _owner = owner; public WindowDialogService(Window owner) => _owner = owner;
@@ -182,9 +183,29 @@ public sealed class WindowDialogService : IDialogService
_missionControl ??= new MissionControlWindow { DataContext = vm }; _missionControl ??= new MissionControlWindow { DataContext = vm };
if (!_missionControl.IsVisible) if (!_missionControl.IsVisible)
_missionControl.Show(); // modeless, independent top-level window _missionControl.Show(); // modeless, independent top-level window
// Activate() alone does not restore a minimised window — it stays in the taskbar and the
// button looks dead. Un-minimise first, then take focus.
if (_missionControl.WindowState == WindowState.Minimized)
_missionControl.WindowState = WindowState.Normal;
_missionControl.Activate(); // bring to front / focus _missionControl.Activate(); // bring to front / focus
} }
public void ShowCheatSheets(CheatSheetsViewModel vm)
{
// Modeless and single-instance, like Mission Control: the point is to park it beside the
// shell, so a second window (or a modal that blocks the shell) would defeat it.
if (_cheatSheets is null)
{
_cheatSheets = new CheatSheetsWindow { DataContext = vm };
vm.CloseAction = () => _cheatSheets?.Close();
_cheatSheets.Closed += (_, _) => _cheatSheets = null;
_cheatSheets.Show(_owner);
}
if (_cheatSheets.WindowState == WindowState.Minimized)
_cheatSheets.WindowState = WindowState.Normal;
_cheatSheets.Activate();
}
public Task<bool> ConfirmAsync(string message) public Task<bool> ConfirmAsync(string message)
{ {
var tcs = new TaskCompletionSource<bool>(); var tcs = new TaskCompletionSource<bool>();
@@ -0,0 +1,34 @@
using System.Linq;
using ClaudeDo.Ui.ViewModels.Modals;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class CheatSheetsViewModelTests
{
[Fact]
public void LoadsEverySheetWithTitleFromHeading()
{
var vm = new CheatSheetsViewModel();
Assert.Equal(6, vm.Sheets.Count);
Assert.Equal(
["Git", "Conventional Commits", "Claude CLI", "Claude Konfiguration", "dotnet CLI", "PowerShell"],
vm.Sheets.Select(s => s.Title));
// The heading is stripped so the window doesn't render the title twice.
Assert.All(vm.Sheets, s => Assert.StartsWith("## ", s.Body));
}
[Fact]
public void SelectSwitchesSheetAndIgnoresUnknownKeys()
{
var vm = new CheatSheetsViewModel();
Assert.Equal("git", vm.SelectedSheet!.Key);
vm.Select("dotnet-cli");
Assert.Equal("dotnet-cli", vm.SelectedSheet!.Key);
vm.Select("nope");
Assert.Equal("dotnet-cli", vm.SelectedSheet!.Key);
}
}
@@ -65,6 +65,7 @@ public class ListsIslandCreateListCancelTests : IDisposable
public Task<bool> ConfirmAsync(string message) => Task.FromResult(true); public Task<bool> ConfirmAsync(string message) => Task.FromResult(true);
public Task ShowErrorAsync(string message) => Task.CompletedTask; public Task ShowErrorAsync(string message) => Task.CompletedTask;
public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException(); public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException();
public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException();
} }
private sealed class ListSettingsProvider(Func<ListSettingsModalViewModel> create) : IServiceProvider private sealed class ListSettingsProvider(Func<ListSettingsModalViewModel> create) : IServiceProvider
@@ -63,6 +63,7 @@ public class TasksIslandMoveToListTests : IDisposable
public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException(); public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException();
public Task ShowErrorAsync(string message) => Task.CompletedTask; public Task ShowErrorAsync(string message) => Task.CompletedTask;
public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException(); public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException();
public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException();
} }
private TasksIslandViewModel BuildViewModel() => new(new TestDbFactory(NewContext), worker: null); private TasksIslandViewModel BuildViewModel() => new(new TestDbFactory(NewContext), worker: null);
@@ -0,0 +1,32 @@
using ClaudeDo.Ui.Views.Controls;
using Xunit;
namespace ClaudeDo.Ui.Tests.Views;
/// <summary>
/// Covers the command-row split only — rendering itself needs an Avalonia app, which this
/// project has no host for.
/// </summary>
public class MarkdownCommandRowTests
{
[Theory]
[InlineData("`git status -sb` — Kurzstatus mit Branch-Zeile", "git status -sb", "Kurzstatus mit Branch-Zeile")]
[InlineData("`git branch -d <name>` — löschen (`-D` erzwingt)", "git branch -d <name>", "löschen (`-D` erzwingt)")]
[InlineData("`dotnet build`", "dotnet build", "")]
public void SplitsCommandFromDescription(string line, string cmd, string desc)
{
Assert.True(MarkdownView.TrySplitCommand(line, out var actualCmd, out var actualDesc));
Assert.Equal(cmd, actualCmd);
Assert.Equal(desc, actualDesc);
}
[Theory]
[InlineData("Prosa ohne Befehl")]
[InlineData("Text mit `code` in der Mitte")]
[InlineData("`unclosed backtick")]
[InlineData("``")]
public void LeavesNonCommandBulletsToThePlainRenderer(string line)
{
Assert.False(MarkdownView.TrySplitCommand(line, out _, out _));
}
}