From 6f4ddecf44ace058b54972e6a2071dfb43f4cda2 Mon Sep 17 00:00:00 2001 From: stan44 Date: Sun, 1 Mar 2026 22:33:42 -0600 Subject: [PATCH] some gui implementations and fixed a gitea issue. --- .github/workflows/reliability-matrix.yml | 65 - ROADMAP.md | 13 +- docs/gui-bridge-contract.md | 8 + docs/gui-tui-parity.json | 32 +- src/DevTool.Host.Bridge/BridgeStdioServer.cs | 345 ++++++ src/DevTool.Host.Gui/TauriShell/README.md | 7 +- src/DevTool.Host.Gui/TauriShell/index.html | 104 ++ .../TauriShell/src/domain/workspace.ts | 7 + .../TauriShell/src/features/parityShell.ts | 1073 ++++++++++++++++- .../TauriShell/src/services/sdtBridge.ts | 82 ++ .../TauriShell/src/styles.css | 118 ++ 11 files changed, 1767 insertions(+), 87 deletions(-) delete mode 100644 .github/workflows/reliability-matrix.yml diff --git a/.github/workflows/reliability-matrix.yml b/.github/workflows/reliability-matrix.yml deleted file mode 100644 index 93e61a5..0000000 --- a/.github/workflows/reliability-matrix.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: reliability-matrix - -on: - workflow_dispatch: - push: - branches: [ main ] - pull_request: - -jobs: - matrix: - name: ${{ matrix.os }} / .NET tests - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [windows-latest, ubuntu-latest, macos-latest] - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.0.x - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Restore - run: dotnet restore DevTool.csproj - - - name: Build - run: dotnet build DevTool.csproj -c Release --no-restore - - - name: Verify workflow routes (static) - run: python scripts/verify-workflow-routes.py --project-root . - - - name: Test - run: dotnet test tests/DevTool.Tests/DevTool.Tests.csproj -c Release --no-build --logger "trx;LogFileName=test-results.trx" - - - name: Generate matrix summary - shell: pwsh - run: | - New-Item -ItemType Directory -Force -Path artifacts | Out-Null - @" - { - "os": "${{ matrix.os }}", - "commit": "${{ github.sha }}", - "workflow": "${{ github.workflow }}", - "runId": "${{ github.run_id }}", - "runNumber": "${{ github.run_number }}", - "status": "passed" - } - "@ | Set-Content artifacts/reliability-${{ matrix.os }}.json -Encoding UTF8 - - - name: Upload test artifacts - uses: actions/upload-artifact@v4 - with: - name: reliability-${{ matrix.os }} - path: | - **/test-results.trx - artifacts/reliability-${{ matrix.os }}.json diff --git a/ROADMAP.md b/ROADMAP.md index 2761c53..e0eb53e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,13 +52,24 @@ - [x] GUI parity phase 1 bridge foundation shipped (`sdt bridge --stdio` + `DevTool.Host.Bridge`) - [x] GUI debug + failure UX slice shipped (debug run, failure card, run context/lifecycle panel) - [x] GUI history/events/env/doctor/setup-plan read views shipped via bridge methods -- [ ] GUI workspace/favorites polish (switching UX + quick action ergonomics) still in progress +- [x] GUI workspace project switcher shipped (configured projects -> set active context) +- [x] GUI favorite quick action runner shipped (run favorite directly from workspace panel) +- [x] GUI workspace/favorites search/filter/sort ergonomics shipped +- [x] GUI "Switch + Run" one-click action shipped from configured projects list +- [x] GUI view preferences persisted (workspace-scoped filters/sorts/context defaults) +- [x] GUI workspace/favorites advanced grouping shipped (tool/path + project/workflow views) +- [x] GUI workspace/favorites bulk actions shipped (bulk run filtered projects/favorites, bulk remove filtered favorites) +- [x] GUI setup actions shipped (apply autofix dirs, apply legacy migration, apply recommended config with backup) +- [x] GUI keyboard refinements shipped (`Ctrl+K` command palette, `?` shortcut help) +- [x] GUI env profile editor shipped (select/load, set-active, create/update profile values) - [x] Create dedicated `src/DevTool.Host.Gui/TauriShell` scaffold to keep GUI work isolated from TUI/core - [x] Bootstrap first Tauri command bridge: `sdt workspace scan --json` - [x] Structural refactor to domain-separated projects under `src/` (`DevTool.Engine`, `DevTool.Runtime`, `DevTool.Host.Tui`) - [x] Add second Tauri bridge command: `sdt run --json` with live stream panel - [ ] Remove legacy PowerShell wrappers in v2 - [x] Add workspace project inventory model (all `.slnx/.sln/.csproj`) for GUI/TUI multi-project selector +- [x] Expand GUI command palette coverage across workspace/run/setup/history/events/favorites actions +- [x] Add full GUI env var definition editor parity (`env[]` model editing with validation) ## Next Sprint (v1.3 UX Foundation) diff --git a/docs/gui-bridge-contract.md b/docs/gui-bridge-contract.md index 99cd515..d1cbfd0 100644 --- a/docs/gui-bridge-contract.md +++ b/docs/gui-bridge-contract.md @@ -36,13 +36,21 @@ Error response: - `workspace.add` (`candidatePath`, `initializeConfig`) - `favorites.list` - `favorites.toggle` (`favoriteProjectPath`, `workflowId`, `label`) +- `favorites.removeMany` (`items[]` with `projectPath`, `workflowId`) - `history.list` (`limit`) - `events.listFiles` - `events.readFile` (`filePath`) - `envProfiles.list` - `envProfiles.resolve` (`envProfile`) +- `envProfiles.setActive` (`profileId`) +- `envProfiles.saveProfile` (`profile` object with `id`, `description`, `inherits[]`, `values{}`) +- `envVars.list` +- `envVars.save` (`env[]` array of env var definitions) - `doctor.run` - `setup.plan` (read-only preview) +- `setup.autofixDirs` (apply missing directory fixes) +- `setup.migrateLegacy` (apply targets->workflows migration) +- `setup.applyRecommendedConfig` (apply recommended config updates with backup) ## Determinism Notes diff --git a/docs/gui-tui-parity.json b/docs/gui-tui-parity.json index 4da39ac..55b5fae 100644 --- a/docs/gui-tui-parity.json +++ b/docs/gui-tui-parity.json @@ -1,14 +1,14 @@ { "version": "1.0", - "updatedAtUtc": "2026-03-02T01:00:00Z", + "updatedAtUtc": "2026-03-02T02:00:00Z", "features": [ { "id": "workspace.switch_and_candidates", "tui": true, "gui": true, - "status": "in_progress", + "status": "done", "owner": "bridge", - "notes": "GUI can load configured + candidate projects and add/add+init candidates through workspace bridge." + "notes": "GUI can load configured + candidate projects, add/add+init candidates, switch context, group/filter, and bulk run filtered projects." }, { "id": "workflow.run", @@ -54,17 +54,33 @@ "id": "favorites.quick_actions", "tui": true, "gui": true, - "status": "in_progress", + "status": "done", "owner": "bridge", - "notes": "Bridge exposes favorites list/toggle; richer quick-action UI still pending." + "notes": "GUI supports favorites run/toggle, grouping/filtering, bulk run filtered favorites, and bulk remove via bridge." }, { "id": "setup_wizard_autofix", "tui": true, - "gui": false, - "status": "planned", + "gui": true, + "status": "in_progress", "owner": "gui", - "notes": "GUI currently exposes doctor + setup plan preview only." + "notes": "GUI now supports doctor/setup plan plus apply flows for dir autofix, legacy migration, and recommended config updates." + }, + { + "id": "env_management_profile_editor", + "tui": true, + "gui": true, + "status": "done", + "owner": "gui", + "notes": "GUI supports env profile selector/load, set-active, create/update profile values, and full env[] definition editing with validation." + }, + { + "id": "command_palette_and_shortcuts", + "tui": true, + "gui": true, + "status": "done", + "owner": "gui", + "notes": "GUI provides Ctrl+K command palette and ? shortcut help, including dynamic workspace switch and favorites run commands plus setup/doctor/history/events actions." } ] } diff --git a/src/DevTool.Host.Bridge/BridgeStdioServer.cs b/src/DevTool.Host.Bridge/BridgeStdioServer.cs index f81f5d5..aed771f 100644 --- a/src/DevTool.Host.Bridge/BridgeStdioServer.cs +++ b/src/DevTool.Host.Bridge/BridgeStdioServer.cs @@ -74,13 +74,21 @@ public sealed class BridgeStdioServer "workspace.add" => Ok(request.Id, HandleWorkspaceAdd(request.Params)), "favorites.list" => Ok(request.Id, HandleFavoritesList(request.Params)), "favorites.toggle" => Ok(request.Id, HandleFavoritesToggle(request.Params)), + "favorites.removeMany" => Ok(request.Id, HandleFavoritesRemoveMany(request.Params)), "history.list" => Ok(request.Id, HandleHistoryList(request.Params)), "events.listFiles" => Ok(request.Id, HandleEventsListFiles(request.Params)), "events.readFile" => Ok(request.Id, HandleEventsReadFile(request.Params)), "envProfiles.list" => Ok(request.Id, HandleEnvProfilesList(request.Params)), "envProfiles.resolve" => Ok(request.Id, HandleEnvProfilesResolve(request.Params)), + "envProfiles.setActive" => Ok(request.Id, HandleEnvProfilesSetActive(request.Params)), + "envProfiles.saveProfile" => Ok(request.Id, HandleEnvProfilesSaveProfile(request.Params)), + "envVars.list" => Ok(request.Id, HandleEnvVarsList(request.Params)), + "envVars.save" => Ok(request.Id, HandleEnvVarsSave(request.Params)), "doctor.run" => Ok(request.Id, await HandleDoctorRunAsync(request.Params, cancellationToken).ConfigureAwait(false)), "setup.plan" => Ok(request.Id, HandleSetupPlan(request.Params)), + "setup.autofixDirs" => Ok(request.Id, HandleSetupAutofixDirs(request.Params)), + "setup.migrateLegacy" => Ok(request.Id, HandleSetupMigrateLegacy(request.Params)), + "setup.applyRecommendedConfig" => Ok(request.Id, HandleSetupApplyRecommendedConfig(request.Params)), _ => Error(request.Id, "method_not_found", $"Unsupported bridge method: {request.Method}") }; } @@ -222,6 +230,48 @@ public sealed class BridgeStdioServer return HandleFavoritesList(@params); } + private object HandleFavoritesRemoveMany(JsonElement @params) + { + var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); + var loaded = WorkspaceLoader.FindAndLoad(startDir) ?? throw new BridgeValidationException("No workspace configuration found."); + var (workspace, workspaceRoot) = loaded; + + if (@params.ValueKind != JsonValueKind.Object || + !@params.TryGetProperty("items", out var itemsProp) || + itemsProp.ValueKind != JsonValueKind.Array) + { + throw new BridgeValidationException("Missing required parameter 'items'."); + } + + var items = new List<(string ProjectPath, string WorkflowId)>(); + foreach (var item in itemsProp.EnumerateArray()) + { + var path = item.TryGetProperty("projectPath", out var pp) && pp.ValueKind == JsonValueKind.String + ? pp.GetString() + : null; + var workflowId = item.TryGetProperty("workflowId", out var wf) && wf.ValueKind == JsonValueKind.String + ? wf.GetString() + : null; + if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(workflowId)) + continue; + items.Add((Path.GetFullPath(path), workflowId!)); + } + + if (items.Count == 0) + return HandleFavoritesList(@params); + + workspace.Favorites.RemoveAll(f => + { + var resolved = WorkspaceLoader.ResolveFavoriteProjectRoot(workspaceRoot, f); + return items.Any(i => + string.Equals(i.ProjectPath, resolved, StringComparison.OrdinalIgnoreCase) && + string.Equals(i.WorkflowId, f.WorkflowId, StringComparison.OrdinalIgnoreCase)); + }); + + WorkspaceLoader.Save(workspaceRoot, workspace); + return HandleFavoritesList(@params); + } + private object HandleHistoryList(JsonElement @params) { var projectRoot = ResolveProjectRootForProjectScopedMethod(@params); @@ -270,6 +320,150 @@ public sealed class BridgeStdioServer }; } + private object HandleEnvProfilesSetActive(JsonElement @params) + { + var loaded = LoadProject(@params); + var profileId = GetRequiredString(@params, "profileId"); + var envProfiles = loaded.Config.EnvProfiles ?? new EnvProfilesConfig + { + Active = profileId, + Profiles = [] + }; + + var profileExists = envProfiles.Profiles.Any(p => + string.Equals(p.Id, profileId, StringComparison.OrdinalIgnoreCase)); + if (!profileExists) + throw new BridgeValidationException($"Profile '{profileId}' does not exist."); + + var updated = new DevToolConfig + { + Name = loaded.Config.Name, + Version = loaded.Config.Version, + Targets = loaded.Config.Targets, + Workflows = loaded.Config.Workflows, + Env = loaded.Config.Env, + EnvProfiles = new EnvProfilesConfig + { + Active = profileId, + Profiles = envProfiles.Profiles + }, + Toolchains = loaded.Config.Toolchains, + Tooling = loaded.Config.Tooling, + Project = loaded.Config.Project, + Debug = loaded.Config.Debug + }; + + var save = SaveConfigWithBackup(loaded.ProjectRoot, updated); + return new + { + projectRoot = loaded.ProjectRoot, + profileId, + save, + envProfiles = new + { + active = profileId, + profiles = envProfiles.Profiles + } + }; + } + + private object HandleEnvProfilesSaveProfile(JsonElement @params) + { + var loaded = LoadProject(@params); + if (@params.ValueKind != JsonValueKind.Object || + !@params.TryGetProperty("profile", out var profileProp) || + profileProp.ValueKind != JsonValueKind.Object) + { + throw new BridgeValidationException("Missing required parameter 'profile'."); + } + + var id = profileProp.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String + ? idProp.GetString() + : null; + if (string.IsNullOrWhiteSpace(id)) + throw new BridgeValidationException("Profile id is required."); + + var description = profileProp.TryGetProperty("description", out var dProp) && dProp.ValueKind == JsonValueKind.String + ? dProp.GetString() ?? "" + : ""; + + var inherits = new List(); + if (profileProp.TryGetProperty("inherits", out var inhProp) && inhProp.ValueKind == JsonValueKind.Array) + { + foreach (var entry in inhProp.EnumerateArray()) + { + if (entry.ValueKind == JsonValueKind.String) + { + var value = entry.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + inherits.Add(value!); + } + } + } + + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (profileProp.TryGetProperty("values", out var valuesProp) && valuesProp.ValueKind == JsonValueKind.Object) + { + foreach (var kvp in valuesProp.EnumerateObject()) + { + if (kvp.Value.ValueKind == JsonValueKind.String) + values[kvp.Name] = kvp.Value.GetString() ?? ""; + } + } + + var envProfiles = loaded.Config.EnvProfiles ?? new EnvProfilesConfig + { + Active = id!, + Profiles = [] + }; + var profiles = envProfiles.Profiles.ToList(); + var existingIndex = profiles.FindIndex(p => string.Equals(p.Id, id, StringComparison.OrdinalIgnoreCase)); + var newProfile = new EnvProfileDefinition + { + Id = id!, + Description = description, + Inherits = inherits, + Values = values, + }; + + if (existingIndex >= 0) + profiles[existingIndex] = newProfile; + else + profiles.Add(newProfile); + + var active = string.IsNullOrWhiteSpace(envProfiles.Active) ? id! : envProfiles.Active; + var updated = new DevToolConfig + { + Name = loaded.Config.Name, + Version = loaded.Config.Version, + Targets = loaded.Config.Targets, + Workflows = loaded.Config.Workflows, + Env = loaded.Config.Env, + EnvProfiles = new EnvProfilesConfig + { + Active = active, + Profiles = profiles + }, + Toolchains = loaded.Config.Toolchains, + Tooling = loaded.Config.Tooling, + Project = loaded.Config.Project, + Debug = loaded.Config.Debug + }; + + var save = SaveConfigWithBackup(loaded.ProjectRoot, updated); + return new + { + projectRoot = loaded.ProjectRoot, + profile = newProfile, + save, + envProfiles = new + { + active, + profiles + } + }; + } + private async Task HandleDoctorRunAsync(JsonElement @params, CancellationToken cancellationToken) { var loaded = LoadProject(@params); @@ -303,6 +497,138 @@ public sealed class BridgeStdioServer }; } + private object HandleEnvVarsList(JsonElement @params) + { + var loaded = LoadProject(@params); + return new + { + projectRoot = loaded.ProjectRoot, + env = loaded.Config.Env + }; + } + + private object HandleEnvVarsSave(JsonElement @params) + { + var loaded = LoadProject(@params); + if (@params.ValueKind != JsonValueKind.Object || + !@params.TryGetProperty("env", out var envProp) || + envProp.ValueKind != JsonValueKind.Array) + { + throw new BridgeValidationException("Missing required parameter 'env'."); + } + + var env = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in envProp.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + continue; + + var key = item.TryGetProperty("key", out var keyProp) && keyProp.ValueKind == JsonValueKind.String + ? keyProp.GetString() + : null; + if (string.IsNullOrWhiteSpace(key)) + throw new BridgeValidationException("Each env var requires a non-empty 'key'."); + if (!seen.Add(key!)) + throw new BridgeValidationException($"Duplicate env key detected: '{key}'."); + + var description = item.TryGetProperty("description", out var descProp) && descProp.ValueKind == JsonValueKind.String + ? descProp.GetString() ?? "" + : ""; + var defaultValue = item.TryGetProperty("default", out var defProp) && defProp.ValueKind == JsonValueKind.String + ? defProp.GetString() ?? "" + : ""; + var options = new List(); + if (item.TryGetProperty("options", out var optionsProp) && optionsProp.ValueKind == JsonValueKind.Array) + { + foreach (var opt in optionsProp.EnumerateArray()) + { + if (opt.ValueKind == JsonValueKind.String) + options.Add(opt.GetString() ?? ""); + } + } + + env.Add(new EnvVarDef + { + Key = key!, + Description = description, + DefaultValue = defaultValue, + Options = options + }); + } + + var updated = new DevToolConfig + { + Name = loaded.Config.Name, + Version = loaded.Config.Version, + Targets = loaded.Config.Targets, + Workflows = loaded.Config.Workflows, + Env = env, + EnvProfiles = loaded.Config.EnvProfiles, + Toolchains = loaded.Config.Toolchains, + Tooling = loaded.Config.Tooling, + Project = loaded.Config.Project, + Debug = loaded.Config.Debug + }; + + var save = SaveConfigWithBackup(loaded.ProjectRoot, updated); + return new + { + projectRoot = loaded.ProjectRoot, + env, + save + }; + } + + private object HandleSetupAutofixDirs(JsonElement @params) + { + var loaded = LoadProject(@params); + var missingDirs = _doctorFixes.FindMissingWorkingDirectories(loaded.Config, loaded.ProjectRoot); + var result = _doctorFixes.CreateMissingWorkingDirectories(missingDirs); + return new + { + projectRoot = loaded.ProjectRoot, + missingDirectories = missingDirs, + result + }; + } + + private object HandleSetupMigrateLegacy(JsonElement @params) + { + var loaded = LoadProject(@params); + var result = _doctorFixes.ApplyLegacyMigration(loaded.ProjectRoot); + return new + { + projectRoot = loaded.ProjectRoot, + result + }; + } + + private object HandleSetupApplyRecommendedConfig(JsonElement @params) + { + var loaded = LoadProject(@params); + var update = _setupConfigService.ApplyRecommendedDefaults(loaded.Config); + if (update.Changes.Count == 0) + { + return new + { + projectRoot = loaded.ProjectRoot, + changes = update.Changes, + applied = false, + message = "No recommended config changes were needed." + }; + } + + var save = SaveConfigWithBackup(loaded.ProjectRoot, update.Config); + return new + { + projectRoot = loaded.ProjectRoot, + changes = update.Changes, + applied = save.Success, + save + }; + } + private LoadedProjectConfig LoadProject(JsonElement @params) { var startDir = GetString(@params, "projectRoot") ?? _startupProjectRoot ?? Directory.GetCurrentDirectory(); @@ -351,6 +677,25 @@ public sealed class BridgeStdioServer private static BridgeResponseEnvelope Error(string? id, string code, string message, object? details = null) => new(id, false, null, new BridgeErrorEnvelope(code, message, details)); + + private static LegacyMigrationApplyResult SaveConfigWithBackup(string projectRoot, DevToolConfig config) + { + try + { + var path = ConfigLoader.FindConfigPath(projectRoot); + if (string.IsNullOrWhiteSpace(path)) + return new LegacyMigrationApplyResult(false, "Could not find devtool.json for saving."); + + var backup = path + $".bak-{DateTimeOffset.Now:yyyyMMdd-HHmmss}"; + File.Copy(path, backup, overwrite: false); + File.WriteAllText(path, ConfigBootstrapper.ToJson(config)); + return new LegacyMigrationApplyResult(true, "Saved updated devtool.json.", BackupPath: backup, ConfigPath: path); + } + catch (Exception ex) + { + return new LegacyMigrationApplyResult(false, ex.Message); + } + } } public sealed class BridgeValidationException(string message) : Exception(message); diff --git a/src/DevTool.Host.Gui/TauriShell/README.md b/src/DevTool.Host.Gui/TauriShell/README.md index 4ddc114..fc91d8d 100644 --- a/src/DevTool.Host.Gui/TauriShell/README.md +++ b/src/DevTool.Host.Gui/TauriShell/README.md @@ -34,5 +34,10 @@ npm run tauri dev - workflow + debug run - failure card rendering from summary payload - run context + lifecycle panel - - workspace load/add/add+init + - workspace load/add/add+init + project context switcher + - favorite quick-action runner ("Run Favorite") - run history + events viewer + - workspace/favorites grouping + bulk actions + - setup apply actions (autofix dirs, legacy migration, recommended config) + - keyboard UX (`Ctrl+K` command palette, `?` shortcut help) + - env management editor (`envProfiles` + grid-based `env[]` definitions editor) diff --git a/src/DevTool.Host.Gui/TauriShell/index.html b/src/DevTool.Host.Gui/TauriShell/index.html index e0a4186..2c6f1e4 100644 --- a/src/DevTool.Host.Gui/TauriShell/index.html +++ b/src/DevTool.Host.Gui/TauriShell/index.html @@ -45,6 +45,45 @@ +

Workspace Controls

+
+ + + +
+
+ + +
+

Configured Projects (switch context)

+
+

Favorites Controls

+
+ + + +
+
+ + +
+

Favorites (run now)

+

       
 
@@ -98,15 +137,80 @@
           
           
         
+        
+ + + +

Environment


+        

Env Profile Editor

+
+
+ + + +
+ + + + + + + + +
+ + +
+
+

Env Var Definitions Editor

+
+

Format: KEY|description|default|option1,option2 (one per line)

+
+ + + + +
+
+ +
+ + + +
+

+        

Diagnostics


       
+
+      
+

Keyboard

+

Ctrl+K command palette, ? shortcut help.

+
+ + + + diff --git a/src/DevTool.Host.Gui/TauriShell/src/domain/workspace.ts b/src/DevTool.Host.Gui/TauriShell/src/domain/workspace.ts index 0eeab6f..cbd97b0 100644 --- a/src/DevTool.Host.Gui/TauriShell/src/domain/workspace.ts +++ b/src/DevTool.Host.Gui/TauriShell/src/domain/workspace.ts @@ -101,3 +101,10 @@ export type SetupPlanResult = { }>; recommendedChanges: string[]; }; + +export type EnvVarDefinition = { + key: string; + description: string; + default: string; + options: string[]; +}; diff --git a/src/DevTool.Host.Gui/TauriShell/src/features/parityShell.ts b/src/DevTool.Host.Gui/TauriShell/src/features/parityShell.ts index 790776f..1b6fd22 100644 --- a/src/DevTool.Host.Gui/TauriShell/src/features/parityShell.ts +++ b/src/DevTool.Host.Gui/TauriShell/src/features/parityShell.ts @@ -1,5 +1,11 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import type { RunHistoryItem, WorkspaceGetResult } from "../domain/workspace"; +import type { + RunHistoryItem, + EnvVarDefinition, + WorkspaceFavorite, + WorkspaceGetResult, + WorkspaceProject, +} from "../domain/workspace"; import type { RunEventLine, RunStreamLineEvent, @@ -10,19 +16,46 @@ import { addWorkspaceCandidate, getWorkspace, listEnvProfiles, + listEnvVars, listEventFiles, listHistory, readEventFile, resolveEnvProfile, + removeFavoritesMany, runDebug, runDoctor, runWorkflow, + saveEnvVars, + saveEnvProfile, + setActiveEnvProfile, + setupApplyRecommendedConfig, + setupAutofixDirs, + setupMigrateLegacy, setupPlan, toggleFavorite, workspaceScanRaw, } from "../services/sdtBridge"; type RunMode = "workflow" | "debug"; +type EnvVarGridRow = { + key: string; + description: string; + defaultValue: string; + options: string[]; +}; +type GuiViewPrefs = { + projectRoot: string; + envProfile: string; + projectFilter: string; + projectSort: string; + projectGroup: string; + switchRunWorkflow: string; + favoriteFilter: string; + favoriteSort: string; + favoriteGroup: string; +}; + +const PREFS_STORAGE_KEY = "sdt.gui.viewPrefs.v1"; function q(selector: string): T { const el = document.querySelector(selector); @@ -82,17 +115,58 @@ export async function setupParityShell(): Promise { const runDebugBtn = q("#run-debug-btn"); const verboseModeInput = q("#verbose-mode"); const toggleFavoriteBtn = q("#toggle-favorite-btn"); + const projectFilterInput = q("#project-filter"); + const projectSortSelect = q("#project-sort"); + const projectGroupSelect = q("#project-group"); + const switchRunWorkflowInput = q("#switch-run-workflow"); + const bulkRunProjectsBtn = q("#bulk-run-projects-btn"); + const favoriteFilterInput = q("#favorite-filter"); + const favoriteSortSelect = q("#favorite-sort"); + const favoriteGroupSelect = q("#favorite-group"); + const bulkRunFavoritesBtn = q("#bulk-run-favorites-btn"); + const bulkRemoveFavoritesBtn = q("#bulk-remove-favorites-btn"); const favoriteWorkflowInput = q("#favorite-workflow-id"); const favoriteLabelInput = q("#favorite-label"); const loadHistoryBtn = q("#load-history-btn"); const loadEventsBtn = q("#load-events-btn"); const loadDoctorBtn = q("#load-doctor-btn"); const loadSetupPlanBtn = q("#load-setup-plan-btn"); + const applyAutofixDirsBtn = q("#apply-autofix-dirs-btn"); + const applyMigrateLegacyBtn = q("#apply-migrate-legacy-btn"); + const applyRecommendedConfigBtn = q("#apply-recommended-config-btn"); const loadEnvBtn = q("#load-env-btn"); const envResolveInput = q("#env-resolve-id"); + const envProfileSelect = q("#env-profile-select"); + const envLoadProfileBtn = q("#env-load-profile-btn"); + const envSetActiveBtn = q("#env-set-active-btn"); + const envProfileIdInput = q("#env-profile-id"); + const envProfileDescInput = q("#env-profile-desc"); + const envProfileInheritsInput = q("#env-profile-inherits"); + const envProfileValuesInput = q("#env-profile-values"); + const envSaveProfileBtn = q("#env-save-profile-btn"); + const envNewProfileBtn = q("#env-new-profile-btn"); + const envVarsEditor = q("#env-vars-editor"); + const envVarsGrid = q("#env-vars-grid"); + const envVarsAddRowBtn = q("#env-vars-add-row-btn"); + const envVarsSyncToAdvancedBtn = q("#env-vars-sync-to-advanced-btn"); + const envVarsSyncFromAdvancedBtn = q("#env-vars-sync-from-advanced-btn"); + const envVarsToggleAdvancedBtn = q("#env-vars-toggle-advanced-btn"); + const envVarsLoadBtn = q("#env-vars-load-btn"); + const envVarsValidateBtn = q("#env-vars-validate-btn"); + const envVarsSaveBtn = q("#env-vars-save-btn"); + const envVarsValidation = q("#env-vars-validation"); + const palette = q("#palette"); + const paletteFilter = q("#palette-filter"); + const paletteList = q("#palette-list"); + const paletteCloseBtn = q("#palette-close-btn"); + const shortcuts = q("#shortcuts"); + const shortcutHelp = q("#shortcut-help"); + const shortcutsCloseBtn = q("#shortcuts-close-btn"); const workspaceStatus = q("#workspace-status"); const workspaceOutput = q("#workspace-output"); + const configuredProjectsEl = q("#configured-projects"); + const favoriteActionsEl = q("#favorite-actions"); const runStatus = q("#run-status"); const runOutput = q("#run-output"); const runContext = q("#run-context"); @@ -106,7 +180,592 @@ export async function setupParityShell(): Promise { let activeSessionId: string | null = null; let workspaceCache: WorkspaceGetResult | null = null; - let historyCache: RunHistoryItem[] = []; + let prefsHydrated = false; + let envProfilesCache: Array<{ + id: string; + description: string; + inherits: string[]; + values: Record; + }> = []; + let envVarGridRows: EnvVarGridRow[] = []; + let envAdvancedVisible = true; + + function currentPrefsNamespace(): string { + if (workspaceCache?.workspaceRoot) { + return workspaceCache.workspaceRoot.toLowerCase(); + } + const root = projectRootInput.value.trim(); + return root ? root.toLowerCase() : "global"; + } + + function loadPrefsStore(): Record { + try { + const raw = window.localStorage.getItem(PREFS_STORAGE_KEY); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw) as Record; + return parsed ?? {}; + } catch { + return {}; + } + } + + function savePrefsStore(store: Record): void { + window.localStorage.setItem(PREFS_STORAGE_KEY, JSON.stringify(store)); + } + + function capturePrefs(): GuiViewPrefs { + return { + projectRoot: projectRootInput.value, + envProfile: envProfileInput.value, + projectFilter: projectFilterInput.value, + projectSort: projectSortSelect.value, + projectGroup: projectGroupSelect.value, + switchRunWorkflow: switchRunWorkflowInput.value, + favoriteFilter: favoriteFilterInput.value, + favoriteSort: favoriteSortSelect.value, + favoriteGroup: favoriteGroupSelect.value, + }; + } + + function applyPrefs(prefs: GuiViewPrefs): void { + projectRootInput.value = prefs.projectRoot ?? ""; + envProfileInput.value = prefs.envProfile ?? ""; + projectFilterInput.value = prefs.projectFilter ?? ""; + projectSortSelect.value = prefs.projectSort || "name"; + projectGroupSelect.value = prefs.projectGroup || "tool"; + switchRunWorkflowInput.value = prefs.switchRunWorkflow ?? ""; + favoriteFilterInput.value = prefs.favoriteFilter ?? ""; + favoriteSortSelect.value = prefs.favoriteSort || "label"; + favoriteGroupSelect.value = prefs.favoriteGroup || "project"; + } + + function parseKeyValueLines(input: string): Record { + const values: Record = {}; + const lines = input.split(/\r?\n/); + for (const raw of lines) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const idx = line.indexOf("="); + if (idx <= 0) continue; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1); + if (!key) continue; + values[key] = value; + } + return values; + } + + function formatEnvVarDefinitions(defs: EnvVarDefinition[]): string { + return defs + .slice() + .sort((a, b) => a.key.localeCompare(b.key)) + .map((d) => `${d.key}|${d.description ?? ""}|${d.default ?? ""}|${(d.options ?? []).join(",")}`) + .join("\n"); + } + + function parseEnvVarDefinitions(input: string): { + entries: EnvVarDefinition[]; + errors: string[]; + } { + const entries: EnvVarDefinition[] = []; + const errors: string[] = []; + const seen = new Set(); + const lines = input.split(/\r?\n/); + for (let i = 0; i < lines.length; i += 1) { + const raw = lines[i]; + const line = raw.trim(); + if (!line || line.startsWith("#")) { + continue; + } + const parts = raw.split("|"); + const key = (parts[0] ?? "").trim(); + const description = (parts[1] ?? "").trim(); + const defaultValue = (parts[2] ?? "").trim(); + const optionsRaw = (parts[3] ?? "").trim(); + const options = optionsRaw + ? optionsRaw.split(",").map((x) => x.trim()).filter((x) => x.length > 0) + : []; + + if (!key) { + errors.push(`Line ${i + 1}: missing key.`); + continue; + } + const keyLower = key.toLowerCase(); + if (seen.has(keyLower)) { + errors.push(`Line ${i + 1}: duplicate key '${key}'.`); + continue; + } + seen.add(keyLower); + + entries.push({ + key, + description, + default: defaultValue, + options, + }); + } + + return { entries, errors }; + } + + function envDefsToGridRows(defs: EnvVarDefinition[]): EnvVarGridRow[] { + return defs.map((d) => ({ + key: d.key ?? "", + description: d.description ?? "", + defaultValue: d.default ?? "", + options: [...(d.options ?? [])], + })); + } + + function gridRowsToEnvDefs(rows: EnvVarGridRow[]): EnvVarDefinition[] { + return rows + .filter((r) => r.key.trim().length > 0) + .map((r) => ({ + key: r.key.trim(), + description: r.description.trim(), + default: r.defaultValue, + options: r.options.map((o) => o.trim()).filter((o) => o.length > 0), + })); + } + + function renderEnvVarGrid(): void { + envVarsGrid.textContent = ""; + + const header = document.createElement("div"); + header.className = "env-grid-row env-grid-header"; + header.innerHTML = "
Key
Description
Default
Options
Actions
"; + envVarsGrid.appendChild(header); + + for (let i = 0; i < envVarGridRows.length; i += 1) { + const row = envVarGridRows[i]; + const rowEl = document.createElement("div"); + rowEl.className = "env-grid-row"; + + const keyInput = document.createElement("input"); + keyInput.value = row.key; + keyInput.placeholder = "SDT_LOG_LEVEL"; + keyInput.addEventListener("input", () => { + envVarGridRows[i].key = keyInput.value; + }); + + const descInput = document.createElement("input"); + descInput.value = row.description; + descInput.placeholder = "Description"; + descInput.addEventListener("input", () => { + envVarGridRows[i].description = descInput.value; + }); + + const defInput = document.createElement("input"); + defInput.value = row.defaultValue; + defInput.placeholder = "default"; + defInput.addEventListener("input", () => { + envVarGridRows[i].defaultValue = defInput.value; + }); + + const optsWrap = document.createElement("div"); + const optsInput = document.createElement("input"); + optsInput.placeholder = "Add option + Enter"; + optsInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + const value = optsInput.value.trim(); + if (!value) return; + envVarGridRows[i].options.push(value); + optsInput.value = ""; + renderEnvVarGrid(); + } + }); + const chips = document.createElement("div"); + chips.className = "chip-row"; + for (const option of row.options) { + const chip = document.createElement("span"); + chip.className = "chip"; + chip.textContent = option; + const remove = document.createElement("button"); + remove.type = "button"; + remove.textContent = "x"; + remove.addEventListener("click", () => { + envVarGridRows[i].options = envVarGridRows[i].options.filter((o) => o !== option); + renderEnvVarGrid(); + }); + chip.appendChild(remove); + chips.appendChild(chip); + } + optsWrap.append(optsInput, chips); + + const actions = document.createElement("div"); + const removeRowBtn = document.createElement("button"); + removeRowBtn.type = "button"; + removeRowBtn.textContent = "Remove"; + removeRowBtn.addEventListener("click", () => { + envVarGridRows.splice(i, 1); + renderEnvVarGrid(); + }); + actions.appendChild(removeRowBtn); + + rowEl.append(keyInput, descInput, defInput, optsWrap, actions); + envVarsGrid.appendChild(rowEl); + } + } + + function syncGridToAdvancedEditor(): { entries: EnvVarDefinition[]; errors: string[] } { + const entries = gridRowsToEnvDefs(envVarGridRows); + const serialized = formatEnvVarDefinitions(entries); + const parsed = parseEnvVarDefinitions(serialized); + if (parsed.errors.length === 0) { + envVarsEditor.value = serialized; + } + return parsed; + } + + function syncAdvancedToGrid(): { entries: EnvVarDefinition[]; errors: string[] } { + const parsed = parseEnvVarDefinitions(envVarsEditor.value); + if (parsed.errors.length === 0) { + envVarGridRows = envDefsToGridRows(parsed.entries); + renderEnvVarGrid(); + } + return parsed; + } + + function setAdvancedModeVisible(visible: boolean): void { + envAdvancedVisible = visible; + envVarsEditor.style.display = visible ? "block" : "none"; + envVarsToggleAdvancedBtn.textContent = visible ? "Hide Advanced Mode" : "Show Advanced Mode"; + } + + function formatKeyValueLines(values: Record): string { + const keys = Object.keys(values).sort((a, b) => a.localeCompare(b)); + return keys.map((k) => `${k}=${values[k]}`).join("\n"); + } + + function refreshEnvProfileSelect(activeId: string | null): void { + envProfileSelect.textContent = ""; + for (const profile of envProfilesCache) { + const option = document.createElement("option"); + option.value = profile.id; + option.textContent = profile.id === activeId ? `${profile.id} (active)` : profile.id; + envProfileSelect.appendChild(option); + } + if (activeId) { + envProfileSelect.value = activeId; + } + } + + function loadProfileIntoEditor(profileId: string): void { + const profile = envProfilesCache.find((p) => p.id === profileId); + if (!profile) return; + envProfileIdInput.value = profile.id; + envProfileDescInput.value = profile.description ?? ""; + envProfileInheritsInput.value = profile.inherits.join(","); + envProfileValuesInput.value = formatKeyValueLines(profile.values ?? {}); + } + + function createGroup( + parent: HTMLElement, + title: string, + ): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "group"; + const heading = document.createElement("p"); + heading.className = "group-title"; + heading.textContent = title; + wrapper.appendChild(heading); + parent.appendChild(wrapper); + return wrapper; + } + + function groupProjects(projects: WorkspaceProject[]): Map { + const mode = projectGroupSelect.value; + const groups = new Map(); + for (const project of projects) { + let key = "Projects"; + if (mode === "tool") { + key = project.toolFamilies[0] || "unclassified"; + } else if (mode === "path") { + const normalized = project.resolvedRoot.replace(/\\/g, "/"); + const parts = normalized.split("/"); + key = parts.length >= 2 ? parts[parts.length - 2] : "root"; + } + if (mode === "none") { + key = "Projects"; + } + const list = groups.get(key) ?? []; + list.push(project); + groups.set(key, list); + } + return groups; + } + + function groupFavorites(favorites: WorkspaceFavorite[]): Map { + const mode = favoriteGroupSelect.value; + const groups = new Map(); + for (const favorite of favorites) { + let key = "Favorites"; + if (mode === "project") { + key = favorite.resolvedProjectRoot; + } else if (mode === "workflow") { + key = favorite.workflowId; + } + if (mode === "none") { + key = "Favorites"; + } + const list = groups.get(key) ?? []; + list.push(favorite); + groups.set(key, list); + } + return groups; + } + + function persistPrefs(): void { + const store = loadPrefsStore(); + store[currentPrefsNamespace()] = capturePrefs(); + savePrefsStore(store); + } + + function hydratePrefs(): void { + const store = loadPrefsStore(); + const exact = store[currentPrefsNamespace()]; + const global = store.global; + const candidate = exact ?? global; + if (candidate) { + applyPrefs(candidate); + } + prefsHydrated = true; + } + + function renderWorkspaceActions(): void { + configuredProjectsEl.textContent = ""; + favoriteActionsEl.textContent = ""; + + if (!workspaceCache) { + return; + } + + const projectFilter = projectFilterInput.value.trim().toLowerCase(); + const projectSort = projectSortSelect.value; + const switchRunWorkflow = switchRunWorkflowInput.value.trim(); + + const configured = [...workspaceCache.configuredProjects] + .filter((project) => { + if (!projectFilter) return true; + return ( + project.name.toLowerCase().includes(projectFilter) || + project.resolvedRoot.toLowerCase().includes(projectFilter) || + project.description.toLowerCase().includes(projectFilter) + ); + }) + .sort((a, b) => { + if (projectSort === "path") { + return a.resolvedRoot.localeCompare(b.resolvedRoot); + } + return a.name.localeCompare(b.name); + }); + + const projectGroups = groupProjects(configured); + for (const [groupName, projects] of projectGroups) { + const host = createGroup(configuredProjectsEl, `${groupName} (${projects.length})`); + for (const project of projects) { + const item = document.createElement("div"); + item.className = "list-item"; + const left = document.createElement("div"); + left.innerHTML = `${project.name}
${project.resolvedRoot}`; + const actions = document.createElement("div"); + actions.className = "row"; + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "Use Project"; + button.addEventListener("click", () => { + projectRootInput.value = project.resolvedRoot; + workspaceStatus.textContent = `Active project set to ${project.name}`; + workspaceStatus.className = "status ok"; + persistPrefs(); + }); + actions.appendChild(button); + + const switchRunButton = document.createElement("button"); + switchRunButton.type = "button"; + switchRunButton.textContent = "Switch + Run"; + switchRunButton.disabled = switchRunWorkflow.length === 0; + switchRunButton.addEventListener("click", () => { + if (!switchRunWorkflow) { + workspaceStatus.textContent = "Set workflow id for Switch + Run first."; + workspaceStatus.className = "status error"; + return; + } + projectRootInput.value = project.resolvedRoot; + workflowIdInput.value = switchRunWorkflow; + workspaceStatus.textContent = `Running '${switchRunWorkflow}' in ${project.name}`; + workspaceStatus.className = "status ok"; + persistPrefs(); + void runExecution("workflow", switchRunWorkflow, project.resolvedRoot); + }); + actions.appendChild(switchRunButton); + + item.append(left, actions); + host.appendChild(item); + } + } + + const favoriteFilter = favoriteFilterInput.value.trim().toLowerCase(); + const favoriteSort = favoriteSortSelect.value; + const favorites = [...workspaceCache.favorites] + .filter((favorite) => { + if (!favoriteFilter) return true; + const label = favorite.label ?? ""; + return ( + label.toLowerCase().includes(favoriteFilter) || + favorite.workflowId.toLowerCase().includes(favoriteFilter) || + favorite.resolvedProjectRoot.toLowerCase().includes(favoriteFilter) + ); + }) + .sort((a, b) => { + if (favoriteSort === "workflow") { + return a.workflowId.localeCompare(b.workflowId); + } + if (favoriteSort === "project") { + return a.resolvedProjectRoot.localeCompare(b.resolvedProjectRoot); + } + return (a.label ?? a.workflowId).localeCompare(b.label ?? b.workflowId); + }); + + const favoriteGroups = groupFavorites(favorites); + for (const [groupName, favs] of favoriteGroups) { + const host = createGroup(favoriteActionsEl, `${groupName} (${favs.length})`); + for (const favorite of favs) { + const item = document.createElement("div"); + item.className = "list-item"; + const left = document.createElement("div"); + const label = favorite.label ?? favorite.workflowId; + left.innerHTML = `${label}
${favorite.resolvedProjectRoot} :: ${favorite.workflowId}`; + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "Run Favorite"; + button.addEventListener("click", () => { + workflowIdInput.value = favorite.workflowId; + projectRootInput.value = favorite.resolvedProjectRoot; + persistPrefs(); + void runExecution("workflow", favorite.workflowId, favorite.resolvedProjectRoot); + }); + item.append(left, button); + host.appendChild(item); + } + } + + if (configured.length === 0) { + configuredProjectsEl.textContent = "No configured projects match current filter."; + } + if (favorites.length === 0) { + favoriteActionsEl.textContent = "No favorites match current filter."; + } + } + + function showPalette(open: boolean): void { + palette.classList.toggle("hidden", !open); + if (open) { + paletteFilter.focus(); + renderPalette(); + } + } + + function showShortcuts(open: boolean): void { + shortcuts.classList.toggle("hidden", !open); + } + + const basePaletteCommands: Array<{ + id: string; + label: string; + run: () => void | Promise; + }> = [ + { id: "workspace.refresh", label: "Workspace: Refresh", run: () => refreshWorkspace() }, + { id: "workspace.scan.raw", label: "Workspace: Raw scan", run: () => scanRawBtn.click() }, + { id: "workspace.bulkRunProjects", label: "Workspace: Bulk run filtered projects", run: () => bulkRunProjectsBtn.click() }, + { id: "run.workflow", label: "Run: Workflow", run: () => runExecution("workflow") }, + { id: "run.debug", label: "Run: Debug", run: () => runExecution("debug") }, + { id: "doctor.run", label: "Doctor: Run", run: () => loadDoctorBtn.click() }, + { id: "setup.plan", label: "Setup: Plan", run: () => loadSetupPlanBtn.click() }, + { id: "setup.autofixDirs", label: "Setup: Apply autofix dirs", run: () => applyAutofixDirsBtn.click() }, + { id: "setup.migrateLegacy", label: "Setup: Apply legacy migration", run: () => applyMigrateLegacyBtn.click() }, + { id: "setup.applyRecommendedConfig", label: "Setup: Apply recommended config", run: () => applyRecommendedConfigBtn.click() }, + { id: "env.load", label: "Env: Load profiles + resolve", run: () => loadEnvBtn.click() }, + { id: "env.setActive", label: "Env: Set active profile (editor selection)", run: () => envSetActiveBtn.click() }, + { id: "env.saveProfile", label: "Env: Save profile from editor", run: () => envSaveProfileBtn.click() }, + { id: "env.vars.load", label: "Env Vars: Load env[] definitions", run: () => envVarsLoadBtn.click() }, + { id: "env.vars.validate", label: "Env Vars: Validate env[] editor", run: () => envVarsValidateBtn.click() }, + { id: "env.vars.save", label: "Env Vars: Save env[] definitions", run: () => envVarsSaveBtn.click() }, + { id: "history.load", label: "History: Load", run: () => loadHistoryBtn.click() }, + { id: "events.load", label: "Events: Load latest", run: () => loadEventsBtn.click() }, + { id: "favorites.bulkRun", label: "Favorites: Bulk run filtered", run: () => bulkRunFavoritesBtn.click() }, + { id: "favorites.bulkRemove", label: "Favorites: Bulk remove filtered", run: () => bulkRemoveFavoritesBtn.click() }, + ]; + + let lastPaletteMatches: Array<{ id: string; label: string; run: () => void | Promise }> = []; + + function currentPaletteCommands(): Array<{ + id: string; + label: string; + run: () => void | Promise; + }> { + const commands = [...basePaletteCommands]; + if (workspaceCache) { + for (const project of workspaceCache.configuredProjects.slice(0, 40)) { + commands.push({ + id: `workspace.switch.${project.name}`, + label: `Workspace: Switch -> ${project.name}`, + run: () => { + projectRootInput.value = project.resolvedRoot; + workspaceStatus.textContent = `Active project set to ${project.name}`; + workspaceStatus.className = "status ok"; + persistPrefs(); + }, + }); + } + + for (const favorite of workspaceCache.favorites.slice(0, 60)) { + const label = favorite.label ?? favorite.workflowId; + commands.push({ + id: `favorites.run.${favorite.workflowId}`, + label: `Favorites: Run -> ${label}`, + run: () => { + workflowIdInput.value = favorite.workflowId; + projectRootInput.value = favorite.resolvedProjectRoot; + persistPrefs(); + return runExecution("workflow", favorite.workflowId, favorite.resolvedProjectRoot); + }, + }); + } + } + return commands; + } + + function renderPalette(): void { + const filter = paletteFilter.value.trim().toLowerCase(); + paletteList.textContent = ""; + const filtered = currentPaletteCommands().filter((cmd) => { + if (!filter) return true; + return cmd.label.toLowerCase().includes(filter) || cmd.id.toLowerCase().includes(filter); + }); + lastPaletteMatches = filtered; + for (const cmd of filtered) { + const row = document.createElement("div"); + row.className = "list-item"; + const left = document.createElement("div"); + left.innerHTML = `${cmd.label}
${cmd.id}`; + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "Run"; + button.addEventListener("click", () => { + showPalette(false); + void cmd.run(); + }); + row.append(left, button); + paletteList.appendChild(row); + } + if (filtered.length === 0) { + paletteList.textContent = "No commands match filter."; + } + } const streamUnlisteners: UnlistenFn[] = []; streamUnlisteners.push( @@ -151,13 +810,28 @@ export async function setupParityShell(): Promise { workspaceStatus.textContent = "Loading workspace..."; workspaceStatus.className = "status ok"; workspaceCache = await getWorkspace(root); + if (!prefsHydrated) { + hydratePrefs(); + } else { + const scoped = loadPrefsStore()[currentPrefsNamespace()]; + if (scoped) { + applyPrefs(scoped); + } + } workspaceOutput.textContent = JSON.stringify(workspaceCache, null, 2); + renderWorkspaceActions(); workspaceStatus.textContent = "Workspace loaded."; + persistPrefs(); } - async function runExecution(mode: RunMode): Promise { + async function runExecution( + mode: RunMode, + forcedTarget?: string, + forcedProjectRoot?: string, + ): Promise { activeSessionId = createSessionId(); - const root = projectRootInput.value.trim() || null; + const rootFromInput = projectRootInput.value.trim() || null; + const root = forcedProjectRoot ?? rootFromInput; const envProfile = envProfileInput.value.trim() || null; const verbose = verboseModeInput.checked; runOutput.textContent = ""; @@ -167,7 +841,9 @@ export async function setupParityShell(): Promise { runStatus.className = "status ok"; const startedAt = new Date().toISOString(); - const target = mode === "workflow" ? workflowIdInput.value.trim() : debugProfileInput.value.trim(); + const target = + forcedTarget ?? + (mode === "workflow" ? workflowIdInput.value.trim() : debugProfileInput.value.trim()); runContext.textContent = JSON.stringify( { category: mode, @@ -267,6 +943,7 @@ export async function setupParityShell(): Promise { if (!candidatePath) return; workspaceCache = await addWorkspaceCandidate(projectRootInput.value.trim() || null, candidatePath, false); workspaceOutput.textContent = JSON.stringify(workspaceCache, null, 2); + renderWorkspaceActions(); })(); }); @@ -277,6 +954,7 @@ export async function setupParityShell(): Promise { if (!candidatePath) return; workspaceCache = await addWorkspaceCandidate(projectRootInput.value.trim() || null, candidatePath, true); workspaceOutput.textContent = JSON.stringify(workspaceCache, null, 2); + renderWorkspaceActions(); })(); }); @@ -297,17 +975,144 @@ export async function setupParityShell(): Promise { if (!workflowId) return; const projectPath = projectRootInput.value.trim() || "."; const label = favoriteLabelInput.value.trim() || null; - const favorites = await toggleFavorite(projectRootInput.value.trim() || null, projectPath, workflowId, label); + const favorites = await toggleFavorite( + projectRootInput.value.trim() || null, + projectPath, + workflowId, + label, + ); workspaceStatus.textContent = `Favorites: ${favorites.length}`; workspaceStatus.className = "status ok"; + await refreshWorkspace(); })(); }); + projectFilterInput.addEventListener("input", () => renderWorkspaceActions()); + projectSortSelect.addEventListener("change", () => renderWorkspaceActions()); + switchRunWorkflowInput.addEventListener("input", () => renderWorkspaceActions()); + favoriteFilterInput.addEventListener("input", () => renderWorkspaceActions()); + favoriteSortSelect.addEventListener("change", () => renderWorkspaceActions()); + projectGroupSelect.addEventListener("change", () => renderWorkspaceActions()); + favoriteGroupSelect.addEventListener("change", () => renderWorkspaceActions()); + + projectRootInput.addEventListener("input", () => persistPrefs()); + envProfileInput.addEventListener("input", () => persistPrefs()); + projectFilterInput.addEventListener("input", () => persistPrefs()); + projectSortSelect.addEventListener("change", () => persistPrefs()); + switchRunWorkflowInput.addEventListener("input", () => persistPrefs()); + favoriteFilterInput.addEventListener("input", () => persistPrefs()); + favoriteSortSelect.addEventListener("change", () => persistPrefs()); + projectGroupSelect.addEventListener("change", () => persistPrefs()); + favoriteGroupSelect.addEventListener("change", () => persistPrefs()); + + bulkRunProjectsBtn.addEventListener("click", (event) => { + event.preventDefault(); + void (async () => { + if (!workspaceCache) return; + const workflowId = switchRunWorkflowInput.value.trim(); + if (!workflowId) { + workspaceStatus.textContent = "Set workflow id for bulk run first."; + workspaceStatus.className = "status error"; + return; + } + const projectFilter = projectFilterInput.value.trim().toLowerCase(); + const configured = workspaceCache.configuredProjects.filter((project) => { + if (!projectFilter) return true; + return ( + project.name.toLowerCase().includes(projectFilter) || + project.resolvedRoot.toLowerCase().includes(projectFilter) || + project.description.toLowerCase().includes(projectFilter) + ); + }); + if (configured.length === 0) { + workspaceStatus.textContent = "No filtered projects to run."; + workspaceStatus.className = "status error"; + return; + } + + workspaceStatus.textContent = `Running '${workflowId}' for ${configured.length} project(s)...`; + workspaceStatus.className = "status ok"; + for (const project of configured) { + projectRootInput.value = project.resolvedRoot; + workflowIdInput.value = workflowId; + await runExecution("workflow", workflowId, project.resolvedRoot); + } + workspaceStatus.textContent = `Bulk project run completed (${configured.length}).`; + workspaceStatus.className = "status ok"; + persistPrefs(); + })(); + }); + + bulkRunFavoritesBtn.addEventListener("click", (event) => { + event.preventDefault(); + void (async () => { + if (!workspaceCache) return; + const favoriteFilter = favoriteFilterInput.value.trim().toLowerCase(); + const favorites = workspaceCache.favorites.filter((favorite) => { + if (!favoriteFilter) return true; + const label = favorite.label ?? ""; + return ( + label.toLowerCase().includes(favoriteFilter) || + favorite.workflowId.toLowerCase().includes(favoriteFilter) || + favorite.resolvedProjectRoot.toLowerCase().includes(favoriteFilter) + ); + }); + if (favorites.length === 0) { + workspaceStatus.textContent = "No filtered favorites to run."; + workspaceStatus.className = "status error"; + return; + } + + workspaceStatus.textContent = `Running ${favorites.length} favorite(s)...`; + workspaceStatus.className = "status ok"; + for (const favorite of favorites) { + projectRootInput.value = favorite.resolvedProjectRoot; + workflowIdInput.value = favorite.workflowId; + await runExecution("workflow", favorite.workflowId, favorite.resolvedProjectRoot); + } + workspaceStatus.textContent = `Bulk favorite run completed (${favorites.length}).`; + workspaceStatus.className = "status ok"; + persistPrefs(); + })(); + }); + + bulkRemoveFavoritesBtn.addEventListener("click", (event) => { + event.preventDefault(); + void (async () => { + if (!workspaceCache) return; + const favoriteFilter = favoriteFilterInput.value.trim().toLowerCase(); + const favorites = workspaceCache.favorites.filter((favorite) => { + if (!favoriteFilter) return true; + const label = favorite.label ?? ""; + return ( + label.toLowerCase().includes(favoriteFilter) || + favorite.workflowId.toLowerCase().includes(favoriteFilter) || + favorite.resolvedProjectRoot.toLowerCase().includes(favoriteFilter) + ); + }); + if (favorites.length === 0) { + workspaceStatus.textContent = "No filtered favorites to remove."; + workspaceStatus.className = "status error"; + return; + } + + await removeFavoritesMany( + projectRootInput.value.trim() || null, + favorites.map((f) => ({ projectPath: f.resolvedProjectRoot, workflowId: f.workflowId })), + ); + workspaceStatus.textContent = `Removed ${favorites.length} favorite(s).`; + workspaceStatus.className = "status ok"; + await refreshWorkspace(); + })(); + }); + + hydratePrefs(); + loadHistoryBtn.addEventListener("click", (event) => { event.preventDefault(); void (async () => { - historyCache = await listHistory(projectRootInput.value.trim() || null, 60); - historyOutput.textContent = JSON.stringify(historyCache, null, 2); + const history: RunHistoryItem[] = await listHistory(projectRootInput.value.trim() || null, 60); + historyOutput.textContent = JSON.stringify(history, null, 2); })(); }); @@ -341,18 +1146,262 @@ export async function setupParityShell(): Promise { })(); }); + async function refreshEnvData(): Promise { + const root = projectRootInput.value.trim() || null; + const profiles = await listEnvProfiles(root); + const resolved = await resolveEnvProfile(root, envResolveInput.value.trim() || null); + envProfilesCache = profiles.profiles; + refreshEnvProfileSelect(profiles.active); + if (profiles.active) { + loadProfileIntoEditor(profiles.active); + } else if (profiles.profiles.length > 0) { + loadProfileIntoEditor(profiles.profiles[0].id); + } + envOutput.textContent = JSON.stringify({ profiles, resolved }, null, 2); + } + + async function refreshEnvVarDefinitions(): Promise { + const result = await listEnvVars(projectRootInput.value.trim() || null); + const defs = result.env ?? []; + envVarGridRows = envDefsToGridRows(defs); + renderEnvVarGrid(); + envVarsEditor.value = formatEnvVarDefinitions(defs); + envVarsValidation.textContent = ""; + } + loadEnvBtn.addEventListener("click", (event) => { event.preventDefault(); void (async () => { - const root = projectRootInput.value.trim() || null; - const profiles = await listEnvProfiles(root); - const resolved = await resolveEnvProfile(root, envResolveInput.value.trim() || null); - envOutput.textContent = JSON.stringify({ profiles, resolved }, null, 2); + await refreshEnvData(); + await refreshEnvVarDefinitions(); })(); }); + envLoadProfileBtn.addEventListener("click", (event) => { + event.preventDefault(); + const selected = envProfileSelect.value; + if (!selected) return; + loadProfileIntoEditor(selected); + }); + + envProfileSelect.addEventListener("change", () => { + if (envProfileSelect.value) { + loadProfileIntoEditor(envProfileSelect.value); + } + }); + + envSetActiveBtn.addEventListener("click", (event) => { + event.preventDefault(); + void (async () => { + const profileId = envProfileIdInput.value.trim() || envProfileSelect.value; + if (!profileId) { + diagnosticsOutput.textContent = "Profile id is required to set active."; + return; + } + if (!window.confirm(`Set active env profile to '${profileId}'?`)) { + return; + } + const result = await setActiveEnvProfile(projectRootInput.value.trim() || null, profileId); + diagnosticsOutput.textContent = JSON.stringify(result, null, 2); + await refreshEnvData(); + })(); + }); + + envSaveProfileBtn.addEventListener("click", (event) => { + event.preventDefault(); + void (async () => { + const id = envProfileIdInput.value.trim(); + if (!id) { + diagnosticsOutput.textContent = "Profile id is required."; + return; + } + const description = envProfileDescInput.value.trim(); + const inherits = envProfileInheritsInput.value + .split(",") + .map((x) => x.trim()) + .filter((x) => x.length > 0); + const values = parseKeyValueLines(envProfileValuesInput.value); + if (!window.confirm(`Save env profile '${id}'?`)) { + return; + } + const result = await saveEnvProfile(projectRootInput.value.trim() || null, { + id, + description, + inherits, + values, + }); + diagnosticsOutput.textContent = JSON.stringify(result, null, 2); + await refreshEnvData(); + })(); + }); + + envNewProfileBtn.addEventListener("click", (event) => { + event.preventDefault(); + envProfileIdInput.value = ""; + envProfileDescInput.value = ""; + envProfileInheritsInput.value = ""; + envProfileValuesInput.value = ""; + }); + + envVarsLoadBtn.addEventListener("click", (event) => { + event.preventDefault(); + void refreshEnvVarDefinitions(); + }); + + envVarsValidateBtn.addEventListener("click", (event) => { + event.preventDefault(); + const parsed = syncGridToAdvancedEditor(); + if (parsed.errors.length > 0) { + envVarsValidation.textContent = `Validation failed:\n${parsed.errors.join("\n")}`; + return; + } + envVarsValidation.textContent = `Validation OK (${parsed.entries.length} definition(s)).`; + }); + + envVarsSaveBtn.addEventListener("click", (event) => { + event.preventDefault(); + void (async () => { + const parsed = syncGridToAdvancedEditor(); + if (parsed.errors.length > 0) { + envVarsValidation.textContent = `Validation failed:\n${parsed.errors.join("\n")}`; + return; + } + if (!window.confirm(`Save ${parsed.entries.length} env definition(s)?`)) { + return; + } + const result = await saveEnvVars(projectRootInput.value.trim() || null, parsed.entries); + diagnosticsOutput.textContent = JSON.stringify(result, null, 2); + envVarsValidation.textContent = "Saved env[] definitions."; + await refreshEnvVarDefinitions(); + })(); + }); + + envVarsAddRowBtn.addEventListener("click", (event) => { + event.preventDefault(); + envVarGridRows.push({ + key: "", + description: "", + defaultValue: "", + options: [], + }); + renderEnvVarGrid(); + }); + + envVarsSyncToAdvancedBtn.addEventListener("click", (event) => { + event.preventDefault(); + const parsed = syncGridToAdvancedEditor(); + envVarsValidation.textContent = + parsed.errors.length > 0 + ? `Sync failed:\n${parsed.errors.join("\n")}` + : `Synced ${parsed.entries.length} row(s) to advanced editor.`; + }); + + envVarsSyncFromAdvancedBtn.addEventListener("click", (event) => { + event.preventDefault(); + const parsed = syncAdvancedToGrid(); + envVarsValidation.textContent = + parsed.errors.length > 0 + ? `Sync failed:\n${parsed.errors.join("\n")}` + : `Synced ${parsed.entries.length} row(s) from advanced editor.`; + }); + + envVarsToggleAdvancedBtn.addEventListener("click", (event) => { + event.preventDefault(); + setAdvancedModeVisible(!envAdvancedVisible); + }); + + applyAutofixDirsBtn.addEventListener("click", (event) => { + event.preventDefault(); + void (async () => { + if (!window.confirm("Apply missing-directory autofix now?")) { + return; + } + const result = await setupAutofixDirs(projectRootInput.value.trim() || null); + diagnosticsOutput.textContent = JSON.stringify(result, null, 2); + await refreshWorkspace(); + })(); + }); + + applyMigrateLegacyBtn.addEventListener("click", (event) => { + event.preventDefault(); + void (async () => { + if (!window.confirm("Apply legacy targets -> workflows migration now?")) { + return; + } + const result = await setupMigrateLegacy(projectRootInput.value.trim() || null); + diagnosticsOutput.textContent = JSON.stringify(result, null, 2); + await refreshWorkspace(); + })(); + }); + + applyRecommendedConfigBtn.addEventListener("click", (event) => { + event.preventDefault(); + void (async () => { + if (!window.confirm("Apply recommended config enhancements now?")) { + return; + } + const result = await setupApplyRecommendedConfig(projectRootInput.value.trim() || null); + diagnosticsOutput.textContent = JSON.stringify(result, null, 2); + await refreshWorkspace(); + })(); + }); + + paletteFilter.addEventListener("input", () => renderPalette()); + paletteFilter.addEventListener("keydown", (event) => { + if (event.key === "Enter" && lastPaletteMatches.length > 0) { + event.preventDefault(); + const first = lastPaletteMatches[0]; + showPalette(false); + void first.run(); + } + }); + paletteCloseBtn.addEventListener("click", () => showPalette(false)); + shortcutsCloseBtn.addEventListener("click", () => showShortcuts(false)); + palette.addEventListener("click", (event) => { + if (event.target === palette) { + showPalette(false); + } + }); + shortcuts.addEventListener("click", (event) => { + if (event.target === shortcuts) { + showShortcuts(false); + } + }); + + shortcutHelp.textContent = [ + "Ctrl+K Open command palette", + "? Open shortcut help", + "Esc Close palette/help", + ].join("\n"); + + document.addEventListener("keydown", (event) => { + const isInputLike = + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement || + event.target instanceof HTMLSelectElement; + + if (event.key === "Escape") { + showPalette(false); + showShortcuts(false); + return; + } + + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + showPalette(true); + return; + } + + if (!isInputLike && event.key === "?") { + event.preventDefault(); + showShortcuts(true); + } + }); + if (!projectRootInput.value.trim()) { projectRootInput.value = ""; } + setAdvancedModeVisible(false); + renderEnvVarGrid(); await refreshWorkspace(); } diff --git a/src/DevTool.Host.Gui/TauriShell/src/services/sdtBridge.ts b/src/DevTool.Host.Gui/TauriShell/src/services/sdtBridge.ts index 6e401b9..4154c38 100644 --- a/src/DevTool.Host.Gui/TauriShell/src/services/sdtBridge.ts +++ b/src/DevTool.Host.Gui/TauriShell/src/services/sdtBridge.ts @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { DoctorReport, + EnvVarDefinition, EnvProfilesResult, EnvResolveResult, RunEventLogFile, @@ -78,6 +79,17 @@ export function toggleFavorite( ); } +export function removeFavoritesMany( + projectRoot: string | null, + items: Array<{ projectPath: string; workflowId: string }>, +): Promise { + return bridgeCall( + "favorites.removeMany", + { items }, + projectRoot, + ); +} + export function listHistory( projectRoot: string | null, limit: number, @@ -115,6 +127,54 @@ export function resolveEnvProfile( ); } +export function setActiveEnvProfile( + projectRoot: string | null, + profileId: string, +): Promise> { + return bridgeCall>( + "envProfiles.setActive", + { profileId }, + projectRoot, + ); +} + +export function saveEnvProfile( + projectRoot: string | null, + profile: { + id: string; + description: string; + inherits: string[]; + values: Record; + }, +): Promise> { + return bridgeCall>( + "envProfiles.saveProfile", + { profile }, + projectRoot, + ); +} + +export function listEnvVars( + projectRoot: string | null, +): Promise<{ projectRoot: string; env: EnvVarDefinition[] }> { + return bridgeCall<{ projectRoot: string; env: EnvVarDefinition[] }>( + "envVars.list", + {}, + projectRoot, + ); +} + +export function saveEnvVars( + projectRoot: string | null, + env: EnvVarDefinition[], +): Promise> { + return bridgeCall>( + "envVars.save", + { env }, + projectRoot, + ); +} + export function runDoctor(projectRoot: string | null): Promise { return bridgeCall("doctor.run", {}, projectRoot); } @@ -122,3 +182,25 @@ export function runDoctor(projectRoot: string | null): Promise { export function setupPlan(projectRoot: string | null): Promise { return bridgeCall("setup.plan", {}, projectRoot); } + +export function setupAutofixDirs( + projectRoot: string | null, +): Promise> { + return bridgeCall>("setup.autofixDirs", {}, projectRoot); +} + +export function setupMigrateLegacy( + projectRoot: string | null, +): Promise> { + return bridgeCall>("setup.migrateLegacy", {}, projectRoot); +} + +export function setupApplyRecommendedConfig( + projectRoot: string | null, +): Promise> { + return bridgeCall>( + "setup.applyRecommendedConfig", + {}, + projectRoot, + ); +} diff --git a/src/DevTool.Host.Gui/TauriShell/src/styles.css b/src/DevTool.Host.Gui/TauriShell/src/styles.css index 0785b26..8e1ddb5 100644 --- a/src/DevTool.Host.Gui/TauriShell/src/styles.css +++ b/src/DevTool.Host.Gui/TauriShell/src/styles.css @@ -45,12 +45,49 @@ body { gap: 8px; } +.list { + display: flex; + flex-direction: column; + gap: 6px; + margin: 8px 0 10px; +} + +.list-item { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + border: 1px solid #1f2937; + border-radius: 8px; + padding: 8px; + background: #0b1629; +} + +.group { + border: 1px dashed #334155; + border-radius: 8px; + padding: 8px; + margin-bottom: 8px; +} + +.group-title { + font-size: 0.9em; + color: #93c5fd; + margin: 0 0 6px; +} + +.list-item code { + color: #93c5fd; +} + label { display: block; margin-bottom: 8px; } input, +select, +textarea, button { border-radius: 8px; border: 1px solid #3f4f69; @@ -65,6 +102,63 @@ input { width: 100%; } +textarea { + width: 100%; + border-radius: 8px; + border: 1px solid #3f4f69; + padding: 0.6em 0.8em; + font-size: 0.95em; + font-family: Consolas, "Courier New", monospace; + color: #e8ecf1; + background-color: #122033; +} + +.env-grid { + display: flex; + flex-direction: column; + gap: 6px; +} + +.env-grid-row { + display: grid; + grid-template-columns: 1.2fr 1.4fr 1fr 1.4fr auto; + gap: 6px; + align-items: center; +} + +.env-grid-header { + font-size: 0.85em; + color: #93c5fd; +} + +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.chip { + border: 1px solid #334155; + border-radius: 999px; + padding: 2px 8px; + font-size: 0.8em; + background: #0f172a; + color: #dbeafe; +} + +.chip button { + margin-left: 6px; + background: transparent; + border: none; + color: #fca5a5; + cursor: pointer; + padding: 0; +} + +select { + min-width: 180px; +} + button { cursor: pointer; white-space: nowrap; @@ -120,3 +214,27 @@ code { width: 100%; } } + +.overlay { + position: fixed; + inset: 0; + background: rgba(2, 6, 23, 0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; +} + +.overlay.hidden { + display: none; +} + +.dialog { + width: min(760px, 92vw); + max-height: 82vh; + overflow: auto; + border: 1px solid #334155; + border-radius: 12px; + padding: 12px; + background: #0b1220; +}