diff --git a/.agents/skills/mobile-parity/SKILL.md b/.agents/skills/mobile-parity/SKILL.md index cb8c54cff3..e0f58ad8c0 100644 --- a/.agents/skills/mobile-parity/SKILL.md +++ b/.agents/skills/mobile-parity/SKILL.md @@ -65,6 +65,7 @@ When a task changes composition, navigation, overlays, touch behavior, scrolling - Reuse domain hooks, state, view-model derivation, filtering/selection, and action handlers across viewports; keep responsive wrappers focused on presentation. Branch composition when the desktop interaction model depends on width or a fine pointer. Do not mount a heavyweight desktop workbench and merely hide or squeeze it on phones. - Use `useResponsiveBreakpoint` and existing `@kandev/ui` or mobile primitives. Reuse current Dropdown/ContextMenu primitives for contextual actions; use `Drawer` or an existing picker shell for structured phone navigation and choices. Use `useTouchDrawer` when a hover disclosure needs a coarse-pointer alternative. - Keep coarse-pointer and mobile touch targets large enough for touch use, generally at least 44px in the active dimension. This is an active hit-area rule, not a universal desktop visual-size rule: fine-pointer desktop controls should retain the surrounding design-system density, and touch-sized classes such as `h-11` must not become the shared desktop button size. + - When one component serves both pointer modes, keep its fine-pointer size as the base class and add the 44px size only behind a coarse-pointer or phone variant. An unqualified `h-11` or `min-h-11` on a desktop-visible shared action is a sizing bug. - Use dynamic viewport units and an explicit internal scroll region for viewport-bound/full-height or potentially overflowing surfaces. Ensure bottom-fixed controls and tall drawers clear safe-area insets; short drawers can retain the shared primitive's intrinsic sizing. Keep document-level horizontal overflow at zero. - If mobile substitutes an unsupported desktop view, derive an effective mobile view without overwriting the user's saved desktop preference. - Use semantic controls, visible labels or accessible names, focus return, and existing design-system components. diff --git a/apps/backend/internal/agent/runtime/agentctl/workspace_state.go b/apps/backend/internal/agent/runtime/agentctl/workspace_state.go index 488a67b0f3..29c09c5ec8 100644 --- a/apps/backend/internal/agent/runtime/agentctl/workspace_state.go +++ b/apps/backend/internal/agent/runtime/agentctl/workspace_state.go @@ -41,3 +41,15 @@ func (c *Client) SetWorkspacePollMode(ctx context.Context, mode string) error { } return nil } + +// RefreshWorkspace requests one file and Git scan from agentctl. The +// turn_complete trigger is used by lifecycle completion; manual_refresh is +// the default explicit retry from a visible workspace surface. +func (c *Client) RefreshWorkspace(ctx context.Context, trigger string) error { + if trigger == "" { + trigger = "manual_refresh" + } + return c.updateWorkspace(ctx, "/api/v1/workspace/refresh", "refresh", struct { + Trigger string `json:"trigger"` + }{Trigger: trigger}) +} diff --git a/apps/backend/internal/agent/runtime/agentctl/workspace_state_test.go b/apps/backend/internal/agent/runtime/agentctl/workspace_state_test.go index 5af7078e8f..a9c48ab535 100644 --- a/apps/backend/internal/agent/runtime/agentctl/workspace_state_test.go +++ b/apps/backend/internal/agent/runtime/agentctl/workspace_state_test.go @@ -43,7 +43,7 @@ func TestSetWorkspacePollMode_PostsExpectedPayload(t *testing.T) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"mode":"slow"}`)) })) - defer srv.Close() + t.Cleanup(srv.Close) c := newHTTPOnlyClient(srv.URL) @@ -65,12 +65,45 @@ func TestSetWorkspacePollMode_PostsExpectedPayload(t *testing.T) { } } +func TestRefreshWorkspace_PostsExpectedTrigger(t *testing.T) { + var got struct { + Path string + Method string + Trigger string + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got.Path = r.URL.Path + got.Method = r.Method + var body struct { + Trigger string `json:"trigger"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + got.Trigger = body.Trigger + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + c := newHTTPOnlyClient(srv.URL) + if err := c.RefreshWorkspace(context.Background(), "turn_complete"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Path != "/api/v1/workspace/refresh" { + t.Errorf("path = %q, want /api/v1/workspace/refresh", got.Path) + } + if got.Method != http.MethodPost { + t.Errorf("method = %q, want POST", got.Method) + } + if got.Trigger != "turn_complete" { + t.Errorf("trigger = %q, want turn_complete", got.Trigger) + } +} + func TestSetWorkspacePollMode_ReturnsErrorOnNon2xx(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"error":"bad mode"}`)) })) - defer srv.Close() + t.Cleanup(srv.Close) c := newHTTPOnlyClient(srv.URL) @@ -89,7 +122,7 @@ func TestSetWorkspacePollMode_HonoursContextCancellation(t *testing.T) { time.Sleep(500 * time.Millisecond) w.WriteHeader(http.StatusOK) })) - defer srv.Close() + t.Cleanup(srv.Close) c := newHTTPOnlyClient(srv.URL) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_events.go b/apps/backend/internal/agent/runtime/lifecycle/manager_events.go index c587f6e63d..c6a8cbe976 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_events.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_events.go @@ -351,7 +351,7 @@ func (m *Manager) handleCompleteEvent(execution *AgentExecution, event *agentctl evidence := execution.promptAttemptEvidenceSnapshot() failureEvidence = &evidence } - m.releaseActivity(executionActivityKey(execution.ID)) + m.finishExecutionWorkspaceActivity(execution, "turn_complete") execution.markAgentActivity() diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go index ed5836fb48..dc4a5e6022 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go @@ -111,9 +111,13 @@ func (m *Manager) PromptAgentWithDispatchCallback(ctx context.Context, execution } key := executionActivityKey(executionID) m.trackActivity(key, lease) + m.setRuntimeInterest(execution.SessionID, true) result, err := m.sessionManager.SendPromptWithDispatchCallback(ctx, execution, prompt, true, attachments, dispatchOnly, onDispatched) if err != nil || !dispatchOnly { m.releaseActivity(key) + if err != nil { + m.setRuntimeInterest(execution.SessionID, false) + } } return result, err } @@ -133,9 +137,13 @@ func (m *Manager) SteerAgentWithDispatchCallback(ctx context.Context, executionI } key := executionActivityKey(executionID) m.trackActivity(key, lease) + m.setRuntimeInterest(execution.SessionID, true) result, err := m.sessionManager.SendPromptSteerWithDispatchCallback(ctx, execution, prompt, true, attachments, dispatchOnly, onDispatched) if err != nil || !dispatchOnly { m.releaseActivity(key) + if err != nil { + m.setRuntimeInterest(execution.SessionID, false) + } } return result, err } @@ -1763,8 +1771,15 @@ func (m *Manager) MarkReady(executionID string) error { // // Publishes events.AgentBootReady. Returns error if execution not found. func (m *Manager) MarkBootReady(executionID string) error { + execution, exists := m.executionStore.Get(executionID) + if exists { + m.finalWorkspaceRefresh(execution, "startup_grace") + } err := m.markReadyEventWithContext(context.Background(), executionID, events.AgentBootReady, false) if err == nil { + if exists { + m.setRuntimeInterest(execution.SessionID, false) + } m.releaseActivity(executionActivityKey(executionID)) } return err diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go index 51b04b3959..f9899c45f4 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go @@ -312,7 +312,6 @@ func (m *Manager) cleanupStaleExecution(ctx context.Context, execution *AgentExe execution.remoteInstanceLifecycleMu.Lock() defer execution.remoteInstanceLifecycleMu.Unlock() execution.agentctlLifecycleMu.Lock() - defer execution.agentctlLifecycleMu.Unlock() sessionID := execution.SessionID m.logger.Info("cleaning up stale agent execution", @@ -324,6 +323,7 @@ func (m *Manager) cleanupStaleExecution(ctx context.Context, execution *AgentExe // execution is created for the same session, causing git polling on deleted worktrees. // This is idempotent — returns success if the instance is already gone. if err := m.stopAgentViaBackend(ctx, execution.ID, execution, stopReasonStaleExecutionCleanup, false, false); err != nil { + execution.agentctlLifecycleMu.Unlock() return fmt.Errorf("stop stale runtime %s: %w", execution.ID, err) } @@ -335,6 +335,8 @@ func (m *Manager) cleanupStaleExecution(ctx context.Context, execution *AgentExe if client := execution.currentAgentCtlClient(); client != nil { // protected by agentctlLifecycleMu client.Close() } + execution.detachAgentctlClient() + execution.agentctlLifecycleMu.Unlock() // Remove from execution store m.RemoveExecution(execution.ID) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_test.go index c6395be026..f863884dde 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_test.go @@ -6,6 +6,7 @@ import ( "errors" "net" "net/http" + "net/http/httptest" "testing" "time" @@ -213,6 +214,58 @@ func TestManager_CleanupStaleExecution_SkipsStopWhenNoRuntime(t *testing.T) { } } +func TestManager_CleanupStaleExecution_ReleasesAgentctlLockBeforeRemove(t *testing.T) { + mgr := newTestManager(t) + modes := make(chan string, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/workspace/poll-mode" { + modes <- r.Method + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + port := srv.Listener.Addr().(*net.TCPAddr).Port + client := agentctl.NewClient("127.0.0.1", port, newTestLogger()) + t.Cleanup(func() { client.Close() }) + execution := &AgentExecution{ + ID: "exec-with-poll-client", + SessionID: "session-with-poll-client", + WorkspacePath: "/tmp/workspace-with-poll-client", + agentctl: client, + } + if err := mgr.executionStore.Add(execution); err != nil { + t.Fatalf("add execution: %v", err) + } + + // Runtime interest makes RemoveExecution publish the transition to paused. + // The cleanup path must detach the client before that transition attempts to + // acquire the client read lock. + mgr.pollAggregator.HandleRuntimeInterest(execution.SessionID, true) + select { + case <-modes: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for initial poll-mode push") + } + + done := make(chan error, 1) + go func() { + done <- mgr.CleanupStaleExecutionBySessionID(context.Background(), execution.SessionID) + }() + select { + case err := <-done: + if err != nil { + t.Fatalf("cleanup stale execution: %v", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("cleanup stale execution blocked while removing execution") + } + + if _, found := mgr.GetExecutionBySessionID(execution.SessionID); found { + t.Fatal("expected stale execution to be removed") + } +} + func TestManager_CleanupStaleExecution_PreservesTrackingOnStopError(t *testing.T) { log := newTestRegistryLogger() reg := newTestRegistry() diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_subscription.go b/apps/backend/internal/agent/runtime/lifecycle/manager_subscription.go index 8c1aa28741..39095381c4 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_subscription.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_subscription.go @@ -74,6 +74,9 @@ type workspacePollAggregator struct { // Last-write-wins queue: pendingPush + pushInFlight serialize per-workspace HTTP pushes so order matches enqueue. pendingPush map[string]workspacePushTarget pushInFlight map[string]bool + // dirtyPush records a desired mode whose last RPC failed. The next + // contribution event retries it even when the effective mode is unchanged. + dirtyPush map[string]bool } // workspacePushTarget bundles the queued mode and execution resolved at enqueue time. @@ -95,6 +98,7 @@ func newWorkspacePollAggregator(mgr *Manager) *workspacePollAggregator { runtimeWorkspaceBySession: make(map[string]string), pendingPush: make(map[string]workspacePushTarget), pushInFlight: make(map[string]bool), + dirtyPush: make(map[string]bool), } } @@ -240,16 +244,18 @@ func (a *workspacePollAggregator) effectiveModeLocked(workspacePath string) Work // to retry a mode that may have been sent before agentctl accepted requests. func (a *workspacePollAggregator) applyEffectiveModeLocked(workspacePath string, effective WorkspacePollMode, force bool) bool { prev, hadPrev := a.lastPushed[workspacePath] - shouldPush := force || !hadPrev || prev != effective - if !force && !hadPrev && effective == WorkspacePollModePaused { + shouldPush := force || a.dirtyPush[workspacePath] || !hadPrev || prev != effective + if !force && !hadPrev && effective == WorkspacePollModePaused && !a.dirtyPush[workspacePath] { return false } if effective == WorkspacePollModePaused { delete(a.lastPushed, workspacePath) - } else { - a.lastPushed[workspacePath] = effective + // Keep the mode map bounded, but still report a transition from an + // already-managed mode so pushAsync delivers the paused state. + return shouldPush } - return shouldPush && effective != WorkspacePollModePaused + a.lastPushed[workspacePath] = effective + return shouldPush } // recordAndCompute updates the per-session mode for the given workspace and @@ -325,11 +331,18 @@ func (a *workspacePollAggregator) pushLoop(workspacePath string) { cancel() releaseClient() if err != nil { + a.mu.Lock() + a.dirtyPush[workspacePath] = true + a.mu.Unlock() a.mgr.logger.Warn("failed to push workspace poll mode", zap.String("workspace", workspacePath), zap.String("mode", string(target.mode)), zap.Error(err)) + continue } + a.mu.Lock() + delete(a.dirtyPush, workspacePath) + a.mu.Unlock() } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_subscription_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_subscription_test.go index 005e6290e7..d885a2fcaf 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_subscription_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_subscription_test.go @@ -2,6 +2,7 @@ package lifecycle import ( "encoding/json" + "net" "net/http" "net/http/httptest" "strconv" @@ -203,6 +204,128 @@ func TestAggregator_RuntimeInterestRemovalYieldsPaused(t *testing.T) { } } +func TestAggregator_RuntimeInterestRemovalDeliversPaused(t *testing.T) { + modes := make(chan string, 4) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/workspace/poll-mode" { + w.WriteHeader(http.StatusNotFound) + return + } + var body struct { + Mode string `json:"mode"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + modes <- body.Mode + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + mgr := newTestManagerForAggregator(t) + port := srv.Listener.Addr().(*net.TCPAddr).Port + client := agentctl.NewClient("127.0.0.1", port, newTestLogger()) + t.Cleanup(func() { client.Close() }) + execution := &AgentExecution{ + ID: "exec-s1", SessionID: "s1", WorkspacePath: "/tmp/ws1", agentctl: client, + } + if err := mgr.executionStore.Add(execution); err != nil { + t.Fatalf("add execution: %v", err) + } + + mgr.pollAggregator.HandleRuntimeInterest("s1", true) + select { + case got := <-modes: + if got != string(WorkspacePollModeSlow) { + t.Fatalf("initial runtime mode = %q, want slow", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for runtime poll mode") + } + + mgr.pollAggregator.HandleRuntimeInterest("s1", false) + select { + case got := <-modes: + if got != string(WorkspacePollModePaused) { + t.Fatalf("final runtime mode = %q, want paused", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for paused poll mode") + } +} + +func TestAggregator_FailedPausedPushIsRetriedOnUnchangedContribution(t *testing.T) { + var ( + mu sync.Mutex + pausedCalls int + modes = make(chan string, 4) + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Mode string `json:"mode"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + modes <- body.Mode + if body.Mode == string(WorkspacePollModePaused) { + mu.Lock() + pausedCalls++ + call := pausedCalls + mu.Unlock() + if call == 1 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + mgr := newTestManagerForAggregator(t) + addExecutionWithClient(t, mgr, "s1", "/tmp/ws1", srv.URL) + + mgr.pollAggregator.HandleRuntimeInterest("s1", true) + if got := <-modes; got != string(WorkspacePollModeSlow) { + t.Fatalf("initial runtime mode = %q, want slow", got) + } + mgr.pollAggregator.HandleRuntimeInterest("s1", false) + if got := <-modes; got != string(WorkspacePollModePaused) { + t.Fatalf("first paused mode = %q, want paused", got) + } + + // The first paused RPC failed. A repeated paused contribution must not be + // suppressed just because the desired mode was already recorded locally. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + mgr.pollAggregator.mu.Lock() + dirty := mgr.pollAggregator.dirtyPush["/tmp/ws1"] + mgr.pollAggregator.mu.Unlock() + if dirty { + break + } + time.Sleep(5 * time.Millisecond) + } + mgr.pollAggregator.mu.Lock() + dirty := mgr.pollAggregator.dirtyPush["/tmp/ws1"] + mgr.pollAggregator.mu.Unlock() + if !dirty { + t.Fatal("first paused RPC did not record a dirty retry state") + } + mgr.pollAggregator.HandleRuntimeInterest("s1", false) + if got := <-modes; got != string(WorkspacePollModePaused) { + t.Fatalf("retried paused mode = %q, want paused", got) + } + + mu.Lock() + defer mu.Unlock() + if pausedCalls != 2 { + t.Fatalf("paused RPC calls = %d, want 2", pausedCalls) + } +} + func TestAggregator_FlushSessionMode_PushesRuntimeBaselineWithoutCachedUIState(t *testing.T) { mgr := newTestManagerForAggregator(t) addExecution(mgr, "s1", "/tmp/ws1") @@ -412,7 +535,7 @@ func TestAggregator_PushAsync_LastWriteWinsUnderRace(t *testing.T) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{}`)) })) - defer srv.Close() + t.Cleanup(srv.Close) mgr := newTestManagerForAggregator(t) addExecutionWithClient(t, mgr, "s1", "/tmp/ws1", srv.URL) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_refresh.go b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_refresh.go new file mode 100644 index 0000000000..a2bc0279ff --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_refresh.go @@ -0,0 +1,50 @@ +package lifecycle + +import ( + "context" + "time" + + "go.uber.org/zap" +) + +// finalWorkspaceRefreshTimeout bounds the synchronous scan at a turn +// boundary. It is a variable so lifecycle tests can keep the boundary short. +var finalWorkspaceRefreshTimeout = 10 * time.Second + +// finalWorkspaceRefresh completes the workspace side of a turn before the +// lifecycle manager releases runtime interest. That ordering leaves the +// cached Git/file state current when the tracker transitions to idle polling. +func (m *Manager) finalWorkspaceRefresh(execution *AgentExecution, trigger string) { + if execution == nil { + return + } + client, releaseClient := execution.AcquireAgentCtlClient() + if client == nil { + return + } + defer releaseClient() + timeout := finalWorkspaceRefreshTimeout + if timeout <= 0 { + timeout = 10 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + if err := client.RefreshWorkspace(ctx, trigger); err != nil && m.logger != nil { + m.logger.Warn("final workspace refresh failed", + zap.String("operation", "workspace.final_refresh"), + zap.String("trigger", trigger), + zap.String("execution_id", execution.ID), + zap.String("task_id", execution.TaskID), + zap.String("session_id", execution.SessionID), + zap.Error(err)) + } +} + +func (m *Manager) finishExecutionWorkspaceActivity(execution *AgentExecution, trigger string) { + if execution == nil { + return + } + m.finalWorkspaceRefresh(execution, trigger) + m.releaseActivity(executionActivityKey(execution.ID)) + m.setRuntimeInterest(execution.SessionID, false) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_refresh_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_refresh_test.go new file mode 100644 index 0000000000..af99505604 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_refresh_test.go @@ -0,0 +1,79 @@ +package lifecycle + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" +) + +func TestFinishExecutionWorkspaceActivityRefreshesBeforePausingRuntime(t *testing.T) { + events := make(chan string, 4) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/workspace/poll-mode": + var body struct { + Mode string `json:"mode"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + events <- r.URL.Path + ":" + body.Mode + case "/api/v1/workspace/refresh": + events <- r.URL.Path + default: + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + mgr := newTestManagerForAggregator(t) + port := srv.Listener.Addr().(*net.TCPAddr).Port + client := agentctl.NewClient("127.0.0.1", port, newTestLogger()) + t.Cleanup(func() { client.Close() }) + execution := &AgentExecution{ + ID: "exec-s1", SessionID: "s1", TaskID: "task-1", WorkspacePath: "/tmp/ws1", agentctl: client, + } + if err := mgr.executionStore.Add(execution); err != nil { + t.Fatalf("add execution: %v", err) + } + + mgr.setRuntimeInterest("s1", true) + select { + case got := <-events: + if got != "/api/v1/workspace/poll-mode:slow" { + t.Fatalf("initial runtime event = %q, want slow poll mode", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for initial runtime poll mode") + } + + mgr.finishExecutionWorkspaceActivity(execution, "turn_complete") + first := eventsWithTimeout(t, events) + second := eventsWithTimeout(t, events) + if first != "/api/v1/workspace/refresh" { + t.Fatalf("first completion event = %q, want final refresh", first) + } + if second != "/api/v1/workspace/poll-mode:paused" { + t.Fatalf("second completion event = %q, want paused poll mode", second) + } +} + +func eventsWithTimeout(t *testing.T, events <-chan string) string { + t.Helper() + timer := time.NewTimer(2 * time.Second) + defer timer.Stop() + select { + case event := <-events: + return event + case <-timer.C: + return "" + } +} diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 3fd3be1e46..3fb15132b5 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -116,6 +116,7 @@ func (s *Server) setupRoutes() { // Workspace state (poll mode driven by gateway focus signal) api.POST("/workspace/poll-mode", s.handleSetPollMode) + api.POST("/workspace/refresh", s.handleRefreshWorkspace) // Workspace rescan: triggered by the kandev backend after a new // sibling worktree appears on disk (multi-branch add_branch flow). diff --git a/apps/backend/internal/agentctl/server/api/workspace_state.go b/apps/backend/internal/agentctl/server/api/workspace_state.go index 0397f77dcf..f277923cf0 100644 --- a/apps/backend/internal/agentctl/server/api/workspace_state.go +++ b/apps/backend/internal/agentctl/server/api/workspace_state.go @@ -13,6 +13,11 @@ type SetPollModeRequest struct { Mode string `json:"mode"` } +// RefreshWorkspaceRequest is the body for POST /api/v1/workspace/refresh. +type RefreshWorkspaceRequest struct { + Trigger string `json:"trigger,omitempty"` +} + // handleSetPollMode updates the workspace tracker's poll mode based on the // gateway's view of UI subscription/focus state for sessions in this workspace. // @@ -50,3 +55,20 @@ func (s *Server) handleSetPollMode(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"mode": req.Mode}) } + +// handleRefreshWorkspace performs one explicit file and Git scan. Lifecycle +// callers use the turn_complete trigger; browser callers use the default +// manual_refresh trigger, which also retries a tracker paused by denial. +func (s *Server) handleRefreshWorkspace(c *gin.Context) { + var req RefreshWorkspaceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()}) + return + } + trigger := req.Trigger + if trigger == "" { + trigger = "manual_refresh" + } + s.procMgr.RefreshWorkspace(c.Request.Context(), trigger) + c.JSON(http.StatusOK, gin.H{"trigger": process.NormalizeWorkspaceTrigger(trigger)}) +} diff --git a/apps/backend/internal/agentctl/server/api/workspace_state_test.go b/apps/backend/internal/agentctl/server/api/workspace_state_test.go index b5bc897ae9..6313d7b7ee 100644 --- a/apps/backend/internal/agentctl/server/api/workspace_state_test.go +++ b/apps/backend/internal/agentctl/server/api/workspace_state_test.go @@ -84,3 +84,52 @@ func TestHandleSetPollMode_EmptyMode(t *testing.T) { t.Errorf("expected 400 for empty mode, got %d", w.Code) } } + +func TestHandleRefreshWorkspace_AcceptsTrigger(t *testing.T) { + cases := []struct { + name string + trigger string + want string + }{ + {name: "explicit", trigger: "turn_complete", want: "turn_complete"}, + {name: "empty", trigger: "", want: "manual_refresh"}, + {name: "unsupported", trigger: "background_poll", want: "manual_refresh"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := newTestServer(t) + body, _ := json.Marshal(RefreshWorkspaceRequest{Trigger: tc.trigger}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/refresh", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + var response struct { + Trigger string `json:"trigger"` + } + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Trigger != tc.want { + t.Fatalf("response trigger = %q, want %q", response.Trigger, tc.want) + } + }) + } +} + +func TestHandleRefreshWorkspace_RejectsInvalidBody(t *testing.T) { + s := newTestServer(t) + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/refresh", bytes.NewReader([]byte(`not json`))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid body, got %d", w.Code) + } +} diff --git a/apps/backend/internal/agentctl/server/process/env_hermetic_test.go b/apps/backend/internal/agentctl/server/process/env_hermetic_test.go index f2bbb9ae79..33baf6a36d 100644 --- a/apps/backend/internal/agentctl/server/process/env_hermetic_test.go +++ b/apps/backend/internal/agentctl/server/process/env_hermetic_test.go @@ -28,6 +28,9 @@ var ambientEnvNotScrubbed = []string{ // files are parsed by the guard below regardless of build tags (only one // compiles per platform), so both names must stay exempt. "COMSPEC", + // KANDEV_DESKTOP_RUNTIME describes the launch policy inherited by the + // agentctl child. Keep it intact so diagnostics reflect the real runtime. + "KANDEV_DESKTOP_RUNTIME", } // clearAmbientGitLabEnv removes the inherited GitLab host/token values so diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 3c0be18ae7..2c5ec5bae1 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -1009,6 +1009,20 @@ func (m *Manager) SetWorkspacePollMode(ctx context.Context, mode PollMode) { }() } +// RefreshWorkspace performs one file and Git scan across the root and all +// repository trackers. Lifecycle uses this at turn completion; the manual +// refresh surface uses it as an explicit access retry. +func (m *Manager) RefreshWorkspace(ctx context.Context, trigger string) { + trigger = NormalizeWorkspaceTrigger(trigger) + root, trackers := m.snapshotTrackers() + if root != nil { + root.RefreshWorkspace(ctx, trigger) + } + for _, tracker := range trackers { + tracker.RefreshWorkspace(ctx, trigger) + } +} + // GitOperator returns the git operator for git operations against the // workspace root. Lazy-initialized. func (m *Manager) GitOperator() *GitOperator { diff --git a/apps/backend/internal/agentctl/server/process/manager_submodules.go b/apps/backend/internal/agentctl/server/process/manager_submodules.go index a9d73820c1..73b3b4be9b 100644 --- a/apps/backend/internal/agentctl/server/process/manager_submodules.go +++ b/apps/backend/internal/agentctl/server/process/manager_submodules.go @@ -78,6 +78,9 @@ func (m *Manager) buildWorkspaceTrackerGraph( } func (m *Manager) configureTracker(tracker *WorkspaceTracker, repositoryName string, roots []string) { + if m.cfg != nil { + tracker.SetDiagnosticIdentity(m.cfg.TaskID, m.cfg.SessionID) + } tracker.SetGitEnvironment(m.trackerGitEnvironment()) tracker.SetAllowedSourceRoots(roots) if !tracker.IsSubmodule() { diff --git a/apps/backend/internal/agentctl/server/process/manager_test.go b/apps/backend/internal/agentctl/server/process/manager_test.go index c62e4e5f75..72ce50aa2e 100644 --- a/apps/backend/internal/agentctl/server/process/manager_test.go +++ b/apps/backend/internal/agentctl/server/process/manager_test.go @@ -23,6 +23,45 @@ import ( "github.com/stretchr/testify/require" ) +func TestManagerRefreshWorkspaceNormalizesTriggerForEveryTracker(t *testing.T) { + log := newTestLogger(t) + rootDir, cleanupRoot := setupTestRepo(t) + t.Cleanup(cleanupRoot) + repoDir, cleanupRepo := setupTestRepo(t) + t.Cleanup(cleanupRepo) + thirdDir, cleanupThird := setupTestRepo(t) + t.Cleanup(cleanupThird) + + root := NewWorkspaceTracker(rootDir, log) + firstRepo := NewWorkspaceTrackerForRepo(repoDir, "first", log) + secondRepo := NewWorkspaceTrackerForRepo(thirdDir, "second", log) + t.Cleanup(root.Stop) + t.Cleanup(firstRepo.Stop) + t.Cleanup(secondRepo.Stop) + + trackers := []*WorkspaceTracker{root, firstRepo, secondRepo} + for _, tracker := range trackers { + tracker.SetPollMode(PollModePaused) + tracker.accessDenied.Store(true) + } + + mgr := &Manager{ + logger: log, + workspaceTracker: root, + repoTrackers: []*WorkspaceTracker{firstRepo, secondRepo}, + } + mgr.RefreshWorkspace(context.Background(), "unsupported_trigger") + + for i, tracker := range trackers { + if got := tracker.GetPollMode(); got != PollModeFast { + t.Errorf("tracker %d poll mode = %q, want fast", i, got) + } + if tracker.accessDenied.Load() { + t.Errorf("tracker %d retained access denial after manual refresh", i) + } + } +} + func TestPublishMCPAttachmentPublishesWithoutBlocking(t *testing.T) { m := &Manager{ updatesCh: make(chan adapter.AgentEvent, 1), diff --git a/apps/backend/internal/agentctl/server/process/workspace_diagnostics.go b/apps/backend/internal/agentctl/server/process/workspace_diagnostics.go new file mode 100644 index 0000000000..8c241f3013 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/workspace_diagnostics.go @@ -0,0 +1,95 @@ +package process + +import ( + "context" + + "github.com/kandev/kandev/internal/common/fsdiagnostics" +) + +type workspaceTriggerContextKey struct{} + +const ( + workspaceManualRefreshTrigger = "manual_refresh" + workspaceUserSelectTrigger = "user_select" +) + +// NormalizeWorkspaceTrigger keeps refresh diagnostics within the set of +// lifecycle and user-operation causes understood by the tracker. +func NormalizeWorkspaceTrigger(trigger string) string { + switch trigger { + case workspaceManualRefreshTrigger, workspaceUserSelectTrigger, "turn_complete", "startup_grace": + return trigger + default: + return workspaceManualRefreshTrigger + } +} + +func withWorkspaceTrigger(ctx context.Context, trigger string) context.Context { + return context.WithValue(ctx, workspaceTriggerContextKey{}, trigger) +} + +func workspaceTrigger(ctx context.Context, fallback string) string { + if trigger, ok := ctx.Value(workspaceTriggerContextKey{}).(string); ok && trigger != "" { + return trigger + } + return fallback +} + +// SetDiagnosticIdentity supplies the task and session that own this tracker. +// The workspace path remains the canonical operation target because agentctl +// instances do not receive a separate workspace UUID. +func (wt *WorkspaceTracker) SetDiagnosticIdentity(taskID, sessionID string) { + wt.diagnosticMu.Lock() + wt.diagnosticTaskID = taskID + wt.diagnosticSessionID = sessionID + wt.diagnosticMu.Unlock() +} + +func (wt *WorkspaceTracker) diagnosticContext(operation, trigger string) fsdiagnostics.Context { + wt.diagnosticMu.RLock() + taskID := wt.diagnosticTaskID + sessionID := wt.diagnosticSessionID + wt.diagnosticMu.RUnlock() + return fsdiagnostics.Context{ + Operation: operation, + Target: wt.workDir, + Trigger: trigger, + Runtime: wt.runtimeMode, + TaskID: taskID, + SessionID: sessionID, + PollMode: string(wt.GetPollMode()), + } +} + +func (wt *WorkspaceTracker) recordFilesystemFailure(operation, trigger string, err error) { + if wt.logger == nil || err == nil { + return + } + operationContext := wt.diagnosticContext(operation, trigger) + if fsdiagnostics.IsAccessDenied(err) { + wt.filesystemWarnings.Warn(wt.logger.Zap(), "filesystem.access_denied", operationContext, err) + wt.pauseAfterAccessDenied(operationContext, err) + return + } + wt.logger.Warn("filesystem operation failed", operationContext.Fields(err)...) +} + +func (wt *WorkspaceTracker) pauseAfterAccessDenied(operationContext fsdiagnostics.Context, err error) { + if !wt.accessDenied.CompareAndSwap(false, true) { + return + } + wt.pollModeMu.Lock() + previous := wt.pollMode + wt.pollMode = PollModePaused + wt.pollModeMu.Unlock() + if previous != PollModePaused { + wt.wakePollLoops() + } + wt.logger.Warn("workspace.poll_paused_after_denial", operationContext.Fields(err)...) +} + +func (wt *WorkspaceTracker) clearAccessDeniedForUserOperation(trigger string) { + if trigger == workspaceManualRefreshTrigger || trigger == workspaceUserSelectTrigger { + wt.accessDenied.Store(false) + } +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_diagnostics_test.go b/apps/backend/internal/agentctl/server/process/workspace_diagnostics_test.go index bb17f0e0ad..db4eafb9a6 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_diagnostics_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_diagnostics_test.go @@ -1,149 +1,59 @@ package process import ( - "context" + "errors" "testing" - "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" ) -// TestWorkspaceTracker_GitPollTickStats_AccumulatesAcrossTicks pins the -// accumulation contract behind the diagnostic agentctl_git_poll_ms metric: -// GitPollTickStats must report zero before any tick has completed, and must -// report a growing count with a non-zero total after ticks run, regardless -// of whether individual ticks succeed. -func TestWorkspaceTracker_GitPollTickStats_AccumulatesAcrossTicks(t *testing.T) { - repoDir, cleanup := setupTestRepo(t) - t.Cleanup(cleanup) +func TestWorkspaceTrackerAccessDeniedPausesUntilVisibleRetry(t *testing.T) { + wt := newGraceTestTracker(t, graceNeverFires) + core, logs := observer.New(zapcore.WarnLevel) + log, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("NewFromZap: %v", err) + } + wt.logger = log + wt.SetDiagnosticIdentity("task-1", "session-1") - log := newTestLogger(t) - wt := NewWorkspaceTracker(repoDir, log) - ctx := context.Background() - - if count, total := wt.GitPollTickStats(); count != 0 || total != 0 { - t.Fatalf("GitPollTickStats before any tick = (%d, %d), want (0, 0)", count, total) + denied := errors.New("permission denied") + for range 3 { + wt.recordFilesystemFailure("workspace.git_poll", "poll", denied) + } + if got := wt.GetPollMode(); got != PollModePaused { + t.Fatalf("poll mode after denial = %v, want paused", got) + } + if !wt.accessDenied.Load() { + t.Fatal("access denial was not retained") + } + if got := logs.Len(); got != 2 { + t.Fatalf("warning count after repeated denial = %d, want initial denial and pause", got) } - var consecutiveFailures int - if stop := wt.gitPollTick(ctx, &consecutiveFailures); stop { - t.Fatal("gitPollTick unexpectedly signalled stop") + wt.SetPollMode(PollModeSlow) + if got := wt.GetPollMode(); got != PollModePaused { + t.Fatalf("poll mode after slow background update = %v, want paused", got) } - - firstCount, firstTotal := wt.GitPollTickStats() - if firstCount != 1 { - t.Fatalf("count after one tick = %d, want 1", firstCount) + wt.SetPollMode(PollModeFast) + if got := wt.GetPollMode(); got != PollModeFast { + t.Fatalf("poll mode after focused retry = %v, want fast", got) } - if firstTotal <= 0 { - t.Fatalf("totalNanos after one tick = %d, want > 0", firstTotal) - } - - if stop := wt.gitPollTick(ctx, &consecutiveFailures); stop { - t.Fatal("gitPollTick unexpectedly signalled stop") - } - - secondCount, secondTotal := wt.GitPollTickStats() - if secondCount != 2 { - t.Fatalf("count after two ticks = %d, want 2", secondCount) - } - if secondTotal <= firstTotal { - t.Fatalf("totalNanos after two ticks = %d, want > %d", secondTotal, firstTotal) + if wt.accessDenied.Load() { + t.Fatal("focused retry did not clear access denial") } } -// TestManager_GitPollStats_AggregatesRootTracker exercises the -// process.Manager-level aggregator that api.handleSystemMetrics reads. It -// must report unavailable (count 0) before any tick, and a plausible mean -// once the root tracker has recorded ticks. -func TestManager_GitPollStats_AggregatesRootTracker(t *testing.T) { - repoDir, cleanup := setupTestRepo(t) - t.Cleanup(cleanup) +func TestWorkspaceTrackerCapturesRuntimeModeAtConstruction(t *testing.T) { + t.Setenv("KANDEV_DESKTOP_RUNTIME", "true") + tracker := NewWorkspaceTracker(t.TempDir(), newTestLogger(t)) + t.Cleanup(tracker.Stop) - log := newTestLogger(t) - cfg := &config.InstanceConfig{WorkDir: repoDir} - m := NewManager(cfg, log) - - if count, mean := m.GitPollStats(); count != 0 || mean != 0 { - t.Fatalf("GitPollStats before any tick = (%d, %f), want (0, 0)", count, mean) - } - - root, _ := m.snapshotTrackers() - if root == nil { - t.Fatal("expected a root workspace tracker") - } - var consecutiveFailures int - root.gitPollTick(context.Background(), &consecutiveFailures) - root.gitPollTick(context.Background(), &consecutiveFailures) - - count, mean := m.GitPollStats() - if count != 2 { - t.Fatalf("count after two ticks = %d, want 2", count) - } - if mean <= 0 { - t.Fatalf("mean after two ticks = %f, want > 0", mean) - } -} - -// TestManager_GitPollStats_AggregatesAcrossRepoTrackers exercises the -// multi-repo half of GitPollStats: with a second WorkspaceTracker added to -// m.repoTrackers (the shape a multi-repo task root produces), the aggregate -// count and mean must include both the root tracker's and the extra -// tracker's ticks, not just the root's. -func TestManager_GitPollStats_AggregatesAcrossRepoTrackers(t *testing.T) { - repoDir, cleanup := setupTestRepo(t) - t.Cleanup(cleanup) - repoDir2, cleanup2 := setupTestRepo(t) - t.Cleanup(cleanup2) - - log := newTestLogger(t) - cfg := &config.InstanceConfig{WorkDir: repoDir} - m := NewManager(cfg, log) - - root, _ := m.snapshotTrackers() - if root == nil { - t.Fatal("expected a root workspace tracker") - } - var consecutiveFailures int - root.gitPollTick(context.Background(), &consecutiveFailures) - root.gitPollTick(context.Background(), &consecutiveFailures) - - extra := NewWorkspaceTrackerForRepo(repoDir2, "extra-repo", log) - extra.gitPollTick(context.Background(), &consecutiveFailures) - - m.repoTrackersMu.Lock() - m.repoTrackers = append(m.repoTrackers, extra) - m.repoTrackersMu.Unlock() - - count, mean := m.GitPollStats() - if count != 3 { - t.Fatalf("count across root + extra tracker = %d, want 3 (2 from root + 1 from extra)", count) - } - if mean <= 0 { - t.Fatalf("mean across root + extra tracker = %f, want > 0", mean) - } -} - -func TestManager_MonitorPollStats_AggregatesWorkspaceMonitorTicks(t *testing.T) { - repoDir, cleanup := setupTestRepo(t) - t.Cleanup(cleanup) - - log := newTestLogger(t) - m := NewManager(&config.InstanceConfig{WorkDir: repoDir}, log) - root, _ := m.snapshotTrackers() - if root == nil { - t.Fatal("expected a root workspace tracker") - } - - lastState := workspaceState{} - var consecutiveFailures int - if stop := root.monitorTick(context.Background(), &lastState, &consecutiveFailures); stop { - t.Fatal("monitorTick unexpectedly signalled stop") - } - - count, mean := m.MonitorPollStats() - if count != 1 { - t.Fatalf("MonitorPollStats count = %d, want 1", count) - } - if mean <= 0 { - t.Fatalf("MonitorPollStats mean = %f, want > 0", mean) + t.Setenv("KANDEV_DESKTOP_RUNTIME", "false") + if got := tracker.diagnosticContext("workspace.file_monitor", "poll").Runtime; got != "desktop" { + t.Fatalf("diagnostic runtime = %q, want desktop", got) } } diff --git a/apps/backend/internal/agentctl/server/process/workspace_files.go b/apps/backend/internal/agentctl/server/process/workspace_files.go index 14330f07e6..b5b8f1d77f 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files.go @@ -58,9 +58,16 @@ func isRootOwnershipMarkerPath(path string) bool { // updateFiles updates the file listing func (wt *WorkspaceTracker) updateFiles(ctx context.Context) { - files, err := wt.getFileListClass(ctx, subproc.GitBackground) + wt.updateFilesClass(ctx, subproc.GitBackground) +} + +func (wt *WorkspaceTracker) updateFilesClass(ctx context.Context, class subproc.GitWorkClass) { + files, err := wt.getFileListClass(ctx, class) if err != nil { - wt.logger.Debug("failed to get file list", zap.Error(err)) + wt.recordFilesystemFailure("workspace.file_monitor", workspaceTrigger(ctx, "poll"), err) + return + } + if err := ctx.Err(); err != nil || (wt.cancelCtx != nil && wt.cancelCtx.Err() != nil) { return } @@ -129,6 +136,7 @@ func (wt *WorkspaceTracker) GetFileTree(reqPath string, depth int) (*types.FileT // Check if path exists info, err := os.Stat(safePath) if err != nil { + wt.recordFilesystemFailure("workspace.file_tree", "user_select", err) return nil, fmt.Errorf("path not found: %w", err) } @@ -158,6 +166,7 @@ func (wt *WorkspaceTracker) buildFileTreeNode(safePath, relPath string, info os. // Read directory contents entries, err := os.ReadDir(safePath) if err != nil { + wt.recordFilesystemFailure("workspace.file_tree", "user_select", err) return node, nil // Return node without children on error } diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_poll.go b/apps/backend/internal/agentctl/server/process/workspace_git_poll.go index 079e6ab9bf..393cd4d44e 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_poll.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_poll.go @@ -10,6 +10,7 @@ import ( "time" "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/common/fsdiagnostics" "github.com/kandev/kandev/internal/common/subproc" "go.uber.org/zap" ) @@ -141,6 +142,11 @@ func (wt *WorkspaceTracker) gitPollTick(ctx context.Context, consecutiveFailures // // Returns true if pollGitChanges should exit. func (wt *WorkspaceTracker) handleGitPollFailure(ctx context.Context, consecutiveFailures *int, cause error) bool { + if fsdiagnostics.IsAccessDenied(cause) { + wt.recordFilesystemFailure("workspace.git_poll", workspaceTrigger(ctx, "poll"), cause) + *consecutiveFailures = 0 + return false + } if errors.Is(cause, subproc.ErrAdmissionCanceled) { wt.logger.Debug("git poll admission canceled; preserving tracker liveness", zap.String("workDir", wt.workDir), @@ -150,6 +156,11 @@ func (wt *WorkspaceTracker) handleGitPollFailure(ctx context.Context, consecutiv } probeErr := wt.runPollingGit(ctx, "rev-parse", "--git-dir") + if fsdiagnostics.IsAccessDenied(probeErr) { + wt.recordFilesystemFailure("workspace.git_poll", workspaceTrigger(ctx, "poll"), probeErr) + *consecutiveFailures = 0 + return false + } if errors.Is(probeErr, subproc.ErrAdmissionCanceled) { wt.logger.Debug("git poll health probe admission canceled; preserving tracker liveness", zap.String("workDir", wt.workDir), diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_status.go b/apps/backend/internal/agentctl/server/process/workspace_git_status.go index db90b41f0a..e8dd97f3b5 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_status.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_status.go @@ -11,6 +11,7 @@ import ( "time" "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/common/fsdiagnostics" "github.com/kandev/kandev/internal/common/subproc" "go.uber.org/zap" ) @@ -45,7 +46,11 @@ func (wt *WorkspaceTracker) updateGitStatusClass(ctx context.Context, class subp if wt.isGitStatusCancellation(err) { wt.logger.Debug("updateGitStatus: getGitStatus canceled", zap.Error(err)) } else { - wt.logger.Warn("updateGitStatus: getGitStatus failed", zap.Error(err)) + if fsdiagnostics.IsAccessDenied(err) { + wt.recordFilesystemFailure("workspace.git_status", workspaceTrigger(ctx, "poll"), err) + } else { + wt.logger.Warn("updateGitStatus: getGitStatus failed", zap.Error(err)) + } } return false } @@ -98,6 +103,35 @@ func (wt *WorkspaceTracker) RefreshGitStatus(ctx context.Context) { wt.updateGitStatusClass(ctx, subproc.GitInteractive) } +// RefreshWorkspace performs one file and Git scan for a lifecycle boundary or +// an explicit user retry. The update lock keeps the two snapshots coherent +// with the normal polling loops. +func (wt *WorkspaceTracker) RefreshWorkspace(ctx context.Context, trigger string) { + if err := ctx.Err(); err != nil { + return + } + wt.clearAccessDeniedForUserOperation(trigger) + if trigger == workspaceManualRefreshTrigger || trigger == workspaceUserSelectTrigger { + wt.SetPollMode(PollModeFast) + } + ctx = withWorkspaceTrigger(ctx, trigger) + wt.updateMu.Lock() + defer wt.updateMu.Unlock() + wt.updateGitStatusClass(ctx, subproc.GitInteractive) + if err := ctx.Err(); err != nil { + return + } + wt.updateFilesClass(ctx, subproc.GitInteractive) + if err := ctx.Err(); err != nil { + return + } + wt.notifyWorkspaceStreamFileChange(types.FileChangeNotification{ + Timestamp: time.Now(), + RepositoryName: wt.repositoryName, + Operation: types.FileOpRefresh, + }) +} + // GetCurrentGitStatus returns the current cached git status. If no status has // been cached yet, it starts or joins a live observation without caching it. func (wt *WorkspaceTracker) GetCurrentGitStatus(ctx context.Context) (types.GitStatusUpdate, error) { diff --git a/apps/backend/internal/agentctl/server/process/workspace_monitor.go b/apps/backend/internal/agentctl/server/process/workspace_monitor.go index fd59427403..d979a8af7b 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_monitor.go +++ b/apps/backend/internal/agentctl/server/process/workspace_monitor.go @@ -176,6 +176,9 @@ func (wt *WorkspaceTracker) monitorTick(ctx context.Context, lastState *workspac currentState, err := wt.getWorkspaceState(ctx) if err != nil { + if wt.accessDenied.Load() { + return false + } if !wt.workDirExists() { wt.logger.Warn("work directory no longer exists, stopping workspace tracker", zap.String("workDir", wt.workDir)) @@ -240,8 +243,10 @@ func (wt *WorkspaceTracker) getWorkspaceState(ctx context.Context) (workspaceSta // to dirty files, we also include the mtime of each dirty file. out, stderr, err := wt.runPollingGitOutputWithStderr(ctx, "diff-files", "--name-only") if err != nil { - return state, fmt.Errorf("git diff-files in %s: %w (stderr: %s)", + wrapped := fmt.Errorf("git diff-files in %s: %w (stderr: %s)", wt.workDir, err, stderr) + wt.recordFilesystemFailure("workspace.file_monitor", workspaceTrigger(ctx, "poll"), wrapped) + return state, wrapped } state.diffFilesID = wt.buildDirtyFilesID(string(out)) @@ -249,6 +254,7 @@ func (wt *WorkspaceTracker) getWorkspaceState(ctx context.Context) (workspaceSta // The shared query excludes dependency trees before Git enumerates paths. untrackedID, err := wt.getUntrackedFilesID(ctx) if err != nil { + wt.recordFilesystemFailure("workspace.file_monitor", workspaceTrigger(ctx, "poll"), err) return state, err } state.untrackedID = untrackedID diff --git a/apps/backend/internal/agentctl/server/process/workspace_monitor_test.go b/apps/backend/internal/agentctl/server/process/workspace_monitor_test.go index 36b8e03ec1..593162aafb 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_monitor_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_monitor_test.go @@ -66,6 +66,10 @@ func TestWorkspaceTracker_MonitorExitsWhenNoGitRepo(t *testing.T) { // The fix should make monitorLoop exit immediately (well under 500ms). wt.filePollInterval = 500 * time.Millisecond wt.gitPollInterval = 500 * time.Millisecond + // This test waits for the polling goroutines directly. Disable the + // lifecycle-owned startup-grace goroutine so that the wait isolates the + // no-git monitor behavior it is checking. + wt.pollModeGrace = 0 if wt.gitIndexPath != "" { t.Fatalf("expected empty gitIndexPath for non-git directory, got %q", wt.gitIndexPath) diff --git a/apps/backend/internal/agentctl/server/process/workspace_poll_mode.go b/apps/backend/internal/agentctl/server/process/workspace_poll_mode.go index 65bb22ff84..f40dfd7526 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_poll_mode.go +++ b/apps/backend/internal/agentctl/server/process/workspace_poll_mode.go @@ -1,6 +1,7 @@ package process import ( + "context" "time" "go.uber.org/zap" @@ -91,6 +92,13 @@ func (wt *WorkspaceTracker) SetPollMode(mode PollMode) { // what the grace demotion exists to detect the absence of. wt.pollModePushed = true wt.disarmPollModeGraceLocked() + if mode == PollModeFast { + // Focus is an explicit visible-user retry after an access denial. + wt.accessDenied.Store(false) + } + if mode == PollModeSlow && wt.accessDenied.Load() { + mode = PollModePaused + } prev := wt.pollMode if prev == mode { wt.pollModeMu.Unlock() @@ -102,20 +110,36 @@ func (wt *WorkspaceTracker) SetPollMode(mode PollMode) { wt.wakePollLoops() } -// demoteUnpushedPollMode drops the tracker to slow once pollModeGracePeriod -// elapses with no explicit SetPollMode. Fires at most once — the timer is not -// rearmed — so a later push always wins. -func (wt *WorkspaceTracker) demoteUnpushedPollMode() { +// demoteUnpushedPollMode performs the one startup-grace scan, then pauses the +// tracker when no gateway mode ever arrived. Fires at most once — the timer is +// not rearmed — so a later push always wins. The caller owns the context so +// Stop can cancel both the scan and the transition before it publishes. +func (wt *WorkspaceTracker) demoteUnpushedPollMode(ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } wt.pollModeMu.Lock() wt.pollModeGraceTimer = nil - if wt.pollModePushed || wt.pollMode == PollModeSlow { + if ctx.Err() != nil || wt.pollModePushed || wt.pollMode == PollModePaused { wt.pollModeMu.Unlock() return } - wt.pollMode = PollModeSlow wt.pollModeMu.Unlock() - wt.logger.Info("no poll mode pushed within grace period, demoting to slow", + wt.RefreshWorkspace(withWorkspaceTrigger(ctx, "startup_grace"), "startup_grace") + if ctx.Err() != nil { + return + } + + wt.pollModeMu.Lock() + if ctx.Err() != nil || wt.pollModePushed { + wt.pollModeMu.Unlock() + return + } + wt.pollMode = PollModePaused + wt.pollModeMu.Unlock() + + wt.logger.Info("no poll mode pushed within grace period, final scan complete, pausing workspace polling", zap.Duration("grace", wt.pollModeGrace), zap.String("workDir", wt.workDir)) wt.wakePollLoops() diff --git a/apps/backend/internal/agentctl/server/process/workspace_poll_mode_grace_test.go b/apps/backend/internal/agentctl/server/process/workspace_poll_mode_grace_test.go index 126ef70a3d..7ed192e7bf 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_poll_mode_grace_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_poll_mode_grace_test.go @@ -2,8 +2,11 @@ package process import ( "context" + "sync/atomic" "testing" "time" + + "github.com/kandev/kandev/internal/agentctl/types" ) // waitForPollMode polls GetPollMode until it matches want or the deadline @@ -66,13 +69,18 @@ func newGraceTestTracker(t *testing.T, grace time.Duration) *WorkspaceTracker { return wt } -// TestPollModeGrace_DemotesWhenNoPushArrives covers the leak this mechanism +// TestPollModeGrace_FinalScansAndPausesWhenNoPushArrives covers the leak this mechanism // exists to close: the gateway only pushes a mode for workspaces a client // focuses or subscribes to, and it deliberately skips the paused push for a -// workspace it has never pushed to. Without the grace demotion such a tracker -// stays at the fast 2s/3s cadence for the life of the process. -func TestPollModeGrace_DemotesWhenNoPushArrives(t *testing.T) { +// workspace it has never pushed to. Without the grace transition such a +// tracker stays at the fast 2s/3s cadence for the life of the process. +func TestPollModeGrace_FinalScansAndPausesWhenNoPushArrives(t *testing.T) { wt := newGraceTestTracker(t, graceFiresQuickly) + var scanCount atomic.Int32 + wt.gitStatusObserver = func(context.Context) (types.GitStatusUpdate, error) { + scanCount.Add(1) + return types.GitStatusUpdate{Timestamp: time.Now()}, nil + } if got := wt.GetPollMode(); got != PollModeFast { t.Fatalf("construction mode = %v, want %v", got, PollModeFast) @@ -81,7 +89,10 @@ func TestPollModeGrace_DemotesWhenNoPushArrives(t *testing.T) { wt.Start(context.Background()) defer wt.Stop() - waitForPollMode(t, wt, PollModeSlow, 10*time.Second) + waitForPollMode(t, wt, PollModePaused, 10*time.Second) + if got := scanCount.Load(); got < 2 { + t.Fatalf("git scan count = %d after grace transition, want initial and final scans", got) + } } // TestPollModeGrace_ExplicitPushDisarmsDemotion verifies the gateway wins: once @@ -169,3 +180,35 @@ func TestPollModeGrace_StopDisarmsTimer(t *testing.T) { t.Errorf("Stop changed the poll mode to %v, want it left at %v", got, PollModeFast) } } + +// TestPollModeGrace_StopJoinsFinalScan verifies the timer callback is owned by +// the tracker wait group. Stop must cancel an in-flight final scan, wait for +// it to return, and prevent the callback from changing the mode afterward. +func TestPollModeGrace_StopJoinsFinalScan(t *testing.T) { + wt := newGraceTestTracker(t, graceFiresQuickly) + var scanCount atomic.Int32 + finalScanStarted := make(chan struct{}) + finalScanFinished := make(chan struct{}) + wt.gitStatusObserver = func(ctx context.Context) (types.GitStatusUpdate, error) { + if scanCount.Add(1) == 2 { + close(finalScanStarted) + <-ctx.Done() + close(finalScanFinished) + return types.GitStatusUpdate{}, ctx.Err() + } + return types.GitStatusUpdate{Timestamp: time.Now()}, nil + } + + wt.Start(context.Background()) + <-finalScanStarted + wt.Stop() + + select { + case <-finalScanFinished: + default: + t.Fatal("Stop returned before the final scan exited") + } + if got := wt.GetPollMode(); got != PollModeFast { + t.Fatalf("poll mode after canceled final scan = %v, want %v", got, PollModeFast) + } +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index 1551dccf93..a87ed78c72 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -11,6 +11,7 @@ import ( "time" "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/common/fsdiagnostics" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/securityutil" "github.com/kandev/kandev/internal/common/subproc" @@ -155,6 +156,16 @@ type WorkspaceTracker struct { // workspace mutations while the target invariant remains atomic. mutationMu sync.Mutex + // diagnostic identity is populated by the process manager once the tracker + // is attached to an agentctl instance. A tracker can be created before the + // owning task/session metadata is available. + diagnosticMu sync.RWMutex + diagnosticTaskID string + diagnosticSessionID string + runtimeMode string + filesystemWarnings *fsdiagnostics.WarningLimiter + accessDenied atomic.Bool + // gitStatusObserver is the expensive live repository observation. Keeping it // as a dependency makes the concurrency contract deterministic to test while // production uses computeGitStatus. @@ -403,6 +414,7 @@ func newWorkspaceTracker(resolvedWorkDir, repositoryName string, log *logger.Log gitIndexPath: gitIndexPath, repositoryName: repositoryName, logger: log.WithFields(logFields...), + runtimeMode: fsdiagnostics.RuntimeMode(strings.EqualFold(strings.TrimSpace(os.Getenv("KANDEV_DESKTOP_RUNTIME")), "true")), workspaceStreamSubscribers: make(map[types.WorkspaceStreamSubscriber]struct{}), filePollInterval: DefaultFilePollInterval, gitPollInterval: DefaultGitPollInterval, @@ -425,6 +437,7 @@ func newWorkspaceTracker(resolvedWorkDir, repositoryName string, log *logger.Log cancelCtx: ctx, cancelFunc: cancel, gitStatusObserveTimeout: workspaceGitStatusObserveTimeout, + filesystemWarnings: fsdiagnostics.NewWarningLimiter(0), } tracker.gitStatusObserver = tracker.computeGitStatus return tracker @@ -534,6 +547,9 @@ func workDirHasOwnGitEntry(workDir string) bool { // so this only checks for subsequent deletion (e.g., worktree cleanup). func (wt *WorkspaceTracker) workDirExists() bool { _, err := os.Stat(wt.workDir) //nolint:gosec // workDir is validated at construction via resolveExistingWorkDir + if err != nil && fsdiagnostics.IsAccessDenied(err) { + wt.recordFilesystemFailure("workspace.file_monitor", "poll", err) + } return !os.IsNotExist(err) } @@ -572,7 +588,23 @@ func (wt *WorkspaceTracker) armPollModeGrace() { if wt.pollModePushed || wt.pollModeGraceTimer != nil || wt.pollModeGrace <= 0 { return } - wt.pollModeGraceTimer = time.AfterFunc(wt.pollModeGrace, wt.demoteUnpushedPollMode) + timer := time.NewTimer(wt.pollModeGrace) + wt.pollModeGraceTimer = timer + // The grace callback owns filesystem work, so it must be part of the + // tracker lifecycle. A timer callback launched by time.AfterFunc cannot be + // joined by Stop when it races with teardown. + wt.wg.Add(1) + go wt.pollModeGraceLoop(timer) +} + +func (wt *WorkspaceTracker) pollModeGraceLoop(timer *time.Timer) { + defer wt.wg.Done() + select { + case <-timer.C: + wt.demoteUnpushedPollMode(wt.cancelCtx) + case <-wt.stopCh: + case <-wt.cancelCtx.Done(): + } } // disarmPollModeGraceLocked stops the fallback timer. Caller must hold pollModeMu. diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 2c9e438699..3aa5e0ec57 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -105,6 +105,7 @@ import ( const ( desktopHealthTokenEnv = "KANDEV_DESKTOP_HEALTH_TOKEN" desktopHealthTokenHeader = "X-Kandev-Desktop-Health-Token" + desktopRuntimeEnv = "KANDEV_DESKTOP_RUNTIME" agentShutdownTimeout = 20 * time.Second httpShutdownTimeout = 10 * time.Second tracingShutdownTimeout = 5 * time.Second @@ -1042,9 +1043,11 @@ func webRuntimeConfig(debug bool, titlePrefix string, req *http.Request) webapp. // Gates QA-only UI (the pseudo-locale option). Separate from Debug: the // e2e harness serves a PRODUCTION bundle, so the frontend cannot infer // this from its own build mode. - NonProduction: profiles.DetectEnvironment() != profiles.EnvProd, - Locale: i18n.FromRequest(req), - TitlePrefix: strings.TrimSpace(titlePrefix), + NonProduction: profiles.DetectEnvironment() != profiles.EnvProd, + Locale: i18n.FromRequest(req), + TitlePrefix: strings.TrimSpace(titlePrefix), + NativeFolderPickerAvailable: strings.EqualFold(strings.TrimSpace(os.Getenv(desktopRuntimeEnv)), "true"), + DesktopRuntime: strings.EqualFold(strings.TrimSpace(os.Getenv(desktopRuntimeEnv)), "true"), } } diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 1bb55810fb..0a89cb8daf 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "os" "path/filepath" "strconv" "strings" @@ -144,6 +145,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Sessions: repos.Task, GitSnapshots: repos.Task, RepoEntities: repos.Task, + DiscoveryRoots: repos.Task, RepositorySets: repos.Task, BranchPolicies: repos.Task, RepositoryCleanup: repos.Task, @@ -163,6 +165,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories Roots: cfg.RepositoryDiscovery.Roots, MaxDepth: cfg.RepositoryDiscovery.MaxDepth, TaskWorktreeRoots: []string{filepath.Join(cfg.ResolvedHomeDir(), "tasks")}, + DesktopRuntime: strings.EqualFold(strings.TrimSpace(os.Getenv("KANDEV_DESKTOP_RUNTIME")), "true"), }, ) taskSvc.SetPendingActionProjectionEpoch(pendingActionProjectionEpoch) diff --git a/apps/backend/internal/backendapp/web_runtime_test.go b/apps/backend/internal/backendapp/web_runtime_test.go index 339a42d1dd..259bd3d57d 100644 --- a/apps/backend/internal/backendapp/web_runtime_test.go +++ b/apps/backend/internal/backendapp/web_runtime_test.go @@ -30,3 +30,16 @@ func TestWebRuntimeConfigOmitsBlankTitlePrefix(t *testing.T) { t.Fatalf("title prefix = %q, want empty", got) } } + +func TestWebRuntimeConfigAdvertisesNativeFolderPickerOnlyForDesktopRuntime(t *testing.T) { + request := httptest.NewRequest("GET", "/", nil) + t.Setenv("KANDEV_DESKTOP_RUNTIME", "true") + if got := webRuntimeConfig(false, "", request).NativeFolderPickerAvailable; !got { + t.Fatal("native folder picker should be advertised for a desktop-launched backend") + } + + t.Setenv("KANDEV_DESKTOP_RUNTIME", "false") + if got := webRuntimeConfig(false, "", request).NativeFolderPickerAvailable; got { + t.Fatal("native folder picker should not be advertised for a browser-launched backend") + } +} diff --git a/apps/backend/internal/common/config/catalog.go b/apps/backend/internal/common/config/catalog.go index 8846217dab..32dd8fb693 100644 --- a/apps/backend/internal/common/config/catalog.go +++ b/apps/backend/internal/common/config/catalog.go @@ -115,6 +115,7 @@ var startupExclusions = []CatalogExclusion{ {EnvVar: "KANDEV_BACKEND_PID_FILE", Class: "internal wiring", Reason: "supervisor-owned process state"}, {EnvVar: "KANDEV_DESKTOP_HEALTH_TOKEN", Class: "generated", Reason: "per-launch health authentication token"}, {EnvVar: "KANDEV_DESKTOP_NATIVE_NOTIFICATIONS", Class: "internal wiring", Reason: "desktop shell capability handoff"}, + {EnvVar: "KANDEV_DESKTOP_RUNTIME", Class: "internal wiring", Reason: "desktop shell launch-policy handoff"}, {EnvVar: "KANDEV_BUNDLE_DIR", Class: "packaging", Reason: "runtime bundle discovery"}, {EnvVar: "KANDEV_WEB_DIST_DIR", Class: "packaging", Reason: "embedded web asset override"}, {EnvVar: "KANDEV_TASK_ID", Class: "workspace injection", Reason: "task-owned child process context"}, diff --git a/apps/backend/internal/common/config/catalog_test.go b/apps/backend/internal/common/config/catalog_test.go index c5d3cbe130..e29e4d5a25 100644 --- a/apps/backend/internal/common/config/catalog_test.go +++ b/apps/backend/internal/common/config/catalog_test.go @@ -135,6 +135,7 @@ func auditedStartupEnvironmentInventory() []auditedStartupEnvironment { {envVar: "KANDEV_BACKEND_PID_FILE", class: "exclusion"}, {envVar: "KANDEV_DESKTOP_HEALTH_TOKEN", class: "exclusion"}, {envVar: "KANDEV_DESKTOP_NATIVE_NOTIFICATIONS", class: "exclusion"}, + {envVar: "KANDEV_DESKTOP_RUNTIME", class: "exclusion"}, {envVar: "KANDEV_BUNDLE_DIR", class: "exclusion"}, {envVar: "KANDEV_WEB_DIST_DIR", class: "exclusion"}, {envVar: "KANDEV_TASK_ID", class: "exclusion"}, diff --git a/apps/backend/internal/common/fsdiagnostics/context.go b/apps/backend/internal/common/fsdiagnostics/context.go new file mode 100644 index 0000000000..c4b9a19fc3 --- /dev/null +++ b/apps/backend/internal/common/fsdiagnostics/context.go @@ -0,0 +1,169 @@ +// Package fsdiagnostics provides shared context and rate limiting for +// filesystem access diagnostics. +package fsdiagnostics + +import ( + "errors" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "go.uber.org/zap" +) + +const defaultWarningInterval = 30 * time.Second + +// Context identifies one filesystem operation in a structured log entry. +// Empty identity fields are omitted because some operations run before a +// task or session exists. +type Context struct { + Operation string + Target string + Trigger string + Runtime string + WorkspaceID string + TaskID string + SessionID string + PollMode string +} + +// Fields returns the common fields for a filesystem operation log entry. +// Target is canonicalized here so callers that fail before their own path +// resolver completes still produce a useful, stable diagnostic. +func (c Context) Fields(err error) []zap.Field { + fields := []zap.Field{ + zap.String("operation", c.Operation), + zap.String("target", CanonicalPath(c.Target)), + zap.String("trigger", c.Trigger), + zap.String("runtime", c.Runtime), + } + if c.WorkspaceID != "" { + fields = append(fields, zap.String("workspace_id", c.WorkspaceID)) + } + if c.TaskID != "" { + fields = append(fields, zap.String("task_id", c.TaskID)) + } + if c.SessionID != "" { + fields = append(fields, zap.String("session_id", c.SessionID)) + } + if c.PollMode != "" { + fields = append(fields, zap.String("poll_mode", c.PollMode)) + } + if err != nil { + fields = append(fields, zap.Error(err)) + } + return fields +} + +// CanonicalPath returns an absolute, cleaned path and resolves symlinks when +// the target currently exists. It deliberately preserves a blank target. +func CanonicalPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + abs, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return filepath.Clean(path) + } + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + return filepath.Clean(resolved) + } + return filepath.Clean(abs) +} + +// RuntimeMode converts the desktop-process marker into the stable runtime +// labels used by diagnostics. +func RuntimeMode(desktop bool) string { + if desktop { + return "desktop" + } + return "server" +} + +// IsAccessDenied reports the deterministic permission class used by the +// tracker pause policy. The text fallback covers platform-specific errors +// that do not wrap os.ErrPermission in tests or in an OS adapter. +func IsAccessDenied(err error) bool { + if err == nil { + return false + } + if errors.Is(err, os.ErrPermission) { + return true + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "permission denied") || + strings.Contains(message, "access is denied") || + strings.Contains(message, "operation not permitted") +} + +type warningState struct { + lastWarned time.Time + suppressed int +} + +// WarningLimiter bounds repeated warnings for an identical operation. The +// first warning is emitted immediately; the next warning after the interval +// carries the number suppressed since the previous warning. +type WarningLimiter struct { + mu sync.Mutex + interval time.Duration + now func() time.Time + entries map[string]warningState +} + +// NewWarningLimiter creates a limiter with the supplied warning interval. +// Non-positive intervals use the production default. +func NewWarningLimiter(interval time.Duration) *WarningLimiter { + if interval <= 0 { + interval = defaultWarningInterval + } + return &WarningLimiter{ + interval: interval, + now: time.Now, + entries: make(map[string]warningState), + } +} + +// Warn emits a bounded warning with the operation context. The error text is +// intentionally excluded from the key so changing OS details cannot create an +// unbounded number of entries for one operation and target. +func (l *WarningLimiter) Warn(log *zap.Logger, message string, operation Context, err error) { + if l == nil || log == nil { + return + } + + now := l.now() + key := warningKey(operation) + l.mu.Lock() + state, exists := l.entries[key] + if exists && now.Sub(state.lastWarned) < l.interval { + state.suppressed++ + l.entries[key] = state + l.mu.Unlock() + return + } + suppressed := state.suppressed + l.entries[key] = warningState{lastWarned: now} + l.mu.Unlock() + + fields := operation.Fields(err) + if suppressed > 0 { + fields = append(fields, zap.Int("suppressed_count", suppressed)) + } + log.Warn(message, fields...) +} + +func warningKey(operation Context) string { + return strings.Join([]string{ + operation.Operation, + CanonicalPath(operation.Target), + operation.Trigger, + operation.Runtime, + operation.WorkspaceID, + operation.TaskID, + operation.SessionID, + }, "\x00") +} diff --git a/apps/backend/internal/common/fsdiagnostics/context_test.go b/apps/backend/internal/common/fsdiagnostics/context_test.go new file mode 100644 index 0000000000..a2a657ee3c --- /dev/null +++ b/apps/backend/internal/common/fsdiagnostics/context_test.go @@ -0,0 +1,110 @@ +package fsdiagnostics + +import ( + "errors" + "path/filepath" + "testing" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" +) + +func TestWarningLimiterBoundsRepeatedAccessDenials(t *testing.T) { + core, logs := observer.New(zapcore.WarnLevel) + clock := time.Date(2026, time.August, 27, 12, 0, 0, 0, time.UTC) + limiter := NewWarningLimiter(time.Minute) + limiter.now = func() time.Time { return clock } + operation := Context{ + Operation: "repository.discovery.scan", + Target: filepath.Join(t.TempDir(), "repo"), + Trigger: "poll", + Runtime: "desktop", + WorkspaceID: "workspace-1", + TaskID: "task-1", + SessionID: "session-1", + PollMode: "slow", + } + denied := errors.New("permission denied") + + for range 3 { + limiter.Warn(zap.New(core), "filesystem.access_denied", operation, denied) + } + if got := logs.Len(); got != 1 { + t.Fatalf("warning count before interval = %d, want 1", got) + } + + clock = clock.Add(time.Minute) + limiter.Warn(zap.New(core), "filesystem.access_denied", operation, denied) + if got := logs.Len(); got != 2 { + t.Fatalf("warning count after interval = %d, want 2", got) + } + if got := logs.All()[1].ContextMap()["suppressed_count"]; got != int64(2) { + t.Fatalf("suppressed_count = %v, want 2", got) + } +} + +func TestWarningLimiterDoesNotGrowPerErrorMessage(t *testing.T) { + core, logs := observer.New(zapcore.WarnLevel) + limiter := NewWarningLimiter(time.Minute) + operation := Context{ + Operation: "workspace.file_monitor", + Target: filepath.Join(t.TempDir(), "repo"), + Trigger: "poll", + Runtime: "desktop", + } + + limiter.Warn(zap.New(core), "filesystem.access_denied", operation, errors.New("permission denied: one")) + limiter.Warn(zap.New(core), "filesystem.access_denied", operation, errors.New("permission denied: two")) + + if got := logs.Len(); got != 1 { + t.Fatalf("warning count for changing errors = %d, want 1", got) + } +} + +func TestContextFieldsCanonicalizeTargetAndIncludeIdentity(t *testing.T) { + root := t.TempDir() + operation := Context{ + Operation: "workspace.file_monitor", + Target: filepath.Join(root, ".", "repo"), + Trigger: "stale_refresh", + Runtime: "server", + WorkspaceID: "workspace-1", + TaskID: "task-1", + SessionID: "session-1", + PollMode: "fast", + } + fields := operation.Fields(errors.New("permission denied")) + core, logs := observer.New(zapcore.WarnLevel) + zap.New(core).Warn("filesystem.access_denied", fields...) + entry := logs.All()[0].ContextMap() + + if got, want := entry["operation"], "workspace.file_monitor"; got != want { + t.Fatalf("operation = %v, want %v", got, want) + } + if got, want := entry["target"], CanonicalPath(operation.Target); got != want { + t.Fatalf("target = %v, want %v", got, want) + } + for key, want := range map[string]string{ + "trigger": "stale_refresh", + "runtime": "server", + "workspace_id": "workspace-1", + "task_id": "task-1", + "session_id": "session-1", + "poll_mode": "fast", + } { + if got := entry[key]; got != want { + t.Errorf("%s = %v, want %v", key, got, want) + } + } +} + +func TestIsAccessDenied(t *testing.T) { + if !IsAccessDenied(errors.New("permission denied")) { + t.Error("permission denied was not classified as access denied") + } + if IsAccessDenied(errors.New("temporary index lock")) { + t.Error("index lock was classified as access denied") + } +} diff --git a/apps/backend/internal/github/mock_controller.go b/apps/backend/internal/github/mock_controller.go index 3a25dc6372..656a2162e4 100644 --- a/apps/backend/internal/github/mock_controller.go +++ b/apps/backend/internal/github/mock_controller.go @@ -536,7 +536,7 @@ func (c *MockController) setMergeOutcome(ctx *gin.Context) { switch req.Outcome { case "failed": c.mock.SetMergeFailure(req.Owner, req.Repo, req.Number, "mock merge provider unavailable") - case "pending": + case computedReviewStatePending: c.mock.SetMergeFailure(req.Owner, req.Repo, req.Number, "mock merge request remained pending") case "head_mismatch": c.mock.SetMergeFailure(req.Owner, req.Repo, req.Number, "mock merge head mismatch") @@ -548,7 +548,7 @@ func (c *MockController) setMergeOutcome(ctx *gin.Context) { func validMockMergeOutcome(outcome string) bool { switch outcome { - case string(MergeOutcomeMerged), string(MergeOutcomeQueued), "failed", "pending", "head_mismatch": + case string(MergeOutcomeMerged), string(MergeOutcomeQueued), "failed", computedReviewStatePending, "head_mismatch": return true default: return false diff --git a/apps/backend/internal/launcher/supervisor.go b/apps/backend/internal/launcher/supervisor.go index 01f3a3a400..9c1ea22b48 100644 --- a/apps/backend/internal/launcher/supervisor.go +++ b/apps/backend/internal/launcher/supervisor.go @@ -189,6 +189,7 @@ func allowedSupervisorEnv(env []string) map[string]string { "KANDEV_DATABASE_PATH": true, "KANDEV_SERVER_PORT": true, "KANDEV_DESKTOP_HEALTH_TOKEN": true, + "KANDEV_DESKTOP_RUNTIME": true, "KANDEV_WEB_INTERNAL_URL": true, "KANDEV_AGENT_STANDALONE_PORT": true, "KANDEV_LOG_LEVEL": true, diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 1ddae9c833..5307559525 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -599,9 +599,26 @@ type LocalRepositoryDTO struct { } type RepositoryDiscoveryResponse struct { - Roots []string `json:"roots"` - Repositories []LocalRepositoryDTO `json:"repositories"` - Total int `json:"total"` + Roots []string `json:"roots"` + Repositories []LocalRepositoryDTO `json:"repositories"` + Total int `json:"total"` + DesktopRuntime bool `json:"desktop_runtime"` + RootStates []DesktopDiscoveryRootDTO `json:"root_states"` + ScanTime *time.Time `json:"scan_time,omitempty"` + Refreshing bool `json:"refreshing"` + Cached bool `json:"cached"` + HomeConfirmationRequired bool `json:"home_confirmation_required"` + FailedRoots []string `json:"failed_roots,omitempty"` +} + +type DesktopDiscoveryRootDTO struct { + ID string `json:"id"` + Path string `json:"path"` + DisplayPath string `json:"display_path"` + State string `json:"state"` + LastScanAt *time.Time `json:"last_scan_at,omitempty"` + LastFailureAt *time.Time `json:"last_failure_at,omitempty"` + LastFailureCode string `json:"last_failure_code,omitempty"` } type RepositoryPathValidationResponse struct { @@ -818,6 +835,18 @@ func FromLocalRepository(repo service.LocalRepository) LocalRepositoryDTO { } } +func FromDesktopDiscoveryRoot(root models.DesktopDiscoveryRoot) DesktopDiscoveryRootDTO { + return DesktopDiscoveryRootDTO{ + ID: root.ID, + Path: root.Path, + DisplayPath: root.DisplayPath, + State: string(root.State), + LastScanAt: root.LastScanAt, + LastFailureAt: root.LastFailureAt, + LastFailureCode: root.LastFailureCode, + } +} + func FromTask(task *models.Task) TaskDTO { return FromTaskWithPrimarySession(task, nil) } diff --git a/apps/backend/internal/task/handlers/repository_handlers.go b/apps/backend/internal/task/handlers/repository_handlers.go index 543088516f..58aea48228 100644 --- a/apps/backend/internal/task/handlers/repository_handlers.go +++ b/apps/backend/internal/task/handlers/repository_handlers.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" + "github.com/kandev/kandev/internal/task/repository/repoerrors" "github.com/kandev/kandev/internal/task/service" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" @@ -41,6 +42,12 @@ func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) { api.POST("/workspaces/:id/repositories", h.httpCreateRepository) api.POST("/workspaces/:id/repositories/initialize-local", h.httpInitializeLocalRepository) api.GET("/workspaces/:id/repositories/discover", h.httpDiscoverRepositories) + api.GET("/workspaces/:id/repositories/discovery", h.httpGetDiscoverySnapshot) + api.POST("/workspaces/:id/repositories/discovery/refresh", h.httpRefreshDiscovery) + api.GET("/repositories/discovery/roots", h.httpListDiscoveryRoots) + api.POST("/repositories/discovery/roots", h.httpAddDiscoveryRoot) + api.POST("/repositories/discovery/roots/reconnect", h.httpReconnectDiscoveryRoot) + api.DELETE("/repositories/discovery/roots", h.httpRemoveDiscoveryRoot) // Unified branch listing — accepts either ?repository_id= for an imported // workspace repo, or ?path= for an on-machine folder discovered but not // yet imported. Both paths bottom out in `listGitBranches`; the only @@ -118,27 +125,145 @@ func (h *RepositoryHandlers) httpListRepositories(c *gin.Context) { } func (h *RepositoryHandlers) httpDiscoverRepositories(c *gin.Context) { + workspaceID := c.Param("id") root := c.Query("root") - result, err := h.service.DiscoverLocalRepositories(c.Request.Context(), root) + result, err := h.service.DiscoverLocalRepositoriesForWorkspace( + c.Request.Context(), workspaceID, root, + ) if err != nil { - if errors.Is(err, service.ErrPathNotAllowed) { - c.JSON(http.StatusBadRequest, gin.H{"error": "root is not within allowed paths"}) - return - } - h.logger.Error("failed to discover repositories", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to discover repositories"}) + h.writeDiscoveryError(c, err) return } - resp := dto.RepositoryDiscoveryResponse{ - Roots: result.Roots, - Repositories: make([]dto.LocalRepositoryDTO, 0, len(result.Repositories)), - Total: len(result.Repositories), + c.JSON(http.StatusOK, discoveryResponse(result)) +} + +func (h *RepositoryHandlers) httpGetDiscoverySnapshot(c *gin.Context) { + result, err := h.service.GetLocalRepositoryDiscoveryForWorkspace( + c.Request.Context(), c.Param("id"), c.Query("root"), + ) + if err != nil { + h.writeDiscoveryError(c, err) + return } + c.JSON(http.StatusOK, discoveryResponse(result)) +} + +func (h *RepositoryHandlers) httpRefreshDiscovery(c *gin.Context) { + result, err := h.service.RefreshLocalRepositoryDiscoveryForWorkspaceWithTrigger( + c.Request.Context(), + c.Param("id"), + c.Query("root"), + service.NormalizeRepositoryDiscoveryTrigger(c.Query("trigger")), + ) + if err != nil { + h.writeDiscoveryError(c, err) + return + } + c.JSON(http.StatusOK, discoveryResponse(result)) +} + +func discoveryResponse(result service.RepositoryDiscoveryResult) dto.RepositoryDiscoveryResponse { + rootStates := make([]dto.DesktopDiscoveryRootDTO, 0, len(result.RootStates)) + for _, root := range result.RootStates { + rootStates = append(rootStates, dto.FromDesktopDiscoveryRoot(root)) + } + repositories := make([]dto.LocalRepositoryDTO, 0, len(result.Repositories)) for _, repo := range result.Repositories { - resp.Repositories = append(resp.Repositories, dto.FromLocalRepository(repo)) + repositories = append(repositories, dto.FromLocalRepository(repo)) } - c.JSON(http.StatusOK, resp) + return dto.RepositoryDiscoveryResponse{ + Roots: result.Roots, + Repositories: repositories, + Total: len(repositories), + DesktopRuntime: result.DesktopRuntime, + RootStates: rootStates, + ScanTime: result.ScanTime, + Refreshing: result.Refreshing, + Cached: result.Cached, + HomeConfirmationRequired: result.HomeConfirmationRequired, + FailedRoots: result.FailedRoots, + } +} + +func (h *RepositoryHandlers) writeDiscoveryError(c *gin.Context, err error) { + switch { + case errors.Is(err, repoerrors.ErrWorkspaceNotFound): + handleNotFound(c, h.logger, err, "workspace not found") + case errors.Is(err, service.ErrPathNotAllowed), errors.Is(err, service.ErrInvalidDiscoveryRoot): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrDesktopDiscoveryUnavailable): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + default: + h.logger.Error("failed to load repository discovery", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load repository discovery"}) + } +} + +type discoveryRootRequest struct { + Path string `json:"path"` + NewPath string `json:"new_path"` +} + +func (h *RepositoryHandlers) httpListDiscoveryRoots(c *gin.Context) { + roots, err := h.service.ListDesktopDiscoveryRoots(c.Request.Context()) + if err != nil { + h.writeDiscoveryError(c, err) + return + } + response := make([]dto.DesktopDiscoveryRootDTO, 0, len(roots)) + for _, root := range roots { + if root != nil { + response = append(response, dto.FromDesktopDiscoveryRoot(*root)) + } + } + c.JSON(http.StatusOK, gin.H{"roots": response}) +} + +func (h *RepositoryHandlers) httpAddDiscoveryRoot(c *gin.Context) { + var body discoveryRootRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + root, err := h.service.AddDesktopDiscoveryRoot(c.Request.Context(), body.Path) + if err != nil { + h.writeDiscoveryError(c, err) + return + } + if root == nil { + h.logger.Error("desktop discovery root was not returned after add") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add discovery root"}) + return + } + c.JSON(http.StatusCreated, dto.FromDesktopDiscoveryRoot(*root)) +} + +func (h *RepositoryHandlers) httpReconnectDiscoveryRoot(c *gin.Context) { + var body discoveryRootRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + root, err := h.service.ReconnectDesktopDiscoveryRoot(c.Request.Context(), body.Path, body.NewPath) + if err != nil { + h.writeDiscoveryError(c, err) + return + } + if root == nil { + h.logger.Error("desktop discovery root was not returned after reconnect") + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reconnect discovery root"}) + return + } + c.JSON(http.StatusOK, dto.FromDesktopDiscoveryRoot(*root)) +} + +func (h *RepositoryHandlers) httpRemoveDiscoveryRoot(c *gin.Context) { + if err := h.service.RemoveDesktopDiscoveryRoot(c.Request.Context(), c.Query("path")); err != nil { + h.writeDiscoveryError(c, err) + return + } + c.Status(http.StatusNoContent) } // httpListDirectory lists the immediate subdirectories of ?path= (defaults diff --git a/apps/backend/internal/task/handlers/repository_handlers_test.go b/apps/backend/internal/task/handlers/repository_handlers_test.go index a06ebd063c..074bcd79b3 100644 --- a/apps/backend/internal/task/handlers/repository_handlers_test.go +++ b/apps/backend/internal/task/handlers/repository_handlers_test.go @@ -416,6 +416,59 @@ func TestHTTPListBranchesRejectsInvalidExplicitPath(t *testing.T) { } } +func TestHTTPDesktopDiscoveryRootLifecycle(t *testing.T) { + router, repo, _ := newDesktopRepositoryHTTPTestRouter(t) + rootPath := canonicalTempDir(t) + + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/repositories/discovery/roots", + strings.NewReader(`{"path":`+strconv.Quote(rootPath)+`}`), + ) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusCreated { + t.Fatalf("add status = %d, want %d; body = %s", response.Code, http.StatusCreated, response.Body.String()) + } + if !strings.Contains(response.Body.String(), strconv.Quote(rootPath)) { + t.Fatalf("add response does not contain root path %q: %s", rootPath, response.Body.String()) + } + + listResponse := httptest.NewRecorder() + router.ServeHTTP(listResponse, httptest.NewRequest(http.MethodGet, "/api/v1/repositories/discovery/roots", nil)) + if listResponse.Code != http.StatusOK || !strings.Contains(listResponse.Body.String(), strconv.Quote(rootPath)) { + t.Fatalf("list status/body = %d/%s", listResponse.Code, listResponse.Body.String()) + } + + snapshotResponse := httptest.NewRecorder() + router.ServeHTTP(snapshotResponse, httptest.NewRequest( + http.MethodGet, + "/api/v1/workspaces/ws-1/repositories/discovery", + nil, + )) + if snapshotResponse.Code != http.StatusOK || !strings.Contains(snapshotResponse.Body.String(), `"desktop_runtime":true`) { + t.Fatalf("snapshot status/body = %d/%s", snapshotResponse.Code, snapshotResponse.Body.String()) + } + + removeResponse := httptest.NewRecorder() + router.ServeHTTP(removeResponse, httptest.NewRequest( + http.MethodDelete, + "/api/v1/repositories/discovery/roots?path="+url.QueryEscape(rootPath), + nil, + )) + if removeResponse.Code != http.StatusNoContent { + t.Fatalf("remove status = %d, want %d; body = %s", removeResponse.Code, http.StatusNoContent, removeResponse.Body.String()) + } + roots, err := repo.ListDesktopDiscoveryRoots(context.Background()) + if err != nil { + t.Fatalf("list roots after remove: %v", err) + } + if len(roots) != 0 { + t.Fatalf("roots after remove = %+v, want none", roots) + } +} + func TestHTTPLocalRepositoryStatusRejectsInvalidExplicitPath(t *testing.T) { router, _ := newRepositoryHTTPTestRouter(t) request := httptest.NewRequest( @@ -439,6 +492,14 @@ func newRepositoryHTTPTestRouter(t *testing.T) (*gin.Engine, *taskrepo.Repositor } func newRepositoryHTTPTestRouterWithService(t *testing.T) (*gin.Engine, *taskrepo.Repository, *service.Service) { + return newRepositoryHTTPTestRouterWithConfig(t, service.RepositoryDiscoveryConfig{}) +} + +func newDesktopRepositoryHTTPTestRouter(t *testing.T) (*gin.Engine, *taskrepo.Repository, *service.Service) { + return newRepositoryHTTPTestRouterWithConfig(t, service.RepositoryDiscoveryConfig{DesktopRuntime: true}) +} + +func newRepositoryHTTPTestRouterWithConfig(t *testing.T, discoveryConfig service.RepositoryDiscoveryConfig) (*gin.Engine, *taskrepo.Repository, *service.Service) { t.Helper() dbConn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "test.db")) if err != nil { @@ -466,9 +527,10 @@ func newRepositoryHTTPTestRouterWithService(t *testing.T) (*gin.Engine, *taskrep } eventBus := bus.NewMemoryEventBus(log) svc := service.NewService(service.Repos{ - Workspaces: repo, - RepoEntities: repo, - }, eventBus, log, service.RepositoryDiscoveryConfig{}) + Workspaces: repo, + RepoEntities: repo, + DiscoveryRoots: repo, + }, eventBus, log, discoveryConfig) router := gin.New() NewRepositoryHandlers(svc, log).registerHTTP(router) return router, repo, svc diff --git a/apps/backend/internal/task/handlers/route_registration_test.go b/apps/backend/internal/task/handlers/route_registration_test.go index 2d09d4b85e..885b8018fb 100644 --- a/apps/backend/internal/task/handlers/route_registration_test.go +++ b/apps/backend/internal/task/handlers/route_registration_test.go @@ -174,6 +174,12 @@ func TestRegisterRepositoryRoutesWiresHTTPAndWS(t *testing.T) { "POST /api/v1/workspaces/:id/repositories", "POST /api/v1/workspaces/:id/repositories/initialize-local", "GET /api/v1/workspaces/:id/repositories/discover", + "GET /api/v1/workspaces/:id/repositories/discovery", + "POST /api/v1/workspaces/:id/repositories/discovery/refresh", + "GET /api/v1/repositories/discovery/roots", + "POST /api/v1/repositories/discovery/roots", + "POST /api/v1/repositories/discovery/roots/reconnect", + "DELETE /api/v1/repositories/discovery/roots", "GET /api/v1/workspaces/:id/branches", "GET /api/v1/workspaces/:id/repositories/local-status", "GET /api/v1/workspaces/:id/repositories/validate", diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 1b1197ea18..ac74a22916 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -1816,6 +1816,37 @@ type Repository struct { DeletedAt *time.Time `json:"deleted_at,omitempty"` } +// DesktopDiscoveryRootState describes whether an install-wide discovery root +// can currently be read by the backend process. +type DesktopDiscoveryRootState string + +const ( + DesktopDiscoveryRootConnected DesktopDiscoveryRootState = "connected" + DesktopDiscoveryRootReconnectRequired DesktopDiscoveryRootState = "reconnect_required" +) + +// DesktopDiscoveryRoot is an install-wide root selected for automatic local +// repository discovery. It is deliberately separate from a workspace-owned +// repository grant. +type DesktopDiscoveryRoot struct { + ID string `json:"id"` + Path string `json:"path"` + DisplayPath string `json:"display_path"` + State DesktopDiscoveryRootState `json:"state"` + LastScanAt *time.Time `json:"last_scan_at,omitempty"` + LastFailureAt *time.Time `json:"last_failure_at,omitempty"` + LastFailureCode string `json:"last_failure_code,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// DesktopDiscoveryMigration records upgrade-only state without turning the +// old implicit Home fallback into a new automatic scan. +type DesktopDiscoveryMigration struct { + HomeConfirmationRequired bool `json:"home_confirmation_required"` + UpdatedAt time.Time `json:"updated_at"` +} + // RepositoryBranchPolicy is a reusable branch workflow owned by one repository. // It is configuration, not task history: task repositories copy these fields // into their snapshot columns when a policy is selected. diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 07ad88db2d..511ba198c4 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -457,6 +457,18 @@ type RepositoryEntityRepository interface { GetRepositoryByLocalPath(ctx context.Context, workspaceID, localPath string) (*models.Repository, error) } +// DesktopDiscoveryRootRepository stores install-wide desktop discovery roots +// and one-time migration state. These records are not owned by a workspace. +type DesktopDiscoveryRootRepository interface { + ListDesktopDiscoveryRoots(ctx context.Context) ([]*models.DesktopDiscoveryRoot, error) + GetDesktopDiscoveryRoot(ctx context.Context, path string) (*models.DesktopDiscoveryRoot, error) + CreateDesktopDiscoveryRoot(ctx context.Context, root *models.DesktopDiscoveryRoot) error + UpdateDesktopDiscoveryRoot(ctx context.Context, root *models.DesktopDiscoveryRoot) error + DeleteDesktopDiscoveryRoot(ctx context.Context, path string) error + GetDesktopDiscoveryMigration(ctx context.Context) (*models.DesktopDiscoveryMigration, error) + SetDesktopDiscoveryMigration(ctx context.Context, migration *models.DesktopDiscoveryMigration) error +} + // RepositorySetRepository stores named, reusable groups of workspace // repositories. Membership order is authoritative: writes assign contiguous // positions from the supplied order, and reads return items in that order with diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 6145845892..b714be049e 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -15,6 +15,7 @@ import ( // added without growing the function's cyclomatic complexity. func (r *Repository) initSchema() error { steps := []func() error{ + r.initDesktopDiscoverySchema, r.initCoreSchema, r.initRepositorySetsSchema, r.initRepositoryBranchPoliciesSchema, diff --git a/apps/backend/internal/task/repository/sqlite/desktop_discovery_root.go b/apps/backend/internal/task/repository/sqlite/desktop_discovery_root.go new file mode 100644 index 0000000000..ca471512f6 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/desktop_discovery_root.go @@ -0,0 +1,186 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/google/uuid" + + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +const desktopDiscoverySchemaDDL = ` + CREATE TABLE IF NOT EXISTS desktop_discovery_roots ( + id TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + display_path TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'connected', + last_scan_at TIMESTAMP, + last_failure_at TIMESTAMP, + last_failure_code TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_desktop_discovery_roots_state + ON desktop_discovery_roots(state); + CREATE TABLE IF NOT EXISTS desktop_discovery_migration ( + id INTEGER PRIMARY KEY, + home_confirmation_required INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL + ); +` + +func (r *Repository) initDesktopDiscoverySchema() error { + legacyInstallation, err := r.tableExists("workspaces") + if err != nil { + return err + } + if _, err := r.db.Exec(desktopDiscoverySchemaDDL); err != nil { + return err + } + required := 0 + if legacyInstallation { + required = 1 + } + _, err = r.db.Exec(r.db.Rebind(` + INSERT INTO desktop_discovery_migration (id, home_confirmation_required, updated_at) + VALUES (1, ?, ?) + ON CONFLICT (id) DO NOTHING + `), required, time.Now().UTC()) + return err +} + +const desktopDiscoveryRootColumns = `id, path, display_path, state, last_scan_at, last_failure_at, last_failure_code, created_at, updated_at` + +func (r *Repository) scanDesktopDiscoveryRoot(scanner interface{ Scan(...any) error }) (*models.DesktopDiscoveryRoot, error) { + root := &models.DesktopDiscoveryRoot{} + var state string + var lastScanAt, lastFailureAt sql.NullTime + if err := scanner.Scan( + &root.ID, &root.Path, &root.DisplayPath, &state, + &lastScanAt, &lastFailureAt, &root.LastFailureCode, + &root.CreatedAt, &root.UpdatedAt, + ); err != nil { + return nil, err + } + root.State = models.DesktopDiscoveryRootState(state) + if lastScanAt.Valid { + value := lastScanAt.Time + root.LastScanAt = &value + } + if lastFailureAt.Valid { + value := lastFailureAt.Time + root.LastFailureAt = &value + } + return root, nil +} + +func (r *Repository) ListDesktopDiscoveryRoots(ctx context.Context) ([]*models.DesktopDiscoveryRoot, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT `+desktopDiscoveryRootColumns+` FROM desktop_discovery_roots + ORDER BY LOWER(display_path), path + `)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + roots := make([]*models.DesktopDiscoveryRoot, 0) + for rows.Next() { + root, err := r.scanDesktopDiscoveryRoot(rows) + if err != nil { + return nil, err + } + roots = append(roots, root) + } + return roots, rows.Err() +} + +func (r *Repository) GetDesktopDiscoveryRoot(ctx context.Context, path string) (*models.DesktopDiscoveryRoot, error) { + root, err := r.scanDesktopDiscoveryRoot(r.ro.QueryRowContext(ctx, r.ro.Rebind( + `SELECT `+desktopDiscoveryRootColumns+` FROM desktop_discovery_roots WHERE path = ?`, + ), path)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return root, err +} + +func (r *Repository) CreateDesktopDiscoveryRoot(ctx context.Context, root *models.DesktopDiscoveryRoot) error { + if root.ID == "" { + root.ID = uuid.NewString() + } + now := time.Now().UTC() + root.CreatedAt = now + root.UpdatedAt = now + if root.State == "" { + root.State = models.DesktopDiscoveryRootConnected + } + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO desktop_discovery_roots ( + id, path, display_path, state, last_scan_at, last_failure_at, + last_failure_code, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `), root.ID, root.Path, root.DisplayPath, root.State, root.LastScanAt, + root.LastFailureAt, root.LastFailureCode, root.CreatedAt, root.UpdatedAt) + return err +} + +func (r *Repository) UpdateDesktopDiscoveryRoot(ctx context.Context, root *models.DesktopDiscoveryRoot) error { + root.UpdatedAt = time.Now().UTC() + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE desktop_discovery_roots + SET path = ?, display_path = ?, state = ?, last_scan_at = ?, + last_failure_at = ?, last_failure_code = ?, updated_at = ? + WHERE id = ? + `), root.Path, root.DisplayPath, root.State, root.LastScanAt, root.LastFailureAt, + root.LastFailureCode, root.UpdatedAt, root.ID) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return repoerrors.ErrRepositoryNotFound + } + return nil +} + +func (r *Repository) DeleteDesktopDiscoveryRoot(ctx context.Context, path string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM desktop_discovery_roots WHERE path = ?`), path) + return err +} + +func (r *Repository) GetDesktopDiscoveryMigration(ctx context.Context) (*models.DesktopDiscoveryMigration, error) { + migration := &models.DesktopDiscoveryMigration{} + var required int + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT home_confirmation_required, updated_at + FROM desktop_discovery_migration WHERE id = 1 + `)).Scan(&required, &migration.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return migration, nil + } + if err != nil { + return nil, err + } + migration.HomeConfirmationRequired = required != 0 + return migration, nil +} + +func (r *Repository) SetDesktopDiscoveryMigration(ctx context.Context, migration *models.DesktopDiscoveryMigration) error { + migration.UpdatedAt = time.Now().UTC() + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO desktop_discovery_migration (id, home_confirmation_required, updated_at) + VALUES (1, ?, ?) + ON CONFLICT (id) DO UPDATE SET + home_confirmation_required = excluded.home_confirmation_required, + updated_at = excluded.updated_at + `), dialect.BoolToInt(migration.HomeConfirmationRequired), migration.UpdatedAt) + return err +} diff --git a/apps/backend/internal/task/repository/sqlite/desktop_discovery_root_postgres_test.go b/apps/backend/internal/task/repository/sqlite/desktop_discovery_root_postgres_test.go new file mode 100644 index 0000000000..d4151c6e9b --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/desktop_discovery_root_postgres_test.go @@ -0,0 +1,71 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +func TestPostgresDesktopDiscoveryRootRoundTrip(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("initialize postgres schema: %v", err) + } + ctx := context.Background() + + migration := &models.DesktopDiscoveryMigration{HomeConfirmationRequired: true} + if err := repo.SetDesktopDiscoveryMigration(ctx, migration); err != nil { + t.Fatalf("set postgres migration: %v", err) + } + loadedMigration, err := repo.GetDesktopDiscoveryMigration(ctx) + if err != nil { + t.Fatalf("get postgres migration: %v", err) + } + if !loadedMigration.HomeConfirmationRequired { + t.Fatal("postgres migration confirmation was not persisted") + } + + root := &models.DesktopDiscoveryRoot{ + ID: "postgres-discovery-root", + Path: "/workspace/postgres-code", + DisplayPath: "~/postgres-code", + State: models.DesktopDiscoveryRootConnected, + } + if err := repo.CreateDesktopDiscoveryRoot(ctx, root); err != nil { + t.Fatalf("create postgres discovery root: %v", err) + } + loaded, err := repo.GetDesktopDiscoveryRoot(ctx, root.Path) + if err != nil { + t.Fatalf("get postgres discovery root: %v", err) + } + if loaded == nil || loaded.ID != root.ID { + t.Fatalf("loaded postgres root = %+v, want id %q", loaded, root.ID) + } + + loaded.State = models.DesktopDiscoveryRootReconnectRequired + loaded.LastFailureCode = "permission_denied" + if err := repo.UpdateDesktopDiscoveryRoot(ctx, loaded); err != nil { + t.Fatalf("update postgres discovery root: %v", err) + } + listed, err := repo.ListDesktopDiscoveryRoots(ctx) + if err != nil { + t.Fatalf("list postgres discovery roots: %v", err) + } + if len(listed) != 1 || listed[0].State != models.DesktopDiscoveryRootReconnectRequired { + t.Fatalf("listed postgres roots = %+v", listed) + } + + if err := repo.DeleteDesktopDiscoveryRoot(ctx, root.Path); err != nil { + t.Fatalf("delete postgres discovery root: %v", err) + } + deleted, err := repo.GetDesktopDiscoveryRoot(ctx, root.Path) + if err != nil { + t.Fatalf("get deleted postgres discovery root: %v", err) + } + if deleted != nil { + t.Fatalf("deleted postgres root still exists: %+v", deleted) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/desktop_discovery_root_test.go b/apps/backend/internal/task/repository/sqlite/desktop_discovery_root_test.go new file mode 100644 index 0000000000..cf1254119c --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/desktop_discovery_root_test.go @@ -0,0 +1,121 @@ +package sqlite + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/task/models" +) + +func newDesktopDiscoveryTestRepo(t *testing.T) (*Repository, *sqlx.DB) { + t.Helper() + dbConn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "desktop-discovery.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repo, err := NewWithDB(sqlxDB, sqlxDB, nil) + if err != nil { + _ = sqlxDB.Close() + t.Fatalf("new repository: %v", err) + } + t.Cleanup(func() { _ = sqlxDB.Close() }) + return repo, sqlxDB +} + +func TestDesktopDiscoveryRootRoundTrip(t *testing.T) { + repo, _ := newDesktopDiscoveryTestRepo(t) + ctx := context.Background() + + root := &models.DesktopDiscoveryRoot{ + ID: "root-1", + Path: "/Users/example/Code", + DisplayPath: "~/Code", + State: models.DesktopDiscoveryRootConnected, + } + if err := repo.CreateDesktopDiscoveryRoot(ctx, root); err != nil { + t.Fatalf("create root: %v", err) + } + if root.CreatedAt.IsZero() || root.UpdatedAt.IsZero() { + t.Fatalf("create timestamps were not populated: %+v", root) + } + + got, err := repo.GetDesktopDiscoveryRoot(ctx, root.Path) + if err != nil { + t.Fatalf("get root: %v", err) + } + if got == nil || got.ID != root.ID || got.DisplayPath != root.DisplayPath { + t.Fatalf("get root = %+v, want %+v", got, root) + } + + got.State = models.DesktopDiscoveryRootReconnectRequired + got.LastFailureCode = "permission_denied" + if err := repo.UpdateDesktopDiscoveryRoot(ctx, got); err != nil { + t.Fatalf("update root: %v", err) + } + updated, err := repo.GetDesktopDiscoveryRoot(ctx, root.Path) + if err != nil { + t.Fatalf("get updated root: %v", err) + } + if updated.State != models.DesktopDiscoveryRootReconnectRequired || updated.LastFailureCode != "permission_denied" { + t.Fatalf("updated root = %+v", updated) + } + + roots, err := repo.ListDesktopDiscoveryRoots(ctx) + if err != nil { + t.Fatalf("list roots: %v", err) + } + if len(roots) != 1 || roots[0].ID != root.ID { + t.Fatalf("listed roots = %+v", roots) + } + if err := repo.DeleteDesktopDiscoveryRoot(ctx, root.Path); err != nil { + t.Fatalf("delete root: %v", err) + } + deleted, err := repo.GetDesktopDiscoveryRoot(ctx, root.Path) + if err != nil { + t.Fatalf("get deleted root: %v", err) + } + if deleted != nil { + t.Fatalf("deleted root still exists: %+v", deleted) + } +} + +func TestDesktopDiscoveryMigrationRoundTripAndSchemaReplay(t *testing.T) { + repo, sqlxDB := newDesktopDiscoveryTestRepo(t) + ctx := context.Background() + + migration, err := repo.GetDesktopDiscoveryMigration(ctx) + if err != nil { + t.Fatalf("get migration: %v", err) + } + if migration.HomeConfirmationRequired { + t.Fatal("fresh installation unexpectedly requires Home confirmation") + } + + migration.HomeConfirmationRequired = true + if err := repo.SetDesktopDiscoveryMigration(ctx, migration); err != nil { + t.Fatalf("set migration: %v", err) + } + updated, err := repo.GetDesktopDiscoveryMigration(ctx) + if err != nil { + t.Fatalf("get updated migration: %v", err) + } + if !updated.HomeConfirmationRequired { + t.Fatal("migration confirmation flag was not persisted") + } + + if _, err := NewWithDB(sqlxDB, sqlxDB, nil); err != nil { + t.Fatalf("replay schema: %v", err) + } + replayed, err := repo.GetDesktopDiscoveryMigration(ctx) + if err != nil { + t.Fatalf("get replayed migration: %v", err) + } + if !replayed.HomeConfirmationRequired { + t.Fatal("schema replay reset migration state") + } +} diff --git a/apps/backend/internal/task/service/directory_listing.go b/apps/backend/internal/task/service/directory_listing.go index 19d3660fa6..56e1df4332 100644 --- a/apps/backend/internal/task/service/directory_listing.go +++ b/apps/backend/internal/task/service/directory_listing.go @@ -48,15 +48,18 @@ func (s *Service) ListDirectory(ctx context.Context, path string) (DirectoryList abs, err := resolveListingPath(path) if err != nil { + s.logFilesystemFailure("repository.discovery.directory_listing", path, "picker_opened", err) return DirectoryListing{}, err } entries, err := readSubdirsBoundedAtFilesystemRoot(abs) if err != nil { + s.logFilesystemFailure("repository.discovery.directory_listing", abs, "picker_opened", err) return DirectoryListing{}, err } _ = ctx + s.logFilesystemInfo("repository discovery directory listed", "repository.discovery.directory_listing", abs, "picker_opened") return DirectoryListing{ Path: abs, Parent: parentPath(abs), diff --git a/apps/backend/internal/task/service/filesystem_diagnostics.go b/apps/backend/internal/task/service/filesystem_diagnostics.go new file mode 100644 index 0000000000..099d36ec4d --- /dev/null +++ b/apps/backend/internal/task/service/filesystem_diagnostics.go @@ -0,0 +1,31 @@ +package service + +import "github.com/kandev/kandev/internal/common/fsdiagnostics" + +func (s *Service) filesystemContext(operation, target, trigger string) fsdiagnostics.Context { + return fsdiagnostics.Context{ + Operation: operation, + Target: target, + Trigger: trigger, + Runtime: fsdiagnostics.RuntimeMode(s.discoveryConfig.DesktopRuntime), + } +} + +func (s *Service) logFilesystemFailure(operation, target, trigger string, err error) { + if s == nil || s.logger == nil || err == nil { + return + } + operationContext := s.filesystemContext(operation, target, trigger) + if fsdiagnostics.IsAccessDenied(err) { + s.filesystemWarnings.Warn(s.logger.Zap(), "filesystem.access_denied", operationContext, err) + return + } + s.logger.Warn("filesystem operation failed", operationContext.Fields(err)...) +} + +func (s *Service) logFilesystemInfo(message, operation, target, trigger string) { + if s == nil || s.logger == nil { + return + } + s.logger.Info(message, s.filesystemContext(operation, target, trigger).Fields(nil)...) +} diff --git a/apps/backend/internal/task/service/repository_discovery.go b/apps/backend/internal/task/service/repository_discovery.go index 99e4a7d5ba..85bddf606e 100644 --- a/apps/backend/internal/task/service/repository_discovery.go +++ b/apps/backend/internal/task/service/repository_discovery.go @@ -8,18 +8,23 @@ import ( "net/url" "os" "path/filepath" + "runtime" "sort" "strconv" "strings" + "time" + "github.com/kandev/kandev/internal/common/fsdiagnostics" "github.com/kandev/kandev/internal/common/gitref" "github.com/kandev/kandev/internal/common/subproc" + "github.com/kandev/kandev/internal/task/models" ) type RepositoryDiscoveryConfig struct { Roots []string MaxDepth int TaskWorktreeRoots []string + DesktopRuntime bool } const ( @@ -48,8 +53,15 @@ type Branch struct { } type RepositoryDiscoveryResult struct { - Roots []string - Repositories []LocalRepository + Roots []string + Repositories []LocalRepository + DesktopRuntime bool + RootStates []models.DesktopDiscoveryRoot + ScanTime *time.Time + Refreshing bool + Cached bool + HomeConfirmationRequired bool + FailedRoots []string } type RepositoryPathValidation struct { @@ -77,48 +89,22 @@ const sourceTypeLocal = "local" const sourceTypeProvider = "provider" func (s *Service) DiscoverLocalRepositories(ctx context.Context, root string) (RepositoryDiscoveryResult, error) { - roots := s.discoveryRoots() - if root != "" { - absRoot, err := filepath.Abs(root) - if err != nil { - return RepositoryDiscoveryResult{}, fmt.Errorf("invalid root path: %w", err) - } - if !isPathAllowed(absRoot, roots) { - return RepositoryDiscoveryResult{}, ErrPathNotAllowed - } - roots = []string{absRoot} - } + return s.RefreshLocalRepositoryDiscovery(ctx, root) +} - repos := make([]LocalRepository, 0) - seen := make(map[string]struct{}) - for _, scanRoot := range roots { - select { - case <-ctx.Done(): - return RepositoryDiscoveryResult{}, ctx.Err() - default: - } - found, err := scanRootForRepos(ctx, scanRoot, s.discoveryMaxDepth()) - if err != nil { - return RepositoryDiscoveryResult{}, err - } - for _, repo := range found { - if _, ok := seen[repo.Path]; ok { - continue - } - seen[repo.Path] = struct{}{} - repos = append(repos, repo) - } - } - - return RepositoryDiscoveryResult{ - Roots: roots, - Repositories: repos, - }, nil +// DiscoverLocalRepositoriesForWorkspace applies the workspace visibility +// check before using the legacy discovery endpoint. +func (s *Service) DiscoverLocalRepositoriesForWorkspace( + ctx context.Context, + workspaceID, root string, +) (RepositoryDiscoveryResult, error) { + return s.refreshLocalRepositoryDiscovery(ctx, workspaceID, root, discoveryTriggerManualRefresh) } func (s *Service) ValidateLocalRepositoryPath(ctx context.Context, path string) (RepositoryPathValidation, error) { absPath, err := filepath.Abs(filepath.Clean(path)) if err != nil { + s.logFilesystemFailure("repository.discovery.validation", path, "user_select", err) return RepositoryPathValidation{}, fmt.Errorf("invalid path: %w", err) } result := RepositoryPathValidation{Path: absPath, Allowed: true} @@ -134,6 +120,9 @@ func (s *Service) ValidateLocalRepositoryPath(ctx context.Context, path string) // codeql[go/path-injection] Intentional read-only diagnostics for the local path selected by the user. info, statErr := os.Stat(absPath) result.Exists = statErr == nil + if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + s.logFilesystemFailure("repository.discovery.validation", absPath, "user_select", statErr) + } switch { case errors.Is(statErr, os.ErrNotExist): result.Message = "Path does not exist" @@ -718,8 +707,19 @@ func (s *Service) discoveryRoots() []string { var roots []string if len(s.discoveryConfig.Roots) > 0 { roots = append(roots, s.discoveryConfig.Roots...) - } else if home, err := os.UserHomeDir(); err == nil { - roots = append(roots, home) + } else if !s.discoveryConfig.DesktopRuntime { + if home, err := os.UserHomeDir(); err == nil { + roots = append(roots, home) + } + } + if s.discoveryConfig.DesktopRuntime && s.desktopRootStore != nil { + if saved, err := s.desktopRootStore.ListDesktopDiscoveryRoots(context.Background()); err == nil { + for _, root := range saved { + if root != nil { + roots = append(roots, root.Path) + } + } + } } // The orchestrator clones provider-backed repos into a configurable base // path. When that base path sits outside HOME (e.g. /data/repos in a @@ -764,12 +764,18 @@ func normalizeRoots(roots []string) []string { } func scanRootForRepos(ctx context.Context, root string, maxDepth int) ([]LocalRepository, error) { + if _, err := os.Stat(root); err != nil { + return nil, err + } repos := make([]LocalRepository, 0) + home, _ := os.UserHomeDir() walker := &repoWalker{ root: root, maxDepth: maxDepth, libraryRoot: filepath.Join(root, "Library"), cacheRoot: filepath.Join(root, ".cache"), + homeRoot: home, + goos: runtime.GOOS, ctx: ctx, } err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { @@ -797,13 +803,18 @@ type repoWalker struct { maxDepth int libraryRoot string cacheRoot string + homeRoot string + goos string ctx context.Context } // visit is the WalkDir callback. Returns a non-nil *LocalRepository when a git repo is found. func (w *repoWalker) visit(path string, d fs.DirEntry, err error) (*LocalRepository, error) { if err != nil { - return nil, nil //nolint:nilerr // skip entries that cannot be accessed + if path == w.root || fsdiagnostics.IsAccessDenied(err) { + return nil, err + } + return nil, nil //nolint:nilerr // skip non-permission entries that cannot be accessed } if w.ctx.Err() != nil { return nil, w.ctx.Err() @@ -847,6 +858,9 @@ func (w *repoWalker) skipByName(path string, d fs.DirEntry) error { return nil } name := d.Name() + if shouldSkipMacOSHomeChild(w.root, path, w.homeRoot, w.goos) { + return fs.SkipDir + } if (name == "Library" || name == ".cache") && filepath.Dir(path) == w.root { return fs.SkipDir } @@ -859,6 +873,19 @@ func (w *repoWalker) skipByName(path string, d fs.DirEntry) error { return nil } +func shouldSkipMacOSHomeChild(root, path, home, goos string) bool { + if goos != "darwin" || home == "" || !sameCanonicalPath(root, home) { + return false + } + if !sameCanonicalPath(filepath.Dir(path), root) { + return false + } + name := filepath.Base(path) + return strings.EqualFold(name, "Desktop") || + strings.EqualFold(name, "Documents") || + strings.EqualFold(name, "Downloads") +} + // makeRepo builds a LocalRepository from a .git entry path. func (w *repoWalker) makeRepo(path string, d fs.DirEntry) *LocalRepository { repoPath := filepath.Dir(path) diff --git a/apps/backend/internal/task/service/repository_discovery_state.go b/apps/backend/internal/task/service/repository_discovery_state.go new file mode 100644 index 0000000000..1e05c1a2cf --- /dev/null +++ b/apps/backend/internal/task/service/repository_discovery_state.go @@ -0,0 +1,713 @@ +package service + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/kandev/kandev/internal/common/fsdiagnostics" + "github.com/kandev/kandev/internal/task/models" +) + +const discoveryRefreshAge = 30 * time.Minute + +var ( + ErrDesktopDiscoveryUnavailable = errors.New("desktop discovery roots are unavailable") + ErrInvalidDiscoveryRoot = errors.New("invalid discovery root") +) + +const ( + discoveryTriggerManualRefresh = "manual_refresh" + discoveryTriggerStaleRefresh = "stale_refresh" + discoveryTriggerUserSelect = "user_select" +) + +// NormalizeRepositoryDiscoveryTrigger keeps scan diagnostics within the +// triggers understood by the discovery contract. Unknown or missing causes +// are treated as an explicit manual refresh. +func NormalizeRepositoryDiscoveryTrigger(trigger string) string { + switch trigger { + case discoveryTriggerManualRefresh, discoveryTriggerStaleRefresh, discoveryTriggerUserSelect: + return trigger + default: + return discoveryTriggerManualRefresh + } +} + +type discoveryCacheEntry struct { + roots []string + repositories []LocalRepository + rootStates []models.DesktopDiscoveryRoot + scanTime *time.Time + failedRoots []string +} + +type discoveryFlight struct { + done chan struct{} + result RepositoryDiscoveryResult + err error +} + +// DiscoveryRefreshAge is the freshness period used by the browser +// coordinator. The backend exposes the scan timestamp and keeps the actual +// scan operation explicit so callers can return cached choices immediately. +func DiscoveryRefreshAge() time.Duration { + return discoveryRefreshAge +} + +func (s *Service) ListDesktopDiscoveryRoots(ctx context.Context) ([]*models.DesktopDiscoveryRoot, error) { + if !s.discoveryConfig.DesktopRuntime || s.desktopRootStore == nil { + return nil, ErrDesktopDiscoveryUnavailable + } + return s.desktopRootStore.ListDesktopDiscoveryRoots(ctx) +} + +// GetLocalRepositoryDiscovery returns the current cached snapshot. It never +// starts a filesystem walk. Call RefreshLocalRepositoryDiscovery for an +// explicit or stale refresh. +func (s *Service) GetLocalRepositoryDiscovery(ctx context.Context, root string) (RepositoryDiscoveryResult, error) { + return s.getLocalRepositoryDiscovery(ctx, "", root) +} + +// GetLocalRepositoryDiscoveryForWorkspace applies the workspace visibility +// check before returning a cached discovery snapshot. +func (s *Service) GetLocalRepositoryDiscoveryForWorkspace( + ctx context.Context, + workspaceID, root string, +) (RepositoryDiscoveryResult, error) { + return s.getLocalRepositoryDiscovery(ctx, workspaceID, root) +} + +func (s *Service) getLocalRepositoryDiscovery( + ctx context.Context, + workspaceID, root string, +) (RepositoryDiscoveryResult, error) { + if err := s.authorizeWorkspaceID(ctx, workspaceID); err != nil { + return RepositoryDiscoveryResult{}, err + } + roots, err := s.resolveDiscoveryRoots(ctx, root) + if err != nil { + return RepositoryDiscoveryResult{}, err + } + states, err := s.discoveryRootStates(ctx, roots) + if err != nil { + return RepositoryDiscoveryResult{}, err + } + homeConfirmation, err := s.homeConfirmationRequired(ctx) + if err != nil { + return RepositoryDiscoveryResult{}, err + } + key := discoveryCacheKey(roots, s.discoveryMaxDepth()) + + s.discoveryCacheMu.Lock() + entry, cached := s.discoveryCache[key] + refreshing := s.discoveryFlights[key] != nil + s.discoveryCacheMu.Unlock() + + if !cached { + return RepositoryDiscoveryResult{ + Roots: roots, + RootStates: states, + DesktopRuntime: s.discoveryConfig.DesktopRuntime, + Refreshing: refreshing, + HomeConfirmationRequired: homeConfirmation, + }, nil + } + if len(states) == 0 { + states = cloneRootStates(entry.rootStates) + } + return RepositoryDiscoveryResult{ + Roots: roots, + Repositories: cloneRepositories(entry.repositories), + DesktopRuntime: s.discoveryConfig.DesktopRuntime, + RootStates: states, + ScanTime: cloneTime(entry.scanTime), + Refreshing: refreshing, + Cached: true, + HomeConfirmationRequired: homeConfirmation, + FailedRoots: append([]string(nil), entry.failedRoots...), + }, nil +} + +// RefreshLocalRepositoryDiscovery performs one scan for the normalized root +// set. Concurrent callers share the same in-flight scan. A failed root keeps +// the previous successful repository list in the cache. +func (s *Service) RefreshLocalRepositoryDiscovery(ctx context.Context, root string) (RepositoryDiscoveryResult, error) { + return s.refreshLocalRepositoryDiscovery(ctx, "", root, discoveryTriggerManualRefresh) +} + +// RefreshLocalRepositoryDiscoveryForWorkspace applies the workspace +// visibility check before starting or joining a discovery scan. +func (s *Service) RefreshLocalRepositoryDiscoveryForWorkspace( + ctx context.Context, + workspaceID, root string, +) (RepositoryDiscoveryResult, error) { + return s.RefreshLocalRepositoryDiscoveryForWorkspaceWithTrigger( + ctx, workspaceID, root, discoveryTriggerManualRefresh, + ) +} + +// RefreshLocalRepositoryDiscoveryForWorkspaceWithTrigger applies the +// workspace visibility check before starting or joining a scan. The validated +// trigger is retained in scan diagnostics so support can distinguish stale +// activation from a user refresh or root selection. +func (s *Service) RefreshLocalRepositoryDiscoveryForWorkspaceWithTrigger( + ctx context.Context, + workspaceID, root, trigger string, +) (RepositoryDiscoveryResult, error) { + return s.refreshLocalRepositoryDiscovery( + ctx, + workspaceID, + root, + NormalizeRepositoryDiscoveryTrigger(trigger), + ) +} + +func (s *Service) refreshLocalRepositoryDiscovery( + ctx context.Context, + workspaceID, root, trigger string, +) (RepositoryDiscoveryResult, error) { + trigger = NormalizeRepositoryDiscoveryTrigger(trigger) + if err := s.authorizeWorkspaceID(ctx, workspaceID); err != nil { + return RepositoryDiscoveryResult{}, err + } + roots, err := s.resolveDiscoveryRoots(ctx, root) + if err != nil { + return RepositoryDiscoveryResult{}, err + } + states, err := s.discoveryRootStates(ctx, roots) + if err != nil { + return RepositoryDiscoveryResult{}, err + } + homeConfirmation, err := s.homeConfirmationRequired(ctx) + if err != nil { + return RepositoryDiscoveryResult{}, err + } + if len(roots) == 0 { + s.logFilesystemInfo("repository discovery scan skipped", "repository.discovery.scan", root, trigger) + return RepositoryDiscoveryResult{ + Roots: roots, + RootStates: states, + DesktopRuntime: s.discoveryConfig.DesktopRuntime, + HomeConfirmationRequired: homeConfirmation, + }, nil + } + + key := discoveryCacheKey(roots, s.discoveryMaxDepth()) + s.discoveryCacheMu.Lock() + if flight := s.discoveryFlights[key]; flight != nil { + done := flight.done + s.discoveryCacheMu.Unlock() + select { + case <-done: + if errors.Is(flight.err, context.Canceled) || errors.Is(flight.err, context.DeadlineExceeded) { + return s.refreshLocalRepositoryDiscovery(ctx, workspaceID, root, trigger) + } + return flight.result, flight.err + case <-ctx.Done(): + return RepositoryDiscoveryResult{}, ctx.Err() + } + } + previous, hasPrevious := s.discoveryCache[key] + flight := &discoveryFlight{done: make(chan struct{})} + s.discoveryFlights[key] = flight + s.discoveryCacheMu.Unlock() + + result, scanErr := s.scanDiscoveryRoots(ctx, roots, previous, hasPrevious, homeConfirmation, trigger) + s.discoveryCacheMu.Lock() + flight.result = result + flight.err = scanErr + delete(s.discoveryFlights, key) + close(flight.done) + s.discoveryCacheMu.Unlock() + return result, scanErr +} + +func (s *Service) scanDiscoveryRoots( + ctx context.Context, + roots []string, + previous discoveryCacheEntry, + hasPrevious bool, + homeConfirmation bool, + trigger string, +) (RepositoryDiscoveryResult, error) { + trigger = NormalizeRepositoryDiscoveryTrigger(trigger) + found := make([]LocalRepository, 0) + failed := make([]string, 0) + for _, root := range roots { + if err := ctx.Err(); err != nil { + return RepositoryDiscoveryResult{}, err + } + s.logFilesystemInfo("repository discovery scan started", "repository.discovery.scan", root, trigger) + repositories, err := s.discoveryScanRoot(ctx, root, s.discoveryMaxDepth()) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return RepositoryDiscoveryResult{}, err + } + failed = append(failed, root) + s.logFilesystemFailure("repository.discovery.scan", root, trigger, err) + s.recordDesktopRootFailure(ctx, root, err) + continue + } + found = appendUniqueRepositories(found, repositories) + s.recordDesktopRootSuccess(ctx, root) + } + + states, err := s.discoveryRootStates(ctx, roots) + if err != nil { + return RepositoryDiscoveryResult{}, err + } + if len(failed) > 0 { + repositories := found + scanTime := (*time.Time)(nil) + cached := hasPrevious + if hasPrevious { + repositories = cloneRepositories(previous.repositories) + scanTime = cloneTime(previous.scanTime) + } + entry := discoveryCacheEntry{ + roots: append([]string(nil), roots...), + repositories: cloneRepositories(repositories), + rootStates: cloneRootStates(states), + scanTime: cloneTime(scanTime), + failedRoots: append([]string(nil), failed...), + } + s.storeDiscoveryCache(roots, entry) + return RepositoryDiscoveryResult{ + Roots: roots, + Repositories: repositories, + DesktopRuntime: s.discoveryConfig.DesktopRuntime, + RootStates: states, + ScanTime: scanTime, + Cached: cached, + HomeConfirmationRequired: homeConfirmation, + FailedRoots: failed, + }, nil + } + + now := s.discoveryClockNow() + entry := discoveryCacheEntry{ + roots: append([]string(nil), roots...), + repositories: cloneRepositories(found), + rootStates: cloneRootStates(states), + scanTime: &now, + } + s.storeDiscoveryCache(roots, entry) + return RepositoryDiscoveryResult{ + Roots: roots, + Repositories: found, + DesktopRuntime: s.discoveryConfig.DesktopRuntime, + RootStates: states, + ScanTime: &now, + HomeConfirmationRequired: homeConfirmation, + }, nil +} + +func (s *Service) storeDiscoveryCache(roots []string, entry discoveryCacheEntry) { + key := discoveryCacheKey(roots, s.discoveryMaxDepth()) + s.discoveryCacheMu.Lock() + s.discoveryCache[key] = entry + s.discoveryCacheMu.Unlock() +} + +func (s *Service) resolveDiscoveryRoots(ctx context.Context, requestedRoot string) ([]string, error) { + roots, err := s.effectiveDiscoveryRoots(ctx) + if err != nil { + return nil, err + } + if requestedRoot == "" { + return roots, nil + } + absRoot, err := filepath.Abs(filepath.Clean(requestedRoot)) + if err != nil { + return nil, fmt.Errorf("invalid root path: %w", err) + } + if !isPathAllowed(absRoot, roots) { + return nil, ErrPathNotAllowed + } + return []string{filepath.Clean(absRoot)}, nil +} + +func (s *Service) effectiveDiscoveryRoots(ctx context.Context) ([]string, error) { + roots := make([]string, 0, len(s.discoveryConfig.Roots)+2) + if len(s.discoveryConfig.Roots) > 0 { + roots = append(roots, s.discoveryConfig.Roots...) + } else if !s.discoveryConfig.DesktopRuntime { + if home, err := os.UserHomeDir(); err == nil { + roots = append(roots, home) + } + } + if s.discoveryConfig.DesktopRuntime && s.desktopRootStore != nil { + saved, err := s.desktopRootStore.ListDesktopDiscoveryRoots(ctx) + if err != nil { + return nil, err + } + for _, root := range saved { + if root != nil { + roots = append(roots, root.Path) + } + } + } + if s.repoCloneLocation != nil { + if base, err := s.repoCloneLocation.ExpandedBasePath(); err == nil && base != "" { + roots = append(roots, base) + } + } + return normalizeRoots(roots), nil +} + +func (s *Service) homeConfirmationRequired(ctx context.Context) (bool, error) { + if !s.discoveryConfig.DesktopRuntime || s.desktopRootStore == nil || len(s.discoveryConfig.Roots) > 0 { + return false, nil + } + roots, err := s.desktopRootStore.ListDesktopDiscoveryRoots(ctx) + if err != nil { + return false, err + } + if len(roots) > 0 { + return false, nil + } + migration, err := s.desktopRootStore.GetDesktopDiscoveryMigration(ctx) + if err != nil { + return false, err + } + return migration != nil && migration.HomeConfirmationRequired, nil +} + +func (s *Service) discoveryRootStates(ctx context.Context, roots []string) ([]models.DesktopDiscoveryRoot, error) { + stored := make(map[string]*models.DesktopDiscoveryRoot) + if s.discoveryConfig.DesktopRuntime && s.desktopRootStore != nil { + items, err := s.desktopRootStore.ListDesktopDiscoveryRoots(ctx) + if err != nil { + return nil, err + } + for _, item := range items { + if item != nil { + stored[filepath.Clean(item.Path)] = item + } + } + } + states := make([]models.DesktopDiscoveryRoot, 0, len(roots)) + for _, root := range roots { + if item := stored[filepath.Clean(root)]; item != nil { + states = append(states, *cloneRoot(item)) + continue + } + states = append(states, models.DesktopDiscoveryRoot{ + Path: root, + DisplayPath: displayDiscoveryPath(root), + State: models.DesktopDiscoveryRootConnected, + }) + } + return states, nil +} + +// AddDesktopDiscoveryRoot persists a canonical install-wide root and starts +// its first immediate scan. A scan failure is represented by root state so the +// caller can offer recovery without treating an inaccessible folder as a +// malformed request. +func (s *Service) AddDesktopDiscoveryRoot(ctx context.Context, path string) (*models.DesktopDiscoveryRoot, error) { + if !s.discoveryConfig.DesktopRuntime || s.desktopRootStore == nil { + return nil, ErrDesktopDiscoveryUnavailable + } + canonical, err := canonicalDiscoveryRoot(path) + if err != nil { + return nil, err + } + root, err := s.desktopRootStore.GetDesktopDiscoveryRoot(ctx, canonical) + if err != nil { + return nil, err + } + if root == nil { + root = &models.DesktopDiscoveryRoot{ + ID: uuid.NewString(), + Path: canonical, + DisplayPath: displayDiscoveryPath(canonical), + State: models.DesktopDiscoveryRootConnected, + } + if err := s.desktopRootStore.CreateDesktopDiscoveryRoot(ctx, root); err != nil { + return nil, err + } + } else { + root.State = models.DesktopDiscoveryRootConnected + root.LastFailureAt = nil + root.LastFailureCode = "" + if err := s.desktopRootStore.UpdateDesktopDiscoveryRoot(ctx, root); err != nil { + return nil, err + } + } + if isCurrentHomePath(canonical) { + if err := s.clearHomeConfirmation(ctx); err != nil { + return nil, err + } + } + s.invalidateDiscoveryCache() + if _, err := s.refreshLocalRepositoryDiscovery(ctx, "", "", discoveryTriggerUserSelect); err != nil { + return nil, err + } + return s.getRequiredDesktopDiscoveryRoot(ctx, canonical) +} + +// ReconnectDesktopDiscoveryRoot replaces the path for an inaccessible root, +// then performs one immediate scan under the new user-selected path. +func (s *Service) ReconnectDesktopDiscoveryRoot(ctx context.Context, oldPath, newPath string) (*models.DesktopDiscoveryRoot, error) { + if !s.discoveryConfig.DesktopRuntime || s.desktopRootStore == nil { + return nil, ErrDesktopDiscoveryUnavailable + } + canonical, err := canonicalDiscoveryRoot(newPath) + if err != nil { + return nil, err + } + oldLookupPath, err := normalizeDiscoveryRootLookupPath(oldPath) + if err != nil { + return nil, err + } + old, err := s.desktopRootStore.GetDesktopDiscoveryRoot(ctx, oldLookupPath) + if err != nil { + return nil, err + } + if old == nil { + return s.AddDesktopDiscoveryRoot(ctx, canonical) + } + conflict, err := s.desktopRootStore.GetDesktopDiscoveryRoot(ctx, canonical) + if err != nil { + return nil, err + } + if conflict != nil && conflict.ID != old.ID { + return nil, fmt.Errorf("%w: root already exists", ErrInvalidDiscoveryRoot) + } + old.Path = canonical + old.DisplayPath = displayDiscoveryPath(canonical) + old.State = models.DesktopDiscoveryRootConnected + old.LastScanAt = nil + old.LastFailureAt = nil + old.LastFailureCode = "" + if err := s.desktopRootStore.UpdateDesktopDiscoveryRoot(ctx, old); err != nil { + return nil, err + } + if isCurrentHomePath(canonical) { + if err := s.clearHomeConfirmation(ctx); err != nil { + return nil, err + } + } + s.invalidateDiscoveryCache() + if _, err := s.refreshLocalRepositoryDiscovery(ctx, "", "", discoveryTriggerUserSelect); err != nil { + return nil, err + } + return s.getRequiredDesktopDiscoveryRoot(ctx, canonical) +} + +func (s *Service) getRequiredDesktopDiscoveryRoot( + ctx context.Context, path string, +) (*models.DesktopDiscoveryRoot, error) { + root, err := s.desktopRootStore.GetDesktopDiscoveryRoot(ctx, path) + if err != nil { + return nil, err + } + if root == nil { + return nil, errors.New("desktop discovery root disappeared after scan") + } + return root, nil +} + +func normalizeDiscoveryRootLookupPath(path string) (string, error) { + if strings.TrimSpace(path) == "" { + return "", fmt.Errorf("%w: path is required", ErrInvalidDiscoveryRoot) + } + abs, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrInvalidDiscoveryRoot, err) + } + if canonical, err := filepath.EvalSymlinks(abs); err == nil { + return filepath.Clean(canonical), nil + } + return filepath.Clean(abs), nil +} + +func (s *Service) RemoveDesktopDiscoveryRoot(ctx context.Context, path string) error { + if !s.discoveryConfig.DesktopRuntime || s.desktopRootStore == nil { + return ErrDesktopDiscoveryUnavailable + } + if strings.TrimSpace(path) == "" { + return fmt.Errorf("%w: path is required", ErrInvalidDiscoveryRoot) + } + canonical, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return fmt.Errorf("%w: %v", ErrInvalidDiscoveryRoot, err) + } + if err := s.desktopRootStore.DeleteDesktopDiscoveryRoot(ctx, filepath.Clean(canonical)); err != nil { + return err + } + s.invalidateDiscoveryCache() + return nil +} + +func (s *Service) clearHomeConfirmation(ctx context.Context) error { + return s.desktopRootStore.SetDesktopDiscoveryMigration(ctx, &models.DesktopDiscoveryMigration{}) +} + +func (s *Service) recordDesktopRootSuccess(ctx context.Context, path string) { + if !s.discoveryConfig.DesktopRuntime || s.desktopRootStore == nil { + return + } + root, err := s.desktopRootStore.GetDesktopDiscoveryRoot(ctx, path) + if err != nil || root == nil { + return + } + now := s.discoveryClockNow() + root.State = models.DesktopDiscoveryRootConnected + root.LastScanAt = &now + root.LastFailureAt = nil + root.LastFailureCode = "" + _ = s.desktopRootStore.UpdateDesktopDiscoveryRoot(ctx, root) +} + +func (s *Service) recordDesktopRootFailure(ctx context.Context, path string, scanErr error) { + if !s.discoveryConfig.DesktopRuntime || s.desktopRootStore == nil { + return + } + root, err := s.desktopRootStore.GetDesktopDiscoveryRoot(ctx, path) + if err != nil || root == nil { + return + } + now := s.discoveryClockNow() + root.State = models.DesktopDiscoveryRootReconnectRequired + root.LastFailureAt = &now + root.LastFailureCode = discoveryFailureCode(scanErr) + _ = s.desktopRootStore.UpdateDesktopDiscoveryRoot(ctx, root) +} + +func discoveryFailureCode(err error) string { + switch { + case fsdiagnostics.IsAccessDenied(err): + return "permission_denied" + case errors.Is(err, os.ErrNotExist): + return "not_found" + default: + return "scan_failed" + } +} + +func canonicalDiscoveryRoot(path string) (string, error) { + if strings.TrimSpace(path) == "" { + return "", fmt.Errorf("%w: path is required", ErrInvalidDiscoveryRoot) + } + abs, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrInvalidDiscoveryRoot, err) + } + canonical, err := filepath.EvalSymlinks(abs) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrInvalidDiscoveryRoot, err) + } + info, err := os.Stat(canonical) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrInvalidDiscoveryRoot, err) + } + if !info.IsDir() { + return "", fmt.Errorf("%w: path is not a directory", ErrInvalidDiscoveryRoot) + } + return filepath.Clean(canonical), nil +} + +func displayDiscoveryPath(path string) string { + home, err := os.UserHomeDir() + if err != nil { + return path + } + rel, err := filepath.Rel(filepath.Clean(home), filepath.Clean(path)) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return path + } + if rel == "." { + return "~" + } + return filepath.Join("~", rel) +} + +func isCurrentHomePath(path string) bool { + home, err := os.UserHomeDir() + return err == nil && sameCanonicalPath(filepath.Clean(home), filepath.Clean(path)) +} + +func (s *Service) invalidateDiscoveryCache() { + s.discoveryCacheMu.Lock() + s.discoveryCache = make(map[string]discoveryCacheEntry) + s.discoveryCacheMu.Unlock() +} + +func (s *Service) discoveryClockNow() time.Time { + if s.discoveryNow != nil { + return s.discoveryNow().UTC() + } + return time.Now().UTC() +} + +func (s *Service) SetDiscoveryClockForTest(now func() time.Time) { + s.discoveryNow = now +} + +func appendUniqueRepositories(dst []LocalRepository, src []LocalRepository) []LocalRepository { + seen := make(map[string]struct{}, len(dst)+len(src)) + for _, repo := range dst { + seen[repo.Path] = struct{}{} + } + for _, repo := range src { + if _, ok := seen[repo.Path]; ok { + continue + } + seen[repo.Path] = struct{}{} + dst = append(dst, repo) + } + sort.Slice(dst, func(i, j int) bool { return dst[i].Path < dst[j].Path }) + return dst +} + +func discoveryCacheKey(roots []string, maxDepth int) string { + ordered := append([]string(nil), roots...) + sort.Strings(ordered) + return fmt.Sprintf("%d:%s", maxDepth, strings.Join(ordered, "\x00")) +} + +func cloneRepositories(repositories []LocalRepository) []LocalRepository { + return append([]LocalRepository(nil), repositories...) +} + +func cloneRootStates(states []models.DesktopDiscoveryRoot) []models.DesktopDiscoveryRoot { + result := make([]models.DesktopDiscoveryRoot, 0, len(states)) + for i := range states { + result = append(result, *cloneRoot(&states[i])) + } + return result +} + +func cloneRoot(root *models.DesktopDiscoveryRoot) *models.DesktopDiscoveryRoot { + if root == nil { + return nil + } + result := *root + if root.LastScanAt != nil { + value := *root.LastScanAt + result.LastScanAt = &value + } + if root.LastFailureAt != nil { + value := *root.LastFailureAt + result.LastFailureAt = &value + } + return &result +} + +func cloneTime(value *time.Time) *time.Time { + if value == nil { + return nil + } + result := *value + return &result +} diff --git a/apps/backend/internal/task/service/repository_discovery_test.go b/apps/backend/internal/task/service/repository_discovery_test.go index 5eaef99f00..42e1b3c04b 100644 --- a/apps/backend/internal/task/service/repository_discovery_test.go +++ b/apps/backend/internal/task/service/repository_discovery_test.go @@ -8,7 +8,9 @@ import ( "path/filepath" "sort" "strings" + "sync" "testing" + "time" "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/common/logger" @@ -16,6 +18,9 @@ import ( "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" ) func TestDiscoverLocalRepositoriesSkipsIgnoredRoots(t *testing.T) { @@ -55,6 +60,361 @@ func TestDiscoverLocalRepositoriesSkipsIgnoredRoots(t *testing.T) { } } +func TestMacOSHomeDiscoverySkipsProtectedChildrenButScansExplicitRoot(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + for _, name := range []string{"Desktop", "Documents", "Downloads"} { + if err := os.MkdirAll(filepath.Join(home, name), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", name, err) + } + if !shouldSkipMacOSHomeChild(home, filepath.Join(home, name), home, "darwin") { + t.Fatalf("protected child %s was not skipped", name) + } + } + if shouldSkipMacOSHomeChild(home, filepath.Join(home, "Code"), home, "darwin") { + t.Fatal("ordinary Home child was skipped") + } + if shouldSkipMacOSHomeChild( + filepath.Join(home, "Documents"), + filepath.Join(home, "Documents", "project"), + home, + "darwin", + ) { + t.Fatal("an explicitly selected protected root was skipped") + } +} + +func TestDesktopDiscoveryWithoutRootDoesNotScanHome(t *testing.T) { + home := filepath.Join(t.TempDir(), "home") + if err := os.MkdirAll(home, 0o755); err != nil { + t.Fatalf("create home: %v", err) + } + makeRepo(t, filepath.Join(home, "project")) + t.Setenv("HOME", home) + + svc := newDiscoveryService(t, home) + svc.discoveryConfig = RepositoryDiscoveryConfig{DesktopRuntime: true, MaxDepth: 6} + var scanCalls int + svc.discoveryScanRoot = func(context.Context, string, int) ([]LocalRepository, error) { + scanCalls++ + return nil, nil + } + + result, err := svc.GetLocalRepositoryDiscovery(context.Background(), "") + if err != nil { + t.Fatalf("get desktop discovery snapshot: %v", err) + } + if len(result.Roots) != 0 || len(result.Repositories) != 0 { + t.Fatalf("unconfigured desktop discovery returned filesystem data: %+v", result) + } + if _, err := svc.RefreshLocalRepositoryDiscovery(context.Background(), ""); err != nil { + t.Fatalf("refresh unconfigured desktop discovery: %v", err) + } + if scanCalls != 0 { + t.Fatalf("unconfigured desktop discovery scanned Home %d time(s)", scanCalls) + } +} + +func TestRepositoryDiscoveryScanUsesValidatedTrigger(t *testing.T) { + for _, test := range []struct { + name string + trigger string + want string + }{ + {name: "manual", trigger: "manual_refresh", want: "manual_refresh"}, + {name: "stale", trigger: "stale_refresh", want: "stale_refresh"}, + {name: "user selection", trigger: "user_select", want: "user_select"}, + {name: "unknown", trigger: "not-a-trigger", want: "manual_refresh"}, + } { + t.Run(test.name, func(t *testing.T) { + root := t.TempDir() + svc := newDiscoveryService(t, root) + core, logs := observer.New(zapcore.InfoLevel) + observedLogger, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("create observer logger: %v", err) + } + svc.logger = observedLogger + svc.discoveryConfig = RepositoryDiscoveryConfig{Roots: []string{root}, MaxDepth: 6} + svc.discoveryScanRoot = func(context.Context, string, int) ([]LocalRepository, error) { + return nil, nil + } + + if _, err := svc.refreshLocalRepositoryDiscovery( + context.Background(), "", "", test.trigger, + ); err != nil { + t.Fatalf("refresh discovery: %v", err) + } + entries := logs.FilterMessage("repository discovery scan started").All() + if len(entries) != 1 { + t.Fatalf("scan-start log entries = %d, want 1", len(entries)) + } + if got := entries[0].ContextMap()["trigger"]; got != test.want { + t.Fatalf("scan trigger = %v, want %q", got, test.want) + } + }) + } +} + +func TestDesktopDiscoveryRootPersistsAcrossServiceRestart(t *testing.T) { + root := t.TempDir() + makeRepo(t, filepath.Join(root, "project")) + svc, _, repo := createTestService(t) + config := RepositoryDiscoveryConfig{DesktopRuntime: true, MaxDepth: 6} + svc.discoveryConfig = config + svc.desktopRootStore = repo + + selected, err := svc.AddDesktopDiscoveryRoot(context.Background(), root) + if err != nil { + t.Fatalf("add desktop discovery root: %v", err) + } + if selected.Path != filepath.Clean(root) || selected.State != models.DesktopDiscoveryRootConnected { + t.Fatalf("selected root = %+v", selected) + } + + restarted := NewService(Repos{DiscoveryRoots: repo}, NewMockEventBus(), svc.logger, config) + roots, err := restarted.ListDesktopDiscoveryRoots(context.Background()) + if err != nil { + t.Fatalf("list roots after restart: %v", err) + } + if len(roots) != 1 || roots[0].Path != filepath.Clean(root) { + t.Fatalf("persisted roots = %+v", roots) + } + result, err := restarted.RefreshLocalRepositoryDiscovery(context.Background(), "") + if err != nil { + t.Fatalf("refresh after restart: %v", err) + } + if len(result.Repositories) != 1 || result.Repositories[0].Path != filepath.Join(root, "project") { + t.Fatalf("repositories after restart = %+v", result.Repositories) + } +} + +func TestDesktopDiscoveryRefreshSharesOneScan(t *testing.T) { + root := t.TempDir() + svc := newDiscoveryService(t, root) + svc.discoveryConfig = RepositoryDiscoveryConfig{Roots: []string{root}, MaxDepth: 6} + started := make(chan struct{}) + release := make(chan struct{}) + var callsMu sync.Mutex + calls := 0 + svc.discoveryScanRoot = func(context.Context, string, int) ([]LocalRepository, error) { + callsMu.Lock() + calls++ + callsMu.Unlock() + close(started) + <-release + return []LocalRepository{{Path: filepath.Join(root, "project"), Name: "project"}}, nil + } + + results := make(chan RepositoryDiscoveryResult, 2) + errs := make(chan error, 2) + go func() { + result, err := svc.RefreshLocalRepositoryDiscovery(context.Background(), "") + results <- result + errs <- err + }() + <-started + secondReturned := make(chan struct{}) + go func() { + result, err := svc.RefreshLocalRepositoryDiscovery(context.Background(), "") + results <- result + errs <- err + close(secondReturned) + }() + select { + case <-secondReturned: + t.Fatal("second refresh completed before the shared scan was released") + case <-time.After(10 * time.Millisecond): + } + close(release) + for range 2 { + if err := <-errs; err != nil { + t.Fatalf("shared refresh error: %v", err) + } + result := <-results + if len(result.Repositories) != 1 { + t.Fatalf("shared refresh repositories = %+v", result.Repositories) + } + } + callsMu.Lock() + defer callsMu.Unlock() + if calls != 1 { + t.Fatalf("discovery scan calls = %d, want 1", calls) + } +} + +func TestDesktopDiscoveryCancelledFlightDoesNotPoisonWaitingCaller(t *testing.T) { + root := t.TempDir() + svc := newDiscoveryService(t, root) + svc.discoveryConfig = RepositoryDiscoveryConfig{Roots: []string{root}, MaxDepth: 6} + started := make(chan struct{}) + release := make(chan struct{}) + var callsMu sync.Mutex + calls := 0 + svc.discoveryScanRoot = func(ctx context.Context, _ string, _ int) ([]LocalRepository, error) { + callsMu.Lock() + calls++ + callNumber := calls + callsMu.Unlock() + if callNumber == 1 { + close(started) + <-release + return nil, ctx.Err() + } + return []LocalRepository{{Path: filepath.Join(root, "project"), Name: "project"}}, nil + } + + ownerCtx, cancelOwner := context.WithCancel(context.Background()) + ownerErr := make(chan error, 1) + go func() { + _, err := svc.RefreshLocalRepositoryDiscovery(ownerCtx, "") + ownerErr <- err + }() + <-started + + waiterResult := make(chan RepositoryDiscoveryResult, 1) + waiterErr := make(chan error, 1) + go func() { + result, err := svc.RefreshLocalRepositoryDiscovery(context.Background(), "") + waiterResult <- result + waiterErr <- err + }() + + cancelOwner() + close(release) + if err := <-ownerErr; !errors.Is(err, context.Canceled) { + t.Fatalf("owner error = %v, want context canceled", err) + } + select { + case err := <-waiterErr: + if err != nil { + t.Fatalf("waiting caller error = %v", err) + } + result := <-waiterResult + if len(result.Repositories) != 1 { + t.Fatalf("waiting caller repositories = %+v", result.Repositories) + } + case <-time.After(2 * time.Second): + t.Fatal("waiting caller did not retry the canceled flight") + } + + callsMu.Lock() + defer callsMu.Unlock() + if calls != 2 { + t.Fatalf("discovery scan calls = %d, want 2", calls) + } +} + +func TestRepoWalkerPropagatesAccessDenied(t *testing.T) { + root := t.TempDir() + walker := &repoWalker{root: root, ctx: context.Background()} + + if _, err := walker.visit(root, nil, os.ErrPermission); !errors.Is(err, os.ErrPermission) { + t.Fatalf("root access error = %v, want permission denied", err) + } + child := filepath.Join(root, "private") + if _, err := walker.visit(child, nil, os.ErrPermission); !errors.Is(err, os.ErrPermission) { + t.Fatalf("child access error = %v, want permission denied", err) + } +} + +func TestReconnectDesktopDiscoveryRootNormalizesOldPath(t *testing.T) { + svc, _, repo := createTestService(t) + svc.discoveryConfig = RepositoryDiscoveryConfig{DesktopRuntime: true, MaxDepth: 6} + svc.desktopRootStore = repo + oldRoot := t.TempDir() + newRoot := t.TempDir() + + selected, err := svc.AddDesktopDiscoveryRoot(context.Background(), oldRoot) + if err != nil { + t.Fatalf("add old discovery root: %v", err) + } + oldPath := oldRoot + string(os.PathSeparator) + if alias := filepath.Join(t.TempDir(), "old-root"); os.Symlink(oldRoot, alias) == nil { + oldPath = alias + } + + reconnected, err := svc.ReconnectDesktopDiscoveryRoot(context.Background(), oldPath, newRoot) + if err != nil { + t.Fatalf("reconnect discovery root: %v", err) + } + if reconnected.ID != selected.ID || reconnected.Path != filepath.Clean(newRoot) { + t.Fatalf("reconnected root = %+v, want id %q and path %q", reconnected, selected.ID, newRoot) + } + roots, err := svc.ListDesktopDiscoveryRoots(context.Background()) + if err != nil { + t.Fatalf("list reconnected roots: %v", err) + } + if len(roots) != 1 || roots[0].Path != filepath.Clean(newRoot) { + t.Fatalf("roots after reconnect = %+v", roots) + } +} + +func TestDesktopDiscoveryFailurePreservesCachedRepositories(t *testing.T) { + root := t.TempDir() + repositoryPath := filepath.Join(root, "project") + makeRepo(t, repositoryPath) + svc := newDiscoveryService(t, root) + svc.discoveryConfig = RepositoryDiscoveryConfig{DesktopRuntime: true, MaxDepth: 6} + var fail bool + svc.discoveryScanRoot = func(context.Context, string, int) ([]LocalRepository, error) { + if fail { + return nil, os.ErrPermission + } + return []LocalRepository{{Path: repositoryPath, Name: "project"}}, nil + } + + if _, err := svc.AddDesktopDiscoveryRoot(context.Background(), root); err != nil { + t.Fatalf("seed desktop discovery cache: %v", err) + } + fail = true + result, err := svc.RefreshLocalRepositoryDiscovery(context.Background(), "") + if err != nil { + t.Fatalf("partial discovery refresh: %v", err) + } + if len(result.Repositories) != 1 || result.Repositories[0].Path != repositoryPath { + t.Fatalf("cached repositories were not preserved: %+v", result.Repositories) + } + if len(result.FailedRoots) != 1 || result.FailedRoots[0] != root { + t.Fatalf("failed roots = %+v", result.FailedRoots) + } + roots, err := svc.ListDesktopDiscoveryRoots(context.Background()) + if err != nil { + t.Fatalf("list failed root: %v", err) + } + if roots[0].State != models.DesktopDiscoveryRootReconnectRequired || roots[0].LastFailureCode != "permission_denied" { + t.Fatalf("failed root state = %+v", roots[0]) + } +} + +func TestDesktopDiscoveryMigrationCanRequireHomeConfirmation(t *testing.T) { + svc, _, repo := createTestService(t) + if err := repo.SetDesktopDiscoveryMigration(context.Background(), &models.DesktopDiscoveryMigration{ + HomeConfirmationRequired: true, + }); err != nil { + t.Fatalf("set migration state: %v", err) + } + svc.discoveryConfig = RepositoryDiscoveryConfig{DesktopRuntime: true} + svc.desktopRootStore = repo + + result, err := svc.GetLocalRepositoryDiscovery(context.Background(), "") + if err != nil { + t.Fatalf("get migration snapshot: %v", err) + } + if !result.HomeConfirmationRequired || len(result.Roots) != 0 { + t.Fatalf("migration snapshot = %+v", result) + } + if _, err := svc.AddDesktopDiscoveryRoot(context.Background(), t.TempDir()); err != nil { + t.Fatalf("select root while migration is pending: %v", err) + } + result, err = svc.GetLocalRepositoryDiscovery(context.Background(), "") + if err != nil { + t.Fatalf("get cleared migration snapshot: %v", err) + } + if result.HomeConfirmationRequired { + t.Fatal("root selection did not clear Home confirmation") + } +} + func TestValidateLocalRepositoryPath(t *testing.T) { root := t.TempDir() svc := newDiscoveryService(t, root) @@ -668,18 +1028,19 @@ func newDiscoveryService(t *testing.T, root string) *Service { log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) eventBus := bus.NewMemoryEventBus(log) return NewService(Repos{ - Workspaces: repo, - Tasks: repo, - TaskRepos: repo, - Workflows: repo, - Messages: repo, - Turns: repo, - Sessions: repo, - GitSnapshots: repo, - RepoEntities: repo, - Executors: repo, - Environments: repo, - Reviews: repo, + Workspaces: repo, + Tasks: repo, + TaskRepos: repo, + Workflows: repo, + Messages: repo, + Turns: repo, + Sessions: repo, + GitSnapshots: repo, + RepoEntities: repo, + DiscoveryRoots: repo, + Executors: repo, + Environments: repo, + Reviews: repo, }, eventBus, log, RepositoryDiscoveryConfig{ Roots: []string{root}, MaxDepth: 6, diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index d24dd2b531..4d76c9ebec 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -7,9 +7,11 @@ import ( "fmt" "strings" "sync" + "time" "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/common/fsdiagnostics" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/secrets" @@ -312,6 +314,7 @@ type Repos struct { Sessions repository.SessionRepository GitSnapshots repository.GitSnapshotRepository RepoEntities repository.RepositoryEntityRepository + DiscoveryRoots repository.DesktopDiscoveryRootRepository RepositorySets repository.RepositorySetRepository BranchPolicies repository.RepositoryBranchPolicyRepository RepositoryCleanup repository.RepositoryCleanupRepository @@ -343,6 +346,7 @@ type Service struct { sessions repository.SessionRepository gitSnapshots repository.GitSnapshotRepository repoEntities repository.RepositoryEntityRepository + desktopRootStore repository.DesktopDiscoveryRootRepository repositorySets repository.RepositorySetRepository branchPolicies repository.RepositoryBranchPolicyRepository repositoryCleanup repository.RepositoryCleanupRepository @@ -363,6 +367,12 @@ type Service struct { eventBus bus.EventBus logger *logger.Logger discoveryConfig RepositoryDiscoveryConfig + discoveryCacheMu sync.Mutex + discoveryCache map[string]discoveryCacheEntry + discoveryFlights map[string]*discoveryFlight + discoveryNow func() time.Time + discoveryScanRoot func(context.Context, string, int) ([]LocalRepository, error) + filesystemWarnings *fsdiagnostics.WarningLimiter worktreeCleanup WorktreeCleanup canvasCleanup CanvasCleanup executionStopper TaskExecutionStopper @@ -509,6 +519,7 @@ func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discover sessions: repos.Sessions, gitSnapshots: repos.GitSnapshots, repoEntities: repos.RepoEntities, + desktopRootStore: repos.DiscoveryRoots, repositorySets: repos.RepositorySets, branchPolicies: repos.BranchPolicies, repositoryCleanup: repos.RepositoryCleanup, @@ -524,6 +535,11 @@ func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discover eventBus: eventBus, logger: log, discoveryConfig: discoveryConfig, + discoveryCache: make(map[string]discoveryCacheEntry), + discoveryFlights: make(map[string]*discoveryFlight), + discoveryNow: time.Now, + discoveryScanRoot: scanRootForRepos, + filesystemWarnings: fsdiagnostics.NewWarningLimiter(0), branchFetcher: newBranchFetcher(log.Zap()), lastTaskActivity: make(map[string]v1.ForegroundActivity), lastTaskSubagentCount: make(map[string]int), diff --git a/apps/backend/internal/task/service/service_access_test.go b/apps/backend/internal/task/service/service_access_test.go index c173d305a0..03a365e955 100644 --- a/apps/backend/internal/task/service/service_access_test.go +++ b/apps/backend/internal/task/service/service_access_test.go @@ -113,6 +113,19 @@ func TestWorkspaceScopingListAndGet(t *testing.T) { } } +func TestWorkspaceScopingDiscoveryRejectsForeignWorkspace(t *testing.T) { + svc, _, repo := createTestService(t) + seedScopedWorkspaces(t, repo) + svc.discoveryConfig = RepositoryDiscoveryConfig{Roots: []string{t.TempDir()}, MaxDepth: 2} + + if _, err := svc.GetLocalRepositoryDiscoveryForWorkspace(ctxAs("user-a"), "ws-b", ""); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + t.Fatalf("get foreign discovery snapshot: %v", err) + } + if _, err := svc.RefreshLocalRepositoryDiscoveryForWorkspace(ctxAs("user-a"), "ws-b", ""); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + t.Fatalf("refresh foreign discovery snapshot: %v", err) + } +} + func TestWorkspaceScopingMutations(t *testing.T) { svc, _, repo := createTestService(t) seedScopedWorkspaces(t, repo) diff --git a/apps/backend/internal/webapp/payload.go b/apps/backend/internal/webapp/payload.go index f85eabd0fc..02b7f65e1a 100644 --- a/apps/backend/internal/webapp/payload.go +++ b/apps/backend/internal/webapp/payload.go @@ -59,6 +59,15 @@ type RuntimeConfig struct { // same " Kandev" for the // /api/v1/app-state boot path, which never renders through the shell. TitlePrefix string `json:"titlePrefix,omitempty"` + // NativeFolderPickerAvailable is true only when the desktop shell launched + // this backend and can service the narrow native folder-picker command. + // Browsers must continue to use the HTTP directory picker. + NativeFolderPickerAvailable bool `json:"nativeFolderPickerAvailable,omitempty"` + // DesktopRuntime identifies the launch policy selected by the backend + // process marker. It is separate from the native picker capability so an + // ordinary browser connected to a desktop backend keeps desktop discovery + // policy while using the HTTP picker. + DesktopRuntime bool `json:"desktopRuntime,omitempty"` } // BootError is a serializable non-fatal boot-data error for partial hydration. diff --git a/apps/backend/internal/worktree/manager_lifecycle.go b/apps/backend/internal/worktree/manager_lifecycle.go index 2fcdeeee30..c8adb9b911 100644 --- a/apps/backend/internal/worktree/manager_lifecycle.go +++ b/apps/backend/internal/worktree/manager_lifecycle.go @@ -590,6 +590,13 @@ func requestBranchIdentitySlug(req CreateRequest) string { return SanitizeBranchSlug(req.BranchSlug) } +func recreateSourceBranch(existingBranch, checkoutBranch string) string { + if checkoutBranch != "" { + return checkoutBranch + } + return existingBranch +} + // resolveBaseRefWithFallback resolves the base ref for a new worktree, optionally // pulling from origin first, and falling back to req.FallbackBaseBranch when the // requested base branch is missing. When the fallback path is taken, req.BaseBranch @@ -1951,23 +1958,22 @@ func (m *Manager) recreate(ctx context.Context, existing *Worktree, req CreateRe } refreshedStartPoint := "" if req.RemoteSyncHandled && req.RemoteContribution == nil && emptyRemoteBaseRef == "" { - sourceBranch := existing.Branch - if req.CheckoutBranch != "" { - sourceBranch = req.CheckoutBranch - } + sourceBranch := recreateSourceBranch(existing.Branch, req.CheckoutBranch) selectedRef, prepareErr := m.prepareBranchFromRefreshedOrigin( ctx, req.RepositoryPath, existing.Branch, sourceBranch, req.PRNumber, ) if prepareErr != nil { return nil, prepareErr } - if selectedRef == "" { + if selectedRef == "" && !req.AllowBranchReplacement { err := &BranchUnrecoverableError{Branch: sourceBranch} - if req.AllowBranchReplacement { - return m.replaceUnrecoverableWorktree(ctx, existing, req, err) - } return nil, fmt.Errorf("%w: refreshed branch %q was not materialized", err, sourceBranch) } + if selectedRef == "" { + return m.replaceUnrecoverableWorktree( + ctx, existing, req, &BranchUnrecoverableError{Branch: sourceBranch}, + ) + } if selectedRef != existing.Branch { refreshedStartPoint = selectedRef } diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 70cd1ec0c2..01cdfb5e7c 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -774,7 +774,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -949,7 +949,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1856,6 +1856,7 @@ dependencies = [ "serde_with", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-single-instance", @@ -2045,7 +2046,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2835,6 +2836,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -2874,7 +2899,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2930,7 +2955,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3315,7 +3340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3659,6 +3684,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-notification" version = "2.3.3" @@ -3870,7 +3937,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4220,7 +4287,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4249,7 +4316,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4627,7 +4694,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 44df973b0f..a5ead9874d 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -25,6 +25,7 @@ desktop-runtime = [ "dep:tauri-plugin-opener", "dep:tauri-plugin-updater", "dep:tauri-plugin-notification", + "dep:tauri-plugin-dialog", "dep:tauri-plugin-single-instance", "dep:tauri-runtime", ] @@ -49,4 +50,5 @@ tauri-plugin-single-instance = { version = "=2.4.3", optional = true } tauri-plugin-updater = { version = "=2.10.1", optional = true } tauri-plugin-notification = { version = "=2.3.3", optional = true } tauri-plugin-opener = { version = "=2.5.4", optional = true } +tauri-plugin-dialog = { version = "=2.7.2", optional = true } url = "2" diff --git a/apps/desktop/src-tauri/Info.plist b/apps/desktop/src-tauri/Info.plist new file mode 100644 index 0000000000..044b551496 --- /dev/null +++ b/apps/desktop/src-tauri/Info.plist @@ -0,0 +1,12 @@ + + + + + NSDesktopFolderUsageDescription + Kandev needs access to folders you choose to discover local repositories. + NSDocumentsFolderUsageDescription + Kandev needs access to folders you choose to discover local repositories. + NSDownloadsFolderUsageDescription + Kandev needs access to folders you choose to discover local repositories. + + diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 85fe915526..4788fcb05f 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -8,6 +8,7 @@ fn main() { "get_native_notification_permission", "request_native_notification_permission", "open_external_url", + "pick_directory", ]); tauri_build::try_build(tauri_build::Attributes::new().app_manifest(manifest)) .expect("failed to build Tauri desktop application"); diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index ff2126d6d0..c5d212e4e1 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -16,6 +16,7 @@ "allow-show-native-notification", "allow-get-native-notification-permission", "allow-request-native-notification-permission", - "allow-open-external-url" + "allow-open-external-url", + "allow-pick-directory" ] } diff --git a/apps/desktop/src-tauri/gen/schemas/acl-manifests.json b/apps/desktop/src-tauri/gen/schemas/acl-manifests.json index 4c6f8de462..44d74bda45 100644 --- a/apps/desktop/src-tauri/gen/schemas/acl-manifests.json +++ b/apps/desktop/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"__app-acl__":{"default_permission":null,"permissions":{"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-get-native-notification-permission":{"identifier":"allow-get-native-notification-permission","description":"Enables the get_native_notification_permission command without any pre-configured scope.","commands":{"allow":["get_native_notification_permission"],"deny":[]}},"allow-get-update-state":{"identifier":"allow-get-update-state","description":"Enables the get_update_state command without any pre-configured scope.","commands":{"allow":["get_update_state"],"deny":[]}},"allow-install-update":{"identifier":"allow-install-update","description":"Enables the install_update command without any pre-configured scope.","commands":{"allow":["install_update"],"deny":[]}},"allow-open-external-url":{"identifier":"allow-open-external-url","description":"Enables the open_external_url command without any pre-configured scope.","commands":{"allow":["open_external_url"],"deny":[]}},"allow-request-native-notification-permission":{"identifier":"allow-request-native-notification-permission","description":"Enables the request_native_notification_permission command without any pre-configured scope.","commands":{"allow":["request_native_notification_permission"],"deny":[]}},"allow-show-native-notification":{"identifier":"allow-show-native-notification","description":"Enables the show_native_notification command without any pre-configured scope.","commands":{"allow":["show_native_notification"],"deny":[]}},"deny-check-for-updates":{"identifier":"deny-check-for-updates","description":"Denies the check_for_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["check_for_updates"]}},"deny-get-native-notification-permission":{"identifier":"deny-get-native-notification-permission","description":"Denies the get_native_notification_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["get_native_notification_permission"]}},"deny-get-update-state":{"identifier":"deny-get-update-state","description":"Denies the get_update_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_update_state"]}},"deny-install-update":{"identifier":"deny-install-update","description":"Denies the install_update command without any pre-configured scope.","commands":{"allow":[],"deny":["install_update"]}},"deny-open-external-url":{"identifier":"deny-open-external-url","description":"Denies the open_external_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_external_url"]}},"deny-request-native-notification-permission":{"identifier":"deny-request-native-notification-permission","description":"Denies the request_native_notification_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_native_notification_permission"]}},"deny-show-native-notification":{"identifier":"deny-show-native-notification","description":"Denies the show_native_notification command without any pre-configured scope.","commands":{"allow":[],"deny":["show_native_notification"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"__app-acl__":{"default_permission":null,"permissions":{"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-get-native-notification-permission":{"identifier":"allow-get-native-notification-permission","description":"Enables the get_native_notification_permission command without any pre-configured scope.","commands":{"allow":["get_native_notification_permission"],"deny":[]}},"allow-get-update-state":{"identifier":"allow-get-update-state","description":"Enables the get_update_state command without any pre-configured scope.","commands":{"allow":["get_update_state"],"deny":[]}},"allow-install-update":{"identifier":"allow-install-update","description":"Enables the install_update command without any pre-configured scope.","commands":{"allow":["install_update"],"deny":[]}},"allow-open-external-url":{"identifier":"allow-open-external-url","description":"Enables the open_external_url command without any pre-configured scope.","commands":{"allow":["open_external_url"],"deny":[]}},"allow-pick-directory":{"identifier":"allow-pick-directory","description":"Enables the pick_directory command without any pre-configured scope.","commands":{"allow":["pick_directory"],"deny":[]}},"allow-request-native-notification-permission":{"identifier":"allow-request-native-notification-permission","description":"Enables the request_native_notification_permission command without any pre-configured scope.","commands":{"allow":["request_native_notification_permission"],"deny":[]}},"allow-show-native-notification":{"identifier":"allow-show-native-notification","description":"Enables the show_native_notification command without any pre-configured scope.","commands":{"allow":["show_native_notification"],"deny":[]}},"deny-check-for-updates":{"identifier":"deny-check-for-updates","description":"Denies the check_for_updates command without any pre-configured scope.","commands":{"allow":[],"deny":["check_for_updates"]}},"deny-get-native-notification-permission":{"identifier":"deny-get-native-notification-permission","description":"Denies the get_native_notification_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["get_native_notification_permission"]}},"deny-get-update-state":{"identifier":"deny-get-update-state","description":"Denies the get_update_state command without any pre-configured scope.","commands":{"allow":[],"deny":["get_update_state"]}},"deny-install-update":{"identifier":"deny-install-update","description":"Denies the install_update command without any pre-configured scope.","commands":{"allow":[],"deny":["install_update"]}},"deny-open-external-url":{"identifier":"deny-open-external-url","description":"Denies the open_external_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_external_url"]}},"deny-pick-directory":{"identifier":"deny-pick-directory","description":"Denies the pick_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["pick_directory"]}},"deny-request-native-notification-permission":{"identifier":"deny-request-native-notification-permission","description":"Denies the request_native_notification_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_native_notification_permission"]}},"deny-show-native-notification":{"identifier":"deny-show-native-notification","description":"Denies the show_native_notification command without any pre-configured scope.","commands":{"allow":[],"deny":["show_native_notification"]}}},"permission_sets":{},"global_scope_schema":null},"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)","commands":{"allow":["message"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)","commands":{"allow":[],"deny":["message"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"notification":{"default_permission":{"identifier":"default","description":"This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n","permissions":["allow-is-permission-granted","allow-request-permission","allow-notify","allow-register-action-types","allow-register-listener","allow-cancel","allow-get-pending","allow-remove-active","allow-get-active","allow-check-permissions","allow-show","allow-batch","allow-list-channels","allow-delete-channel","allow-create-channel","allow-permission-state"]},"permissions":{"allow-batch":{"identifier":"allow-batch","description":"Enables the batch command without any pre-configured scope.","commands":{"allow":["batch"],"deny":[]}},"allow-cancel":{"identifier":"allow-cancel","description":"Enables the cancel command without any pre-configured scope.","commands":{"allow":["cancel"],"deny":[]}},"allow-check-permissions":{"identifier":"allow-check-permissions","description":"Enables the check_permissions command without any pre-configured scope.","commands":{"allow":["check_permissions"],"deny":[]}},"allow-create-channel":{"identifier":"allow-create-channel","description":"Enables the create_channel command without any pre-configured scope.","commands":{"allow":["create_channel"],"deny":[]}},"allow-delete-channel":{"identifier":"allow-delete-channel","description":"Enables the delete_channel command without any pre-configured scope.","commands":{"allow":["delete_channel"],"deny":[]}},"allow-get-active":{"identifier":"allow-get-active","description":"Enables the get_active command without any pre-configured scope.","commands":{"allow":["get_active"],"deny":[]}},"allow-get-pending":{"identifier":"allow-get-pending","description":"Enables the get_pending command without any pre-configured scope.","commands":{"allow":["get_pending"],"deny":[]}},"allow-is-permission-granted":{"identifier":"allow-is-permission-granted","description":"Enables the is_permission_granted command without any pre-configured scope.","commands":{"allow":["is_permission_granted"],"deny":[]}},"allow-list-channels":{"identifier":"allow-list-channels","description":"Enables the list_channels command without any pre-configured scope.","commands":{"allow":["list_channels"],"deny":[]}},"allow-notify":{"identifier":"allow-notify","description":"Enables the notify command without any pre-configured scope.","commands":{"allow":["notify"],"deny":[]}},"allow-permission-state":{"identifier":"allow-permission-state","description":"Enables the permission_state command without any pre-configured scope.","commands":{"allow":["permission_state"],"deny":[]}},"allow-register-action-types":{"identifier":"allow-register-action-types","description":"Enables the register_action_types command without any pre-configured scope.","commands":{"allow":["register_action_types"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-active":{"identifier":"allow-remove-active","description":"Enables the remove_active command without any pre-configured scope.","commands":{"allow":["remove_active"],"deny":[]}},"allow-request-permission":{"identifier":"allow-request-permission","description":"Enables the request_permission command without any pre-configured scope.","commands":{"allow":["request_permission"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"deny-batch":{"identifier":"deny-batch","description":"Denies the batch command without any pre-configured scope.","commands":{"allow":[],"deny":["batch"]}},"deny-cancel":{"identifier":"deny-cancel","description":"Denies the cancel command without any pre-configured scope.","commands":{"allow":[],"deny":["cancel"]}},"deny-check-permissions":{"identifier":"deny-check-permissions","description":"Denies the check_permissions command without any pre-configured scope.","commands":{"allow":[],"deny":["check_permissions"]}},"deny-create-channel":{"identifier":"deny-create-channel","description":"Denies the create_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["create_channel"]}},"deny-delete-channel":{"identifier":"deny-delete-channel","description":"Denies the delete_channel command without any pre-configured scope.","commands":{"allow":[],"deny":["delete_channel"]}},"deny-get-active":{"identifier":"deny-get-active","description":"Denies the get_active command without any pre-configured scope.","commands":{"allow":[],"deny":["get_active"]}},"deny-get-pending":{"identifier":"deny-get-pending","description":"Denies the get_pending command without any pre-configured scope.","commands":{"allow":[],"deny":["get_pending"]}},"deny-is-permission-granted":{"identifier":"deny-is-permission-granted","description":"Denies the is_permission_granted command without any pre-configured scope.","commands":{"allow":[],"deny":["is_permission_granted"]}},"deny-list-channels":{"identifier":"deny-list-channels","description":"Denies the list_channels command without any pre-configured scope.","commands":{"allow":[],"deny":["list_channels"]}},"deny-notify":{"identifier":"deny-notify","description":"Denies the notify command without any pre-configured scope.","commands":{"allow":[],"deny":["notify"]}},"deny-permission-state":{"identifier":"deny-permission-state","description":"Denies the permission_state command without any pre-configured scope.","commands":{"allow":[],"deny":["permission_state"]}},"deny-register-action-types":{"identifier":"deny-register-action-types","description":"Denies the register_action_types command without any pre-configured scope.","commands":{"allow":[],"deny":["register_action_types"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-active":{"identifier":"deny-remove-active","description":"Denies the remove_active command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_active"]}},"deny-request-permission":{"identifier":"deny-request-permission","description":"Denies the request_permission command without any pre-configured scope.","commands":{"allow":[],"deny":["request_permission"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}}},"permission_sets":{},"global_scope_schema":null},"opener":{"default_permission":{"identifier":"default","description":"This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer","permissions":["allow-open-url","allow-reveal-item-in-dir","allow-default-urls"]},"permissions":{"allow-default-urls":{"identifier":"allow-default-urls","description":"This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.","commands":{"allow":[],"deny":[]},"scope":{"allow":[{"url":"mailto:*"},{"url":"tel:*"},{"url":"http://*"},{"url":"https://*"}]}},"allow-open-path":{"identifier":"allow-open-path","description":"Enables the open_path command without any pre-configured scope.","commands":{"allow":["open_path"],"deny":[]}},"allow-open-url":{"identifier":"allow-open-url","description":"Enables the open_url command without any pre-configured scope.","commands":{"allow":["open_url"],"deny":[]}},"allow-reveal-item-in-dir":{"identifier":"allow-reveal-item-in-dir","description":"Enables the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":["reveal_item_in_dir"],"deny":[]}},"deny-open-path":{"identifier":"deny-open-path","description":"Denies the open_path command without any pre-configured scope.","commands":{"allow":[],"deny":["open_path"]}},"deny-open-url":{"identifier":"deny-open-url","description":"Denies the open_url command without any pre-configured scope.","commands":{"allow":[],"deny":["open_url"]}},"deny-reveal-item-in-dir":{"identifier":"deny-reveal-item-in-dir","description":"Denies the reveal_item_in_dir command without any pre-configured scope.","commands":{"allow":[],"deny":["reveal_item_in_dir"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this url with, for example: firefox."},"url":{"description":"A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"","type":"string"}},"required":["url"],"type":"object"},{"properties":{"app":{"allOf":[{"$ref":"#/definitions/Application"}],"description":"An application to open this path with, for example: xdg-open."},"path":{"description":"A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"}},"required":["path"],"type":"object"}],"definitions":{"Application":{"anyOf":[{"description":"Open in default application.","type":"null"},{"description":"If true, allow open with any application.","type":"boolean"},{"description":"Allow specific application to open with.","type":"string"}],"description":"Opener scope application."}},"description":"Opener scope entry.","title":"OpenerScopeEntry"}},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/apps/desktop/src-tauri/gen/schemas/capabilities.json b/apps/desktop/src-tauri/gen/schemas/capabilities.json index d459b84acc..570ef02bbe 100644 --- a/apps/desktop/src-tauri/gen/schemas/capabilities.json +++ b/apps/desktop/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Desktop bridge; privileged commands verify the exact owned backend origin","remote":{"urls":["http://127.0.0.1:*"]},"local":false,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","allow-get-update-state","allow-check-for-updates","allow-install-update","allow-show-native-notification","allow-get-native-notification-permission","allow-request-native-notification-permission","allow-open-external-url"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Desktop bridge; privileged commands verify the exact owned backend origin","remote":{"urls":["http://127.0.0.1:*"]},"local":false,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","allow-get-update-state","allow-check-for-updates","allow-install-update","allow-show-native-notification","allow-get-native-notification-permission","allow-request-native-notification-permission","allow-open-external-url","allow-pick-directory"]}} \ No newline at end of file diff --git a/apps/desktop/src-tauri/gen/schemas/desktop-schema.json b/apps/desktop/src-tauri/gen/schemas/desktop-schema.json index f1605a9e8b..b90dd3ca68 100644 --- a/apps/desktop/src-tauri/gen/schemas/desktop-schema.json +++ b/apps/desktop/src-tauri/gen/schemas/desktop-schema.json @@ -374,6 +374,12 @@ "const": "allow-open-external-url", "markdownDescription": "Enables the open_external_url command without any pre-configured scope." }, + { + "description": "Enables the pick_directory command without any pre-configured scope.", + "type": "string", + "const": "allow-pick-directory", + "markdownDescription": "Enables the pick_directory command without any pre-configured scope." + }, { "description": "Enables the request_native_notification_permission command without any pre-configured scope.", "type": "string", @@ -416,6 +422,12 @@ "const": "deny-open-external-url", "markdownDescription": "Denies the open_external_url command without any pre-configured scope." }, + { + "description": "Denies the pick_directory command without any pre-configured scope.", + "type": "string", + "const": "deny-pick-directory", + "markdownDescription": "Denies the pick_directory command without any pre-configured scope." + }, { "description": "Denies the request_native_notification_permission command without any pre-configured scope.", "type": "string", @@ -2444,6 +2456,72 @@ "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." + }, { "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", "type": "string", diff --git a/apps/desktop/src-tauri/gen/schemas/linux-schema.json b/apps/desktop/src-tauri/gen/schemas/linux-schema.json index f1605a9e8b..b90dd3ca68 100644 --- a/apps/desktop/src-tauri/gen/schemas/linux-schema.json +++ b/apps/desktop/src-tauri/gen/schemas/linux-schema.json @@ -374,6 +374,12 @@ "const": "allow-open-external-url", "markdownDescription": "Enables the open_external_url command without any pre-configured scope." }, + { + "description": "Enables the pick_directory command without any pre-configured scope.", + "type": "string", + "const": "allow-pick-directory", + "markdownDescription": "Enables the pick_directory command without any pre-configured scope." + }, { "description": "Enables the request_native_notification_permission command without any pre-configured scope.", "type": "string", @@ -416,6 +422,12 @@ "const": "deny-open-external-url", "markdownDescription": "Denies the open_external_url command without any pre-configured scope." }, + { + "description": "Denies the pick_directory command without any pre-configured scope.", + "type": "string", + "const": "deny-pick-directory", + "markdownDescription": "Denies the pick_directory command without any pre-configured scope." + }, { "description": "Denies the request_native_notification_permission command without any pre-configured scope.", "type": "string", @@ -2444,6 +2456,72 @@ "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)" + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)" + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." + }, { "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", "type": "string", diff --git a/apps/desktop/src-tauri/permissions/allow-pick-directory.toml b/apps/desktop/src-tauri/permissions/allow-pick-directory.toml new file mode 100644 index 0000000000..961175ac75 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/allow-pick-directory.toml @@ -0,0 +1,9 @@ +[[permission]] +identifier = "allow-pick-directory" +description = "Enables the Kandev-owned directory picker command without exposing generic dialog commands." +commands.allow = ["pick_directory"] + +[[permission]] +identifier = "deny-pick-directory" +description = "Denies the Kandev-owned directory picker command." +commands.deny = ["pick_directory"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/pick_directory.toml b/apps/desktop/src-tauri/permissions/autogenerated/pick_directory.toml new file mode 100644 index 0000000000..cd24c712df --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/pick_directory.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-pick-directory" +description = "Enables the pick_directory command without any pre-configured scope." +commands.allow = ["pick_directory"] + +[[permission]] +identifier = "deny-pick-directory" +description = "Denies the pick_directory command without any pre-configured scope." +commands.deny = ["pick_directory"] diff --git a/apps/desktop/src-tauri/src/backend.rs b/apps/desktop/src-tauri/src/backend.rs index 6855467f5b..74a5d9a871 100644 --- a/apps/desktop/src-tauri/src/backend.rs +++ b/apps/desktop/src-tauri/src/backend.rs @@ -24,6 +24,7 @@ const DEFAULT_DESKTOP_PORT: u16 = 38430; const DESKTOP_PORT_ENV: &str = "KANDEV_DESKTOP_PORT"; const DESKTOP_HEALTH_TOKEN_ENV: &str = "KANDEV_DESKTOP_HEALTH_TOKEN"; const DESKTOP_NATIVE_NOTIFICATIONS_ENV: &str = "KANDEV_DESKTOP_NATIVE_NOTIFICATIONS"; +const DESKTOP_RUNTIME_ENV: &str = "KANDEV_DESKTOP_RUNTIME"; const LAUNCHER_PARENT_PID_ENV: &str = "KANDEV_LAUNCHER_PARENT_PID"; const DESKTOP_HEALTH_TOKEN_HEADER: &str = "x-kandev-desktop-health-token"; const STARTUP_OUTPUT_LIMIT: usize = 12 * 1024; @@ -369,8 +370,9 @@ pub fn desktop_environment( ) -> BTreeMap { let path = normalized_path(env.get(OsStr::new("PATH")), home_dir); env.retain(|key, _| { - !key.to_string_lossy() - .eq_ignore_ascii_case(DESKTOP_NATIVE_NOTIFICATIONS_ENV) + let key = key.to_string_lossy(); + !key.eq_ignore_ascii_case(DESKTOP_NATIVE_NOTIFICATIONS_ENV) + && !key.eq_ignore_ascii_case(DESKTOP_RUNTIME_ENV) }); env.insert( OsString::from("KANDEV_SERVER_HOST"), @@ -384,6 +386,7 @@ pub fn desktop_environment( OsString::from(DESKTOP_NATIVE_NOTIFICATIONS_ENV), OsString::from("true"), ); + env.insert(OsString::from(DESKTOP_RUNTIME_ENV), OsString::from("true")); env.insert(OsString::from("PATH"), path); env } @@ -730,6 +733,10 @@ fn current_home_dir() -> Option { .map(PathBuf::from) } +pub(crate) fn picker_home_dir() -> Option { + current_home_dir() +} + fn executable_name(name: &str) -> OsString { if cfg!(windows) { OsString::from(format!("{name}.exe")) @@ -870,6 +877,10 @@ mod tests { .get(OsStr::new("KANDEV_DESKTOP_NATIVE_NOTIFICATIONS")), Some(&OsString::from("true")) ); + assert_eq!( + spec.env.get(OsStr::new("KANDEV_DESKTOP_RUNTIME")), + Some(&OsString::from("true")) + ); } #[test] diff --git a/apps/desktop/src-tauri/src/folder_picker.rs b/apps/desktop/src-tauri/src/folder_picker.rs new file mode 100644 index 0000000000..df95009e6e --- /dev/null +++ b/apps/desktop/src-tauri/src/folder_picker.rs @@ -0,0 +1,75 @@ +use serde::Serialize; +use std::path::PathBuf; + +/// The result of the deliberately narrow native folder-selection boundary. +/// The command never accepts a path from the WebView. The selected path is +/// returned only after the user chooses it in the operating-system picker. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum FolderPickerOutcome { + Selected { path: String }, + Cancelled, + Failed { message: String }, +} + +fn outcome_from_path(path: Option>) -> FolderPickerOutcome { + match path { + Some(Ok(path)) => FolderPickerOutcome::Selected { + path: path.to_string_lossy().into_owned(), + }, + Some(Err(message)) => FolderPickerOutcome::Failed { message }, + None => FolderPickerOutcome::Cancelled, + } +} + +#[cfg(feature = "desktop-runtime")] +#[tauri::command] +pub fn pick_directory( + app: tauri::AppHandle, + backend: tauri::State<'_, crate::backend::BackendState>, + webview: tauri::WebviewWindow, +) -> Result { + use tauri_plugin_dialog::{DialogExt, FilePath}; + + backend.require_owned_origin(&webview)?; + let home = crate::backend::picker_home_dir() + .ok_or_else(|| "could not determine the desktop user's home directory".to_string())?; + let picked = app + .dialog() + .file() + .set_directory(home) + .set_parent(&webview) + .blocking_pick_folder(); + let result = picked.map(|path: FilePath| path.into_path().map_err(|err| err.to_string())); + Ok(outcome_from_path(result)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_a_selected_directory() { + assert_eq!( + outcome_from_path(Some(Ok(PathBuf::from("/Users/example/Code")))), + FolderPickerOutcome::Selected { + path: "/Users/example/Code".to_string() + } + ); + } + + #[test] + fn maps_cancellation_without_a_path() { + assert_eq!(outcome_from_path(None), FolderPickerOutcome::Cancelled); + } + + #[test] + fn maps_picker_failures_without_exposing_a_path_argument() { + assert_eq!( + outcome_from_path(Some(Err("picker unavailable".to_string()))), + FolderPickerOutcome::Failed { + message: "picker unavailable".to_string() + } + ); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 31d5d3c39d..e1384d7d58 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ pub mod backend; pub mod external_links; +pub mod folder_picker; pub mod native_notifications; pub mod shell; pub mod updater; diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 7266301cbf..7b36443fd7 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,5 +1,5 @@ use kandev_desktop::{ - backend, external_links, + backend, external_links, folder_picker, native_notifications::{self, NativeNotificationState}, shell::{self, MenuAction, ZoomState}, updater::{self, UpdaterState}, @@ -22,6 +22,7 @@ fn main() { let app = tauri::Builder::default() .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_dialog::init()) .plugin( tauri_plugin_opener::Builder::new() .open_js_links_on_click(false) @@ -42,6 +43,7 @@ fn main() { native_notifications::get_native_notification_permission, native_notifications::request_native_notification_permission, external_links::open_external_url, + folder_picker::pick_directory, ]) .menu(build_menu) .on_menu_event(handle_menu_event) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index b0ecd81ac9..ac3a83aa10 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -58,6 +58,9 @@ "digestAlgorithm": "sha256", "timestampUrl": "https://timestamp.digicert.com", "signCommand": "powershell -NoProfile -ExecutionPolicy Bypass -File ./windows-sign.ps1 %1" + }, + "macOS": { + "infoPlist": "Info.plist" } } } diff --git a/apps/web/app/actions/repository-discovery.ts b/apps/web/app/actions/repository-discovery.ts new file mode 100644 index 0000000000..bf79988596 --- /dev/null +++ b/apps/web/app/actions/repository-discovery.ts @@ -0,0 +1,64 @@ +"use server"; + +import { fetchJson } from "@/lib/api/client"; +import { getBackendConfig } from "@/lib/config"; +import type { DesktopDiscoveryRoot, RepositoryDiscoveryResponse } from "@/lib/types/http"; + +const { apiBaseUrl } = getBackendConfig(); + +export type RepositoryDiscoveryRefreshTrigger = "manual_refresh" | "stale_refresh"; + +export async function getRepositoryDiscoveryAction( + workspaceId: string, + root?: string, +): Promise { + const params = root ? `?root=${encodeURIComponent(root)}` : ""; + return fetchJson( + `${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories/discovery${params}`, + ); +} + +export async function refreshRepositoryDiscoveryAction( + workspaceId: string, + root?: string, + trigger: RepositoryDiscoveryRefreshTrigger = "manual_refresh", +): Promise { + const params = new URLSearchParams(); + if (root) params.set("root", root); + params.set("trigger", trigger); + return fetchJson( + `${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories/discovery/refresh?${params.toString()}`, + { init: { method: "POST" } }, + ); +} + +export async function listDesktopDiscoveryRootsAction(): Promise<{ + roots: DesktopDiscoveryRoot[]; +}> { + return fetchJson<{ roots: DesktopDiscoveryRoot[] }>( + `${apiBaseUrl}/api/v1/repositories/discovery/roots`, + ); +} + +export async function addDesktopDiscoveryRootAction(path: string): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/repositories/discovery/roots`, { + init: { method: "POST", body: JSON.stringify({ path }) }, + }); +} + +export async function reconnectDesktopDiscoveryRootAction( + oldPath: string, + newPath: string, +): Promise { + return fetchJson( + `${apiBaseUrl}/api/v1/repositories/discovery/roots/reconnect`, + { init: { method: "POST", body: JSON.stringify({ path: oldPath, new_path: newPath }) } }, + ); +} + +export async function removeDesktopDiscoveryRootAction(path: string): Promise { + await fetchJson( + `${apiBaseUrl}/api/v1/repositories/discovery/roots?path=${encodeURIComponent(path)}`, + { init: { method: "DELETE" } }, + ); +} diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index d13a66cb29..25467c76c6 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Workspace and workflow actions share one server-action boundary. */ + "use server"; import { getBackendConfig } from "@/lib/config"; @@ -233,6 +235,15 @@ export async function discoverRepositoriesAction( ); } +export { + getRepositoryDiscoveryAction, + refreshRepositoryDiscoveryAction, + listDesktopDiscoveryRootsAction, + addDesktopDiscoveryRootAction, + reconnectDesktopDiscoveryRootAction, + removeDesktopDiscoveryRootAction, +} from "./repository-discovery"; + export async function validateRepositoryPathAction( workspaceId: string, path: string, diff --git a/apps/web/app/office/projects/project-repository-picker.tsx b/apps/web/app/office/projects/project-repository-picker.tsx index 65d2f5a5ae..0ec588b973 100644 --- a/apps/web/app/office/projects/project-repository-picker.tsx +++ b/apps/web/app/office/projects/project-repository-picker.tsx @@ -18,6 +18,7 @@ import type { Repository } from "@/lib/types/http"; import { normalizeRepoValue, shouldShowCustomEntry } from "./repo-entry"; import { useDiscoveredRepositories } from "./use-discovered-repositories"; import { useTranslation } from "react-i18next"; +import { RepositoryDiscoveryControls } from "@/components/repository-discovery-controls"; type Props = { workspaceId: string | null; @@ -117,6 +118,11 @@ export function ProjectRepositoryPicker({ {t("office:pickAWorkspaceRepoADiscovered")} + ({ - discoverRepositoriesAction: vi.fn(), + getRepositoryDiscoveryAction: vi.fn(), + refreshRepositoryDiscoveryAction: vi.fn(), })); -const mockDiscover = vi.mocked(discoverRepositoriesAction); +const mockDiscover = vi.mocked(getRepositoryDiscoveryAction); const repoA: LocalRepository = { path: "/work/a", name: "a", default_branch: "main" }; const repoB: LocalRepository = { path: "/work/b", name: "b", default_branch: "main" }; function deferred() { - let resolve!: (v: { repositories: LocalRepository[] }) => void; - const promise = new Promise<{ repositories: LocalRepository[] }>((r) => { - resolve = r; - }); + let resolve!: (v: { roots: never[]; repositories: LocalRepository[]; total: number }) => void; + const promise = new Promise<{ roots: never[]; repositories: LocalRepository[]; total: number }>( + (r) => { + resolve = r; + }, + ); return { promise, resolve }; } @@ -32,11 +39,17 @@ function renderDiscovery(open: boolean, ws: string | null) { describe("useDiscoveredRepositories", () => { beforeEach(() => { + repositoryDiscoveryCoordinator.dispose(); mockDiscover.mockReset(); + vi.mocked(refreshRepositoryDiscoveryAction).mockReset(); + }); + + afterEach(() => { + repositoryDiscoveryCoordinator.dispose(); }); it("returns null until discovery resolves, then the repos", async () => { - mockDiscover.mockResolvedValue({ repositories: [repoA] } as never); + mockDiscover.mockResolvedValue({ roots: [], repositories: [repoA], total: 1 } as never); const { result } = renderDiscovery(true, "ws1"); expect(result.current).toBeNull(); await waitFor(() => expect(result.current).toEqual([repoA])); @@ -48,27 +61,28 @@ describe("useDiscoveredRepositories", () => { expect(mockDiscover).not.toHaveBeenCalled(); }); - it("retries after a request interrupted by closing the popover", async () => { + it("keeps the cached result after closing and reopening the popover", async () => { const first = deferred(); mockDiscover.mockReturnValueOnce(first.promise as never); const { result, rerender } = renderDiscovery(true, "ws1"); - // Close before the first request resolves — its response is discarded. + // Close before the first request resolves. The coordinator keeps the + // response because it is a cache, not a component-local request. rerender({ o: false, w: "ws1" }); - await act(async () => first.resolve({ repositories: [repoA] })); + await act(async () => first.resolve({ roots: [], repositories: [repoA], total: 1 })); expect(result.current).toBeNull(); - // Reopen: must refetch instead of latching "searching" forever. - mockDiscover.mockResolvedValueOnce({ repositories: [repoB] } as never); + // Reopen: the cached result is immediately available and no second scan + // is needed while the cache is fresh. rerender({ o: true, w: "ws1" }); - await waitFor(() => expect(result.current).toEqual([repoB])); - expect(mockDiscover).toHaveBeenCalledTimes(2); + await waitFor(() => expect(result.current).toEqual([repoA])); + expect(mockDiscover).toHaveBeenCalledTimes(1); }); it("never surfaces another workspace's results after a switch", async () => { const slowWs1 = deferred(); mockDiscover.mockReturnValueOnce(slowWs1.promise as never); - mockDiscover.mockResolvedValueOnce({ repositories: [repoB] } as never); + mockDiscover.mockResolvedValueOnce({ roots: [], repositories: [repoB], total: 1 } as never); const { result, rerender } = renderDiscovery(true, "ws1"); // Switch workspaces while ws1's request is still in flight. @@ -77,7 +91,7 @@ describe("useDiscoveredRepositories", () => { await waitFor(() => expect(result.current).toEqual([repoB])); // ws1's late response must not clobber ws2's result. - await act(async () => slowWs1.resolve({ repositories: [repoA] })); + await act(async () => slowWs1.resolve({ roots: [], repositories: [repoA], total: 1 })); expect(result.current).toEqual([repoB]); }); }); diff --git a/apps/web/app/office/projects/use-discovered-repositories.ts b/apps/web/app/office/projects/use-discovered-repositories.ts index 4e67b7b3d1..64b5dfa448 100644 --- a/apps/web/app/office/projects/use-discovered-repositories.ts +++ b/apps/web/app/office/projects/use-discovered-repositories.ts @@ -1,11 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; -import { discoverRepositoriesAction } from "@/app/actions/workspaces"; +import { useRepositoryDiscovery } from "@/hooks/domains/workspace/use-repository-discovery"; import type { LocalRepository } from "@/lib/types/http"; -type DiscoveryResult = { ws: string; repos: LocalRepository[] }; - /** * Lazily discovers on-disk repositories while the picker popover is * open. Returns `null` until the current workspace's discovery has @@ -22,23 +19,7 @@ export function useDiscoveredRepositories( open: boolean, workspaceId: string | null, ): LocalRepository[] | null { - const [result, setResult] = useState(null); - - useEffect(() => { - if (!open || !workspaceId) return; - if (result?.ws === workspaceId) return; - let cancelled = false; - discoverRepositoriesAction(workspaceId) - .then((res) => { - if (!cancelled) setResult({ ws: workspaceId, repos: res.repositories ?? [] }); - }) - .catch(() => { - if (!cancelled) setResult({ ws: workspaceId, repos: [] }); - }); - return () => { - cancelled = true; - }; - }, [open, workspaceId, result]); - - return result?.ws === workspaceId ? result.repos : null; + const discovery = useRepositoryDiscovery(workspaceId, open); + if (!open || !workspaceId || !discovery.hasSnapshot) return null; + return discovery.repositories; } diff --git a/apps/web/app/settings/workspace/repository-discovery-root-controls.tsx b/apps/web/app/settings/workspace/repository-discovery-root-controls.tsx new file mode 100644 index 0000000000..14e810861d --- /dev/null +++ b/apps/web/app/settings/workspace/repository-discovery-root-controls.tsx @@ -0,0 +1,4 @@ +export { + RepositoryDiscoveryRootControls, + type RepositoryDiscoveryRootControlsProps, +} from "@/components/repository-discovery-root-controls"; diff --git a/apps/web/app/settings/workspace/use-discovery-root-actions.ts b/apps/web/app/settings/workspace/use-discovery-root-actions.ts new file mode 100644 index 0000000000..6b2e1287ad --- /dev/null +++ b/apps/web/app/settings/workspace/use-discovery-root-actions.ts @@ -0,0 +1 @@ +export { useDiscoveryRootActions } from "@/hooks/domains/workspace/use-discovery-root-actions"; diff --git a/apps/web/app/settings/workspace/workspace-add-local-repository-dialog.tsx b/apps/web/app/settings/workspace/workspace-add-local-repository-dialog.tsx index cb88559a8c..8ad75bf9e9 100644 --- a/apps/web/app/settings/workspace/workspace-add-local-repository-dialog.tsx +++ b/apps/web/app/settings/workspace/workspace-add-local-repository-dialog.tsx @@ -32,6 +32,9 @@ export function AddLocalRepositoryDialog({ isValidating={state.isValidating} canSave={state.canSave} onConfirm={state.handleConfirmLocalRepository} + desktopRuntime={state.desktopRuntime} + workspaceId={state.workspaceId} + onRefreshDiscovery={state.onRefreshDiscovery} /> ); } diff --git a/apps/web/app/settings/workspace/workspace-repositories-client.tsx b/apps/web/app/settings/workspace/workspace-repositories-client.tsx index e2a6ecd647..0e200c0c95 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-client.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-client.tsx @@ -16,11 +16,11 @@ import { createRepositoryScriptAction, deleteRepositoryAction, deleteRepositoryScriptAction, - discoverRepositoriesAction, updateRepositoryAction, updateRepositoryScriptAction, validateRepositoryPathAction, } from "@/app/actions/workspaces"; +import { useRepositoryDiscovery } from "@/hooks/domains/workspace/use-repository-discovery"; import { repositoryId as toRepositoryId, type LocalRepository, @@ -29,7 +29,6 @@ import { type Workspace, } from "@/lib/types/http"; import { useRequest } from "@/lib/http/use-request"; -import { useToast } from "@/components/toast-provider"; import { useAppStore } from "@/components/state-provider"; import type { ManualValidation } from "@/app/settings/workspace/workspace-repositories-dialog"; import { WorkspaceNotFoundCard } from "@/app/settings/workspace/workspace-not-found-card"; @@ -101,6 +100,28 @@ type RepoHandlerArgs = { clearRepositoryScripts: (id: string) => void; }; +function selectDiscoveredRepository( + path: string, + setSelectedRepoPath: React.Dispatch>, + setManualRepoPath: React.Dispatch>, + setManualValidation: React.Dispatch>, +) { + setSelectedRepoPath(path); + setManualRepoPath(""); + setManualValidation({ status: "idle" }); +} + +function changeManualRepositoryPath( + value: string, + setSelectedRepoPath: React.Dispatch>, + setManualRepoPath: React.Dispatch>, + setManualValidation: React.Dispatch>, +) { + setManualRepoPath(value); + setSelectedRepoPath(null); + setManualValidation({ status: "idle" }); +} + async function saveNewRepository( repo: RepositoryItem, repoId: string, @@ -316,19 +337,15 @@ function useRepositoryHandlers({ }; } -function useDiscoverDialog( - workspace: Workspace | null, - toast: ReturnType["toast"], - t: TFunction, -) { +function useDiscoverDialog(workspace: Workspace | null, t: TFunction) { const [localRepoDialogOpen, setLocalRepoDialogOpen] = useState(false); - const [discoveredRepositories, setDiscoveredRepositories] = useState([]); const [repoSearch, setRepoSearch] = useState(""); const [selectedRepoPath, setSelectedRepoPath] = useState(null); const [manualRepoPath, setManualRepoPath] = useState(""); const [manualValidation, setManualValidation] = useState({ status: "idle" }); - const discoverRequest = useRequest(discoverRepositoriesAction); const validateRequest = useRequest(validateRepositoryPathAction); + const discovery = useRepositoryDiscovery(workspace?.id ?? null, localRepoDialogOpen); + const discoveredRepositories = discovery.repositories; const filteredRepositories = useMemo(() => { const query = repoSearch.trim().toLowerCase(); @@ -340,25 +357,15 @@ function useDiscoverDialog( const handleDiscover = async () => { if (!workspace) return; - try { - const result = await discoverRequest.run(workspace.id); - setDiscoveredRepositories(result.repositories); - } catch (error) { - toast({ - title: t("workspaces:failedToDiscoverRepositories"), - description: error instanceof Error ? error.message : t("common:requestFailed"), - variant: "error", - }); - } + await discovery.refresh(); }; - const openDialog = async () => { + const openDialog = () => { setLocalRepoDialogOpen(true); setRepoSearch(""); setSelectedRepoPath(null); setManualRepoPath(""); setManualValidation({ status: "idle" }); - await handleDiscover(); }; const handleValidateManualPath = async () => { @@ -391,16 +398,10 @@ function useDiscoverDialog( } }; - const handleSelectRepoPath = (path: string) => { - setSelectedRepoPath(path); - setManualRepoPath(""); - setManualValidation({ status: "idle" }); - }; - const handleManualRepoPathChange = (value: string) => { - setManualRepoPath(value); - setSelectedRepoPath(null); - setManualValidation({ status: "idle" }); - }; + const handleSelectRepoPath = (path: string) => + selectDiscoveredRepository(path, setSelectedRepoPath, setManualRepoPath, setManualValidation); + const handleManualRepoPathChange = (value: string) => + changeManualRepositoryPath(value, setSelectedRepoPath, setManualRepoPath, setManualValidation); const canSave = Boolean(selectedRepoPath) || (manualValidation.status === "success" && manualValidation.isValid === true); @@ -418,10 +419,13 @@ function useDiscoverDialog( manualValidation, handleValidateManualPath, isValidating: validateRequest.isLoading, - isDiscovering: discoverRequest.isLoading, + isDiscovering: discovery.isLoading || discovery.isRefreshing, canSave, openDialog, discoveredRepositories, + desktopRuntime: discovery.desktopRuntime, + onRefreshDiscovery: () => void handleDiscover(), + workspaceId: workspace?.id ?? null, }; } export function useWorkspaceRepositoriesPage( @@ -429,7 +433,6 @@ export function useWorkspaceRepositoriesPage( repositories: RepositoryWithScripts[], ) { const router = useRouter(); - const { toast } = useToast(); const { t } = useTranslation(); const clearRepositoryScripts = useAppStore((state) => state.clearRepositoryScripts); const [repositoryItems, setRepositoryItems] = useState(repositories); @@ -457,23 +460,12 @@ export function useWorkspaceRepositoriesPage( handleDeleteRepository, } = handlers; - const discover = useDiscoverDialog(workspace, toast, t); + const discover = useDiscoverDialog(workspace, t); const { - localRepoDialogOpen, setLocalRepoDialogOpen, - filteredRepositories, - repoSearch, - setRepoSearch, selectedRepoPath, - handleSelectRepoPath, manualRepoPath, - handleManualRepoPathChange, manualValidation, - handleValidateManualPath, - isValidating, - isDiscovering, - canSave, - openDialog, discoveredRepositories, } = discover; @@ -492,7 +484,6 @@ export function useWorkspaceRepositoriesPage( return { router, - toast, repositoryItems, savedRepositoriesById, handleUpdateRepository, @@ -501,21 +492,7 @@ export function useWorkspaceRepositoriesPage( handleDeleteRepositoryScript, handleSaveRepository, handleDeleteRepository, - localRepoDialogOpen, - setLocalRepoDialogOpen, - filteredRepositories, - repoSearch, - setRepoSearch, - selectedRepoPath, - handleSelectRepoPath, - manualRepoPath, - handleManualRepoPathChange, - manualValidation, - handleValidateManualPath, - isValidating, - isDiscovering, - canSave, - openDialog, + ...discover, handleConfirmLocalRepository, }; } diff --git a/apps/web/app/settings/workspace/workspace-repositories-dialog.tsx b/apps/web/app/settings/workspace/workspace-repositories-dialog.tsx index f6c89ae39b..cdfc565163 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-dialog.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-dialog.tsx @@ -14,6 +14,7 @@ import { DialogTitle, } from "@kandev/ui/dialog"; import type { LocalRepository } from "@/lib/types/http"; +import { RepositoryDiscoveryControls } from "@/components/repository-discovery-controls"; export type ManualValidation = { status: "idle" | "loading" | "success" | "error"; @@ -38,6 +39,9 @@ type DiscoverRepoDialogProps = { isValidating: boolean; canSave: boolean; onConfirm: () => void; + desktopRuntime: boolean; + workspaceId: string | null; + onRefreshDiscovery: () => void; }; function RepoListContent({ @@ -80,6 +84,51 @@ function RepoListContent({ ); } +function ManualRepositoryPath({ + manualRepoPath, + onManualRepoPathChange, + manualValidation, + onValidateManualPath, + isValidating, +}: Pick< + DiscoverRepoDialogProps, + | "manualRepoPath" + | "onManualRepoPathChange" + | "manualValidation" + | "onValidateManualPath" + | "isValidating" +>) { + const { t } = useTranslation(); + return ( +
+ +
+ {/* An absolute filesystem path is the value shape this field + expects, not prose — left in English deliberately. */} + onManualRepoPathChange(e.target.value)} + /> + +
+ {manualValidation.status === "error" && ( +

{manualValidation.message}

+ )} + {manualValidation.status === "success" && ( +

{manualValidation.message}

+ )} +
+ ); +} + export function DiscoverRepoDialog({ open, onOpenChange, @@ -96,6 +145,9 @@ export function DiscoverRepoDialog({ isValidating, canSave, onConfirm, + desktopRuntime, + workspaceId, + onRefreshDiscovery, }: DiscoverRepoDialogProps) { const { t } = useTranslation(); return ( @@ -106,8 +158,23 @@ export function DiscoverRepoDialog({ {t("workspaces:discoverRepositoryDescription")}
+ {desktopRuntime && ( + + )}
- +
+ + {!desktopRuntime && ( + + )} +
-
- -
- {/* An absolute filesystem path is the value shape this field - expects, not prose — left in English deliberately. */} - onManualRepoPathChange(e.target.value)} - /> - -
- {manualValidation.status === "error" && ( -

{manualValidation.message}

- )} - {manualValidation.status === "success" && ( -

{manualValidation.message}

- )} -
+ + {showDiscoveryControls ?
: null}
), })); @@ -182,6 +187,7 @@ describe("ConfigSection shared task selectors", () => { renderConfig(); expect(screen.getByTestId("shared-repository-chips")).toBeTruthy(); + expect(screen.getByTestId("repository-discovery-selector-enabled")).toBeTruthy(); expect(screen.queryByText("Use workspace default")).toBeNull(); expect( screen.getByText("Run without repository files in a task-owned scratch workspace."), diff --git a/apps/web/components/automations/config-section.tsx b/apps/web/components/automations/config-section.tsx index 9542174d4c..cd5b13b7c5 100644 --- a/apps/web/components/automations/config-section.tsx +++ b/apps/web/components/automations/config-section.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Label } from "@kandev/ui/label"; import { SettingsFieldLabel } from "@/components/settings/settings-typography"; @@ -8,7 +8,7 @@ import { settingsControlClassName } from "@/components/settings/settings-control import { useAppStore } from "@/components/state-provider"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; -import { discoverRepositoriesAction } from "@/app/actions/workspaces"; +import { useRepositoryDiscovery } from "@/hooks/domains/workspace/use-repository-discovery"; import type { Executor, ExecutorProfile, LocalRepository, Repository } from "@/lib/types/http"; import type { TaskMode } from "@/lib/types/automation"; import type { TaskRepoRow } from "@/components/task-create-dialog-types"; @@ -64,25 +64,6 @@ const CLEAN_FIELDS = { repositorySelections: false, }; -function useDiscoveredRepositories(workspaceId: string) { - const [items, setItems] = useState([]); - useEffect(() => { - if (!workspaceId) return; - let cancelled = false; - discoverRepositoriesAction(workspaceId) - .then((response) => { - if (!cancelled) setItems(response.repositories ?? []); - }) - .catch(() => { - if (!cancelled) setItems([]); - }); - return () => { - cancelled = true; - }; - }, [workspaceId]); - return items; -} - function executorProfiles(executors: Executor[]): ExecutorProfile[] { return executors.flatMap((executor) => (executor.profiles ?? []).map((profile) => ({ @@ -147,6 +128,7 @@ function RepositoryAccess({ repositories={repositories} discoveredRepositories={discovered} workspaceId={workspaceId} + showDiscoveryControls canAddMore={supportsMultiRepo || rows.length === 0} addLabel={t("task:addRepository")} allowDuplicateRepositories={false} @@ -197,7 +179,7 @@ export function ConfigSection({ ); const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); const { repositories } = useRepositories(workspaceId, true); - const discovered = useDiscoveredRepositories(workspaceId); + const discovered = useRepositoryDiscovery(workspaceId, true).repositories; const agentProfiles = useAppStore((state) => state.agentProfiles.items); const executors = useAppStore((state) => state.executors.items); const agents = useAgentProfileOptions(agentProfiles); diff --git a/apps/web/components/folder-picker.test.tsx b/apps/web/components/folder-picker.test.tsx index d3284cf00e..3bdadc4a9f 100644 --- a/apps/web/components/folder-picker.test.tsx +++ b/apps/web/components/folder-picker.test.tsx @@ -45,6 +45,8 @@ afterEach(() => { cleanup(); mockedListDirectory.mockReset(); setPopoverOpen = undefined; + delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; + delete (window as Window & { __KANDEV_BOOT_PAYLOAD__?: unknown }).__KANDEV_BOOT_PAYLOAD__; }); describe("FolderPicker", () => { @@ -60,6 +62,27 @@ describe("FolderPicker", () => { expect(screen.getByTestId(TRIGGER_TEST_ID).textContent).toBe("C:\\"); }); + it("uses the native picker in a Tauri WebView without listing directories over HTTP", async () => { + const invoke = vi.fn(async () => ({ status: "selected", path: "/Users/example/Code" })); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { invoke }, + }); + Object.defineProperty(window, "__KANDEV_BOOT_PAYLOAD__", { + configurable: true, + value: { runtime: { nativeFolderPickerAvailable: true } }, + }); + const onChange = vi.fn(); + + render(); + fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID)); + + await waitFor(() => expect(onChange).toHaveBeenCalledWith("/Users/example/Code")); + expect(invoke).toHaveBeenCalledWith("pick_directory", undefined); + expect(mockedListDirectory).not.toHaveBeenCalled(); + expect(screen.queryByTestId("folder-picker-popover")).toBeNull(); + }); + it("builds Windows breadcrumbs with a virtual root and native drive paths", async () => { mockedListDirectory .mockResolvedValueOnce( diff --git a/apps/web/components/folder-picker.tsx b/apps/web/components/folder-picker.tsx index a4ffc76570..c069708666 100644 --- a/apps/web/components/folder-picker.tsx +++ b/apps/web/components/folder-picker.tsx @@ -15,6 +15,12 @@ import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@/lib/utils"; import { listDirectory, type DirectoryListing } from "@/lib/api/domains/fs-api"; +import { + isTauriWebview, + nativeFolderPicker, + NativeFolderPickerUnavailableError, +} from "@/lib/desktop/folder-picker"; +import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; type FolderPickerProps = { @@ -25,6 +31,64 @@ type FolderPickerProps = { placeholder?: string; }; +type NativeFolderPickerTriggerProps = { + triggerClass: string; + hasValue: boolean; + triggerLabel: string; + loading: boolean; + error: string | null; + onChoose: () => void; +}; + +function NativeFolderPickerTrigger({ + triggerClass, + hasValue, + triggerLabel, + loading, + error, + onChoose, +}: NativeFolderPickerTriggerProps) { + const { t } = useTranslation(); + return ( +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} + +function nativePickerErrorMessage(error: unknown, t: TFunction): string { + if (error instanceof NativeFolderPickerUnavailableError) { + return t("common:nativeFolderPickerUnavailable"); + } + if (error instanceof Error && error.message) return error.message; + return t("common:nativeFolderPickerFailed"); +} + /** * Folder picker for repo-less tasks. Drives GET /api/v1/fs/list-dir on the * local kandev backend (browsers can't enumerate the host filesystem). The @@ -33,13 +97,16 @@ type FolderPickerProps = { export function FolderPicker({ value, onChange, placeholder }: FolderPickerProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); - const { listing, loading, error, load } = useDirectoryListing(open, value); + const [nativeLoading, setNativeLoading] = useState(false); + const [nativeError, setNativeError] = useState(null); + const tauriWebview = isTauriWebview(); + const { listing, loading, error, load } = useDirectoryListing(open && !tauriWebview, value); const leaf = leafName(value); const triggerLabel = leaf || placeholder || t("common:scratchWorkspace"); const hasValue = !!value; const triggerClass = cn( - "h-7 inline-flex items-center gap-1.5 rounded-md px-2.5 text-xs cursor-pointer", + "h-7 inline-flex items-center gap-1.5 rounded-md px-2.5 text-xs cursor-pointer [@media(pointer:coarse)]:h-11", "border border-border/60 transition-colors", hasValue ? "bg-primary/10 text-foreground hover:bg-primary/15" @@ -57,6 +124,39 @@ export function FolderPicker({ value, onChange, placeholder }: FolderPickerProps ); + const chooseNativeFolder = async () => { + setNativeError(null); + if (!nativeFolderPicker.isAvailable()) { + setNativeError(t("common:nativeFolderPickerUnavailable")); + return; + } + setNativeLoading(true); + try { + const outcome = await nativeFolderPicker.pickDirectory(); + if (outcome.status === "selected") onChange(outcome.path); + if (outcome.status === "failed") { + setNativeError(outcome.message || t("common:nativeFolderPickerFailed")); + } + } catch (error) { + setNativeError(nativePickerErrorMessage(error, t)); + } finally { + setNativeLoading(false); + } + }; + + if (tauriWebview) { + return ( + void chooseNativeFolder()} + /> + ); + } + return ( diff --git a/apps/web/components/repository-discovery-controls.test.tsx b/apps/web/components/repository-discovery-controls.test.tsx new file mode 100644 index 0000000000..3432dbb6e4 --- /dev/null +++ b/apps/web/components/repository-discovery-controls.test.tsx @@ -0,0 +1,69 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const discovery = vi.hoisted(() => ({ + desktopRuntime: true, + isLoading: false, + isRefreshing: false, + rootStates: [ + { id: "root-1", path: "/projects", display_path: "~/projects", state: "connected" }, + { id: "", path: "/configured", display_path: "/configured", state: "connected" }, + ], + homeConfirmationRequired: true, +})); +const actions = vi.hoisted(() => ({ + refreshDiscovery: vi.fn(), + handleChooseDiscoveryRoot: vi.fn(), + handleReconnectDiscoveryRoot: vi.fn(), + handleRemoveDiscoveryRoot: vi.fn(), +})); + +vi.mock("@/hooks/domains/workspace/use-repository-discovery", () => ({ + useRepositoryDiscovery: () => discovery, +})); +vi.mock("@/hooks/domains/workspace/use-discovery-root-actions", () => ({ + useDiscoveryRootActions: () => actions, +})); +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/components/repository-discovery-root-controls", () => ({ + RepositoryDiscoveryRootControls: (props: { + discoveryRoots: Array<{ id: string }>; + onChooseDiscoveryRoot: (path: string) => void; + }) => ( +
+ {props.discoveryRoots.length} + +
+ ), +})); + +import { RepositoryDiscoveryControls } from "./repository-discovery-controls"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("RepositoryDiscoveryControls", () => { + it("renders the shared desktop consent surface and only exposes operator roots", () => { + render(); + + expect(screen.getByTestId("root-controls")).toBeTruthy(); + expect(screen.getByTestId("root-count").textContent).toBe("1"); + fireEvent.click(screen.getByRole("button", { name: "Choose" })); + expect(actions.handleChooseDiscoveryRoot).toHaveBeenCalledWith("/picked"); + }); + + it("does not lease or render a surface when disabled", () => { + render(); + + expect(screen.queryByTestId("root-controls")).toBeNull(); + }); +}); diff --git a/apps/web/components/repository-discovery-controls.tsx b/apps/web/components/repository-discovery-controls.tsx new file mode 100644 index 0000000000..daf0c7baf9 --- /dev/null +++ b/apps/web/components/repository-discovery-controls.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { useToast } from "@/components/toast-provider"; +import { useRepositoryDiscovery } from "@/hooks/domains/workspace/use-repository-discovery"; +import { useDiscoveryRootActions } from "@/hooks/domains/workspace/use-discovery-root-actions"; +import { RepositoryDiscoveryRootControls } from "@/components/repository-discovery-root-controls"; +import { cn } from "@/lib/utils"; + +type RepositoryDiscoveryControlsProps = { + workspaceId: string | null; + enabled?: boolean; + className?: string; + presentation?: "card" | "picker"; +}; + +/** + * The shared consent and recovery surface for every repository selector. + * Keeping the discovery lease and root actions together prevents a picker + * from showing an empty result without also offering the action that can + * establish or repair its filesystem access. + */ +export function RepositoryDiscoveryControls({ + workspaceId, + enabled = true, + className, + presentation = "card", +}: RepositoryDiscoveryControlsProps) { + const discovery = useRepositoryDiscovery(workspaceId, enabled); + const { toast } = useToast(); + const { t } = useTranslation(); + const actions = useDiscoveryRootActions(discovery, toast, t); + + if (!enabled || !workspaceId || !discovery.desktopRuntime) return null; + + return ( + Boolean(root.id))} + homeConfirmationRequired={discovery.homeConfirmationRequired} + onChooseDiscoveryRoot={actions.handleChooseDiscoveryRoot} + onRefreshDiscovery={actions.refreshDiscovery} + onReconnectDiscoveryRoot={actions.handleReconnectDiscoveryRoot} + onRemoveDiscoveryRoot={actions.handleRemoveDiscoveryRoot} + /> + ); +} diff --git a/apps/web/components/repository-discovery-root-controls.tsx b/apps/web/components/repository-discovery-root-controls.tsx new file mode 100644 index 0000000000..8307aedb00 --- /dev/null +++ b/apps/web/components/repository-discovery-root-controls.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import { FolderPicker } from "@/components/folder-picker"; +import { cn } from "@/lib/utils"; +import type { DesktopDiscoveryRoot } from "@/lib/types/http"; + +export type RepositoryDiscoveryRootControlsProps = { + className?: string; + presentation?: "card" | "picker"; + isLoading: boolean; + discoveryRoots: DesktopDiscoveryRoot[]; + homeConfirmationRequired: boolean; + onChooseDiscoveryRoot: (path: string) => void; + onRefreshDiscovery: () => void; + onReconnectDiscoveryRoot: (oldPath: string, newPath: string) => void; + onRemoveDiscoveryRoot: (path: string) => void; +}; + +export function RepositoryDiscoveryRootControls({ + className, + presentation = "card", + isLoading, + discoveryRoots, + homeConfirmationRequired, + onChooseDiscoveryRoot, + onRefreshDiscovery, + onReconnectDiscoveryRoot, + onRemoveDiscoveryRoot, +}: RepositoryDiscoveryRootControlsProps) { + const { t } = useTranslation(); + return ( +
+ {presentation === "card" && ( +
+

+ {t("workspaces:chooseFoldersToDiscoverRepositories")} +

+

+ {t("workspaces:chooseFoldersToDiscoverRepositoriesDescription")} +

+
+ )} +
+ + +
+ {homeConfirmationRequired && ( +
+

{t("workspaces:homeDiscoveryConfirmationDescription")}

+
+ +
+
+ )} + {discoveryRoots.map((root) => ( +
+ + {root.display_path || root.path} + + {root.state === "reconnect_required" && ( + onReconnectDiscoveryRoot(root.path, newPath)} + /> + )} + +
+ ))} +
+ ); +} diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index a895edbcba..0b2664b5eb 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -4,10 +4,7 @@ import { useEffect, useMemo, useRef } from "react"; import type { Repository, Executor, ExecutorProfile } from "@/lib/types/http"; import { DEFAULT_LOCAL_EXECUTOR_TYPE } from "@/lib/utils"; import { useToast } from "@/components/toast-provider"; -import { - discoverRepositoriesAction, - getLocalRepositoryStatusAction, -} from "@/app/actions/workspaces"; +import { getLocalRepositoryStatusAction } from "@/app/actions/workspaces"; import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; import { parseGitHubAnyUrl } from "@/hooks/domains/github/use-pr-info-by-url"; import type { @@ -22,6 +19,7 @@ import { import { useRepositoryAutoSelectEffect } from "@/components/task-create-dialog-repository-autopick"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; import { t } from "@/lib/i18n"; +import { useRepositoryDiscovery } from "@/hooks/domains/workspace/use-repository-discovery"; // Re-export autopick hooks for callers that imported them from this module. export { useWorkflowAgentProfileEffect }; @@ -75,41 +73,33 @@ export function useDiscoverReposEffect( repositoriesLoading: boolean, toast: ReturnType["toast"], ) { - const { - discoverReposLoaded, - discoverReposLoading, - setDiscoveredRepositories, - setDiscoverReposLoading, - setDiscoverReposLoaded, - } = fs; + const discovery = useRepositoryDiscovery(workspaceId, open && !repositoriesLoading); + const { setDiscoveredRepositories, setDiscoverReposLoading, setDiscoverReposLoaded } = fs; + const reportedErrorRef = useRef(null); useEffect(() => { - if (!open || !workspaceId || repositoriesLoading || discoverReposLoaded || discoverReposLoading) - return; - void Promise.resolve() - .then(() => setDiscoverReposLoading(true)) - .then(() => discoverRepositoriesAction(workspaceId)) - .then((r) => { - setDiscoveredRepositories(r.repositories); - }) - .catch((e) => { - toast({ - title: t("task:failedToDiscoverRepositories"), - description: e instanceof Error ? e.message : t("common:requestFailed"), - variant: "error", - }); - setDiscoveredRepositories([]); - }) - .finally(() => { - setDiscoverReposLoading(false); - setDiscoverReposLoaded(true); + if (!open || !workspaceId || repositoriesLoading) return; + setDiscoveredRepositories(discovery.repositories); + setDiscoverReposLoading(discovery.isLoading || discovery.isRefreshing); + setDiscoverReposLoaded(discovery.hasSnapshot || discovery.error !== null); + if (discovery.error && reportedErrorRef.current !== discovery.error.message) { + reportedErrorRef.current = discovery.error.message; + toast({ + title: t("task:failedToDiscoverRepositories"), + description: discovery.error.message || t("common:requestFailed"), + variant: "error", }); - // eslint-disable-next-line react-hooks/exhaustive-deps + } }, [ - discoverReposLoaded, - discoverReposLoading, + discovery.error, + discovery.hasSnapshot, + discovery.isLoading, + discovery.isRefreshing, + discovery.repositories, open, - fs.discoveredRepositories.length, repositoriesLoading, + setDiscoveredRepositories, + setDiscoverReposLoaded, + setDiscoverReposLoading, toast, workspaceId, ]); diff --git a/apps/web/components/task-create-dialog-pill.tsx b/apps/web/components/task-create-dialog-pill.tsx index 7f4f7e1fa4..42433bfcd0 100644 --- a/apps/web/components/task-create-dialog-pill.tsx +++ b/apps/web/components/task-create-dialog-pill.tsx @@ -38,11 +38,10 @@ export type PillAction = { }; /** - * `Pill` wraps cmdk's `Command` / `CommandInput` / `CommandList`. Its popover - * body only supports cmdk children (`CommandItem`, etc.) — keyboard nav and - * focus are routed through cmdk. If you need a popover with mixed content - * (search list + a free-form ``, banners, etc.), build a custom - * `Popover` from `@kandev/ui/popover` instead of warping `Pill`. + * `Pill` wraps cmdk's `Command` / `CommandInput` / `CommandList`. Searchable + * content must remain cmdk children so keyboard navigation and focus stay + * correct. Contextual controls can use `popoverHeader`, which renders outside + * `Command`; mixed searchable content still needs a custom `Popover`. */ type PillProps = { @@ -84,6 +83,8 @@ type PillProps = { prefix?: string; /** Optional icon action rendered beside the search input. */ action?: PillAction; + /** Optional contextual controls rendered above the searchable list. */ + popoverHeader?: React.ReactNode; }; /** Returns the active-state hover classes for the pill trigger button. */ @@ -244,6 +245,7 @@ function PillPopoverContent({ emptyMessage, portalContainer, action, + popoverHeader, }: { filter?: PillProps["filter"]; searchPlaceholder: string; @@ -258,6 +260,7 @@ function PillPopoverContent({ emptyMessage: string; portalContainer: HTMLElement | null; action?: PillAction; + popoverHeader?: React.ReactNode; }) { return ( + {popoverHeader}
@@ -328,6 +332,7 @@ function PillPopover({ emptyMessage, portalContainer, action, + popoverHeader, }: { open: boolean; setOpen: (open: boolean) => void; @@ -344,6 +349,7 @@ function PillPopover({ emptyMessage: string; portalContainer: HTMLElement | null; action?: PillAction; + popoverHeader?: React.ReactNode; }) { return ( @@ -362,6 +368,7 @@ function PillPopover({ emptyMessage={emptyMessage} portalContainer={portalContainer} action={action} + popoverHeader={popoverHeader} /> ); @@ -383,6 +390,7 @@ type PillPopoverShellProps = { emptyMessage: string; portalContainer: HTMLElement | null; action?: PillAction; + popoverHeader?: React.ReactNode; tooltip?: string; tooltipOpenState: boolean; suppressTooltip: boolean; @@ -407,6 +415,7 @@ function renderPillPopover({ emptyMessage, portalContainer, action, + popoverHeader, tooltip, tooltipOpenState, suppressTooltip, @@ -434,6 +443,7 @@ function renderPillPopover({ emptyMessage={emptyMessage} portalContainer={portalContainer} action={action} + popoverHeader={popoverHeader} /> ); @@ -539,6 +549,7 @@ export function Pill({ tooltip, prefix, action, + popoverHeader, }: PillProps) { const [open, setOpenState] = useState(false); const { tooltipOpenState, handleTooltipOpenChange, closeTooltip } = useTooltipMountGate(); @@ -606,6 +617,7 @@ export function Pill({ emptyMessage, portalContainer, action, + popoverHeader, tooltip, tooltipOpenState, suppressTooltip, diff --git a/apps/web/components/task-create-dialog-repo-chip-parts.tsx b/apps/web/components/task-create-dialog-repo-chip-parts.tsx index c290adcd7e..5a8a3fbfa7 100644 --- a/apps/web/components/task-create-dialog-repo-chip-parts.tsx +++ b/apps/web/components/task-create-dialog-repo-chip-parts.tsx @@ -215,6 +215,7 @@ export function RepoChipRepositoryPill({ onCreateRepository, onRefreshRepositories, repositoriesRefreshing, + popoverHeader, }: { repoLabel: string; repoTooltip: string; @@ -224,6 +225,7 @@ export function RepoChipRepositoryPill({ onCreateRepository?: () => void; onRefreshRepositories?: () => void; repositoriesRefreshing?: boolean; + popoverHeader?: React.ReactNode; }) { const { t } = useTranslation(); return ( @@ -242,6 +244,7 @@ export function RepoChipRepositoryPill({ onRefresh={onRefreshRepositories} refreshing={repositoriesRefreshing} refreshLabel="repositories" + popoverHeader={popoverHeader} flat /> ); diff --git a/apps/web/components/task-create-dialog-repo-chips.test.tsx b/apps/web/components/task-create-dialog-repo-chips.test.tsx index 9d359c1cc1..c5f5da198e 100644 --- a/apps/web/components/task-create-dialog-repo-chips.test.tsx +++ b/apps/web/components/task-create-dialog-repo-chips.test.tsx @@ -32,6 +32,10 @@ vi.mock("./task-create-dialog-remote-repo-chip", () => ({ selectedRemoteRepositoryIdentity: () => null, })); +vi.mock("@/components/repository-discovery-controls", () => ({ + RepositoryDiscoveryControls: () =>
, +})); + import { RepoChipsRow } from "./task-create-dialog-repo-chips"; afterEach(() => { @@ -100,6 +104,23 @@ const renderInProvider = (ui: Parameters[0]) => render({ui}); // eslint-disable-next-line max-lines-per-function -- test describe block, splitting hurts readability describe("RepoChipsRow", () => { + it("mounts discovery controls only inside the open repository selector", () => { + renderInProvider( + , + ); + + expect(screen.queryByTestId("repository-discovery-controls")).toBeNull(); + fireEvent.click(screen.getByTestId(REPO_CHIP_TRIGGER)); + expect(screen.getByTestId("repository-discovery-controls")).toBeTruthy(); + }); + it("keeps the compact Repo, Remote, and None source-mode controls and test IDs", () => { const onToggleRemote = vi.fn(); const onToggleNoRepository = vi.fn(); diff --git a/apps/web/components/task-create-dialog-repo-chips.tsx b/apps/web/components/task-create-dialog-repo-chips.tsx index 5ea6b90d7e..972508a165 100644 --- a/apps/web/components/task-create-dialog-repo-chips.tsx +++ b/apps/web/components/task-create-dialog-repo-chips.tsx @@ -131,7 +131,6 @@ export function RepoChipsRow({ // - "Fork a new branch" toggle is a separate flow that creates a NEW branch // from the selected base // Other executors: branch is fully editable (no special pre-fill). - const branchLocked = false; // No early returns above hooks. URL mode and started-state checks happen below. if (isTaskStarted) return null; @@ -157,7 +156,7 @@ export function RepoChipsRow({ fs={fs} repositories={repositories} workspaceId={workspaceId} - branchLocked={branchLocked} + branchLocked={false} isLocalExecutor={!!isLocalExecutor} canAddMore={canAddMore} addHint={addHint} @@ -288,15 +287,8 @@ function ModeBody({ onRefreshRepositories?: () => void; repositoriesRefreshing?: boolean; }) { - const { t } = useTranslation(); if (fs.noRepository) { - return ( - {})} - placeholder={t("task:pickAStartingFolderOptional")} - /> - ); + return ; } if (fs.useRemote) { return ( @@ -330,14 +322,14 @@ function ModeBody({ onRowPolicyChange={onRowPolicyChange} onPolicySelected={onPolicySelected} showBranchPolicies + showDiscoveryControls lastUsedBranch={lastUsedBranch} userSettingsLoaded={userSettingsLoaded} onCreateRepository={onCreateRepository} onRefreshRepositories={onRefreshRepositories} repositoriesRefreshing={repositoriesRefreshing} freshBranchToggle={ - // Multi-repo runs use worktrees, so the existing-vs-fork choice - // is irrelevant — only surface the toggle for single-repo flows. + // Multi-repo worktrees do not need the existing-vs-fork choice. freshBranchAvailable && onToggleFreshBranch && fs.repositories.length === 1 ? ( ) : null @@ -346,6 +338,23 @@ function ModeBody({ ); } +function NoRepositoryMode({ + fs, + onWorkspacePathChange, +}: { + fs: DialogFormState; + onWorkspacePathChange?: (value: string) => void; +}) { + const { t } = useTranslation(); + return ( + {})} + placeholder={t("task:pickAStartingFolderOptional")} + /> + ); +} + function FreshBranchToggle({ enabled, onToggle, diff --git a/apps/web/components/task-create-dialog-workspace-repo-chips.tsx b/apps/web/components/task-create-dialog-workspace-repo-chips.tsx index bbababe97e..e269543254 100644 --- a/apps/web/components/task-create-dialog-workspace-repo-chips.tsx +++ b/apps/web/components/task-create-dialog-workspace-repo-chips.tsx @@ -23,6 +23,7 @@ import { } from "@/components/task-create-dialog-repo-chip-parts"; import { useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; +import { RepositoryDiscoveryControls } from "@/components/repository-discovery-controls"; type WorkspaceRepoChipsProps = { rows: TaskRepoRow[]; @@ -36,6 +37,7 @@ type WorkspaceRepoChipsProps = { freshBranchEnabled?: boolean; branchPolicyDisabledReason?: string; showBranchPolicies?: boolean; + showDiscoveryControls?: boolean; canAddMore: boolean; addHint?: string; addLabel?: string; @@ -71,6 +73,7 @@ export function WorkspaceRepoChips({ freshBranchEnabled, branchPolicyDisabledReason, showBranchPolicies = false, + showDiscoveryControls = false, canAddMore, addHint, addLabel, @@ -127,6 +130,7 @@ export function WorkspaceRepoChips({ } onPolicySelected={onPolicySelected} showBranchPolicies={showBranchPolicies} + showDiscoveryControls={showDiscoveryControls} onCreateRepository={ rows.length === 1 && onCreateRepository ? () => onCreateRepository(row.key) : undefined } @@ -263,6 +267,7 @@ type RepoChipProps = { onPolicySelected?: () => void; branchPolicyDisabledReason?: string; showBranchPolicies?: boolean; + showDiscoveryControls?: boolean; onRemove: () => void; onCreateRepository?: () => void; onRefreshRepositories?: () => void; @@ -479,6 +484,8 @@ function RepoChipContent({ onCreateRepository, onRefreshRepositories, repositoriesRefreshing, + showDiscoveryControls, + workspaceId, }: RepoChipProps & { data: RepoChipData; branchPolicies: RepositoryBranchPolicy[] }) { const { repoOptions, branchOptions, branches, branchesLoading, refreshBranches } = data; const { repoLabel, repoTooltip } = computeRepoChipDisplay( @@ -514,6 +521,11 @@ function RepoChipContent({ onCreateRepository={onCreateRepository} onRefreshRepositories={onRefreshRepositories} repositoriesRefreshing={repositoriesRefreshing} + popoverHeader={ + showDiscoveryControls ? ( + + ) : undefined + } /> ({ +const { + attachTaskWorkspaceSources, + discoverRepositoriesAction, + getRepositoryDiscoveryAction, + refreshRepositoryDiscoveryAction, + refreshRepositories, +} = vi.hoisted(() => { + const discover = vi.fn().mockResolvedValue({ repositories: [] }); + return { attachTaskWorkspaceSources: vi.fn(), - discoverRepositoriesAction: vi.fn().mockResolvedValue({ repositories: [] }), + discoverRepositoriesAction: discover, + getRepositoryDiscoveryAction: discover, + refreshRepositoryDiscoveryAction: discover, refreshRepositories: vi.fn().mockResolvedValue(undefined), - }), -); + }; +}); vi.mock("@/hooks/use-responsive-breakpoint", () => ({ useResponsiveBreakpoint: () => ({ isMobile }), @@ -33,8 +43,16 @@ vi.mock("@/components/folder-picker", () => ({ ), })); +vi.mock("@/components/repository-discovery-controls", () => ({ + RepositoryDiscoveryControls: ({ enabled }: { enabled?: boolean }) => + enabled !== false ?
: null, +})); vi.mock("@/lib/api/domains/kanban-api", () => ({ attachTaskWorkspaceSources })); -vi.mock("@/app/actions/workspaces", () => ({ discoverRepositoriesAction })); +vi.mock("@/app/actions/workspaces", () => ({ + discoverRepositoriesAction, + getRepositoryDiscoveryAction, + refreshRepositoryDiscoveryAction, +})); async function finishClose(surface: HTMLElement, isDrawer: boolean) { await waitFor(() => expect(surface.getAttribute("data-state")).not.toBe("open")); @@ -110,6 +128,7 @@ function HarnessContent({ afterEach(() => { cleanup(); + repositoryDiscoveryCoordinator.dispose(); isMobile = false; attachTaskWorkspaceSources.mockReset(); discoverRepositoriesAction.mockClear(); @@ -164,6 +183,24 @@ describe("AddWorkspaceSourcesDialog consequences", () => { }); }); +describe("AddWorkspaceSourcesDialog repository discovery", () => { + it("mounts discovery controls only inside a saved repository selector", async () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: ADD_SOURCES_LABEL })); + expect(screen.queryByTestId("repository-discovery-controls")).toBeNull(); + openRepositoryMenu(); + fireEvent.click(await screen.findByRole("menuitem", { name: "Workspace repository" })); + fireEvent.click(screen.getByTestId("repo-chip-trigger")); + + expect(screen.getByTestId("repository-discovery-controls")).toBeTruthy(); + }); +}); + describe("AddWorkspaceSourcesDialog", () => { it("uses a touch-sized repository menu without tabs or discarded mixed source rows", async () => { isMobile = true; diff --git a/apps/web/components/task/add-workspace-sources/saved-repository-source-row.tsx b/apps/web/components/task/add-workspace-sources/saved-repository-source-row.tsx index fd7172105a..a06e0ade08 100644 --- a/apps/web/components/task/add-workspace-sources/saved-repository-source-row.tsx +++ b/apps/web/components/task/add-workspace-sources/saved-repository-source-row.tsx @@ -75,6 +75,7 @@ export function SavedRepositorySourceRow({ repositories={repositories} discoveredRepositories={discoveredRepositories} workspaceId={workspaceId} + showDiscoveryControls canAddMore={false} onAdd={() => {}} onRemove={() => {}} diff --git a/apps/web/components/task/add-workspace-sources/use-workspace-repository-options.ts b/apps/web/components/task/add-workspace-sources/use-workspace-repository-options.ts index ff39ccb613..ad1a438cc2 100644 --- a/apps/web/components/task/add-workspace-sources/use-workspace-repository-options.ts +++ b/apps/web/components/task/add-workspace-sources/use-workspace-repository-options.ts @@ -1,9 +1,8 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { discoverRepositoriesAction } from "@/app/actions/workspaces"; +import { useCallback } from "react"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; -import type { LocalRepository } from "@/lib/types/http"; +import { useRepositoryDiscovery } from "@/hooks/domains/workspace/use-repository-discovery"; export function useWorkspaceRepositoryOptions(workspaceId: string | null, open: boolean) { const { @@ -11,46 +10,16 @@ export function useWorkspaceRepositoryOptions(workspaceId: string | null, open: isLoading: repositoriesLoading, refresh: refreshRepositories, } = useRepositories(workspaceId, open); - const [discoveredRepositories, setDiscoveredRepositories] = useState([]); - const [repositoriesDiscovering, setRepositoriesDiscovering] = useState(false); - const currentWorkspaceRef = useRef(workspaceId); - currentWorkspaceRef.current = workspaceId; - - const discoverRepositories = useCallback(async () => { - if (!workspaceId) { - setDiscoveredRepositories([]); - return; - } - const requestedWorkspaceId = workspaceId; - setRepositoriesDiscovering(true); - try { - const result = await discoverRepositoriesAction(requestedWorkspaceId); - if (currentWorkspaceRef.current === requestedWorkspaceId) { - setDiscoveredRepositories(result.repositories); - } - } catch { - if (currentWorkspaceRef.current === requestedWorkspaceId) { - setDiscoveredRepositories([]); - } - } finally { - if (currentWorkspaceRef.current === requestedWorkspaceId) { - setRepositoriesDiscovering(false); - } - } - }, [workspaceId]); - - useEffect(() => { - if (open) void discoverRepositories(); - }, [discoverRepositories, open]); + const discovery = useRepositoryDiscovery(workspaceId, open); const refreshRepositoryOptions = useCallback(() => { - void Promise.all([refreshRepositories(), discoverRepositories()]); - }, [discoverRepositories, refreshRepositories]); + void Promise.all([refreshRepositories(), discovery.refresh()]); + }, [discovery.refresh, refreshRepositories]); return { repositories, - discoveredRepositories, - repositoriesRefreshing: repositoriesLoading || repositoriesDiscovering, + discoveredRepositories: discovery.repositories, + repositoriesRefreshing: repositoriesLoading || discovery.isLoading || discovery.isRefreshing, refreshRepositoryOptions, }; } diff --git a/apps/web/e2e/tests/task/mobile-repository-discovery.spec.ts b/apps/web/e2e/tests/task/mobile-repository-discovery.spec.ts new file mode 100644 index 0000000000..7c610e80dc --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-repository-discovery.spec.ts @@ -0,0 +1,108 @@ +import { expect, test } from "../../fixtures/test-base"; +import { useRegularMode } from "../../helpers/regular-mode"; +import { waitForFiniteAnimations } from "../../helpers/animations"; +import { MobileKanbanPage } from "../../pages/mobile-kanban-page"; +import fs from "node:fs"; + +useRegularMode(); + +test.describe("Mobile repository discovery consent", () => { + test("shows discovery actions only inside the repository selector with matched touch targets", async ({ + testPage, + backend, + }) => { + test.setTimeout(120_000); + await backend.restart({ KANDEV_DESKTOP_RUNTIME: "true" }); + const mobile = new MobileKanbanPage(testPage); + await mobile.goto(); + await mobile.mobileFab.tap(); + + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByTestId("discovery-root-controls")).toHaveCount(0); + + await dialog.getByTestId("repo-chip-trigger").first().tap(); + const controls = testPage.getByTestId("discovery-root-controls"); + const chooseFolders = controls.getByTestId("folder-picker-trigger"); + const refreshRepositories = controls.getByRole("button", { + name: "Refresh repositories", + }); + await expect(controls).toBeVisible(); + await waitForFiniteAnimations(controls); + + const [chooseBox, refreshBox] = await Promise.all([ + chooseFolders.boundingBox(), + refreshRepositories.boundingBox(), + ]); + const [chooseCssHeight, refreshCssHeight] = await Promise.all([ + chooseFolders.evaluate((element) => Number.parseFloat(getComputedStyle(element).height)), + refreshRepositories.evaluate((element) => + Number.parseFloat(getComputedStyle(element).height), + ), + ]); + expect(chooseBox).not.toBeNull(); + expect(refreshBox).not.toBeNull(); + expect(refreshBox!.height).toBeCloseTo(chooseBox!.height, 1); + expect(chooseCssHeight).toBe(44); + expect(refreshCssHeight).toBe(44); + }); + + test("uses the HTTP picker on a mobile browser connected to a desktop backend", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(120_000); + const homePath = fs.realpathSync(backend.tmpDir); + let rootSaved = false; + + try { + await backend.restart({ KANDEV_DESKTOP_RUNTIME: "true" }); + await testPage.setViewportSize({ width: 390, height: 844 }); + await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/repositories`); + await testPage.getByRole("button", { name: "Add Local Repository" }).click(); + + const dialog = testPage.getByRole("dialog", { name: "Add Local Repository" }); + const controls = dialog.getByTestId("discovery-root-controls"); + await expect(controls).toBeVisible(); + + const listingResponse = testPage.waitForResponse( + (response) => + response.url().includes("/api/v1/fs/list-dir") && + response.request().method() === "GET" && + response.ok(), + ); + await controls.getByTestId("folder-picker-trigger").tap(); + await listingResponse; + const picker = testPage.getByTestId("folder-picker-popover"); + await expect(picker).toBeVisible(); + await expect(picker.getByTestId("folder-picker-choose")).toBeEnabled(); + + const addResponse = testPage.waitForResponse( + (response) => + response.url().endsWith("/api/v1/repositories/discovery/roots") && + response.request().method() === "POST" && + response.ok(), + ); + await picker.getByTestId("folder-picker-choose").tap(); + expect((await addResponse).status()).toBe(201); + rootSaved = true; + + await expect(controls.getByRole("button", { name: "Refresh repositories" })).toBeVisible(); + expect( + await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + } finally { + if (rootSaved) { + await apiClient + .rawRequest( + "DELETE", + `/api/v1/repositories/discovery/roots?path=${encodeURIComponent(homePath)}`, + ) + .catch(() => undefined); + } + await backend.restart(); + } + }); +}); diff --git a/apps/web/e2e/tests/task/repository-discovery-consent.spec.ts b/apps/web/e2e/tests/task/repository-discovery-consent.spec.ts new file mode 100644 index 0000000000..8126f303a8 --- /dev/null +++ b/apps/web/e2e/tests/task/repository-discovery-consent.spec.ts @@ -0,0 +1,171 @@ +import { expect, test } from "../../fixtures/test-base"; +import { useRegularMode } from "../../helpers/regular-mode"; +import { waitForFiniteAnimations } from "../../helpers/animations"; +import { KanbanPage } from "../../pages/kanban-page"; +import fs from "node:fs"; +import path from "node:path"; + +useRegularMode(); + +test.describe("Desktop repository discovery consent", () => { + test("shows compact discovery actions only inside the repository selector", async ({ + testPage, + backend, + }) => { + test.setTimeout(120_000); + await backend.restart({ KANDEV_DESKTOP_RUNTIME: "true" }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + + const dialog = testPage.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByTestId("discovery-root-controls")).toHaveCount(0); + + await dialog.getByTestId("repo-chip-trigger").first().click(); + const controls = testPage.getByTestId("discovery-root-controls"); + const chooseFolders = controls.getByTestId("folder-picker-trigger"); + const refreshRepositories = controls.getByRole("button", { + name: "Refresh repositories", + }); + await expect(controls).toBeVisible(); + await waitForFiniteAnimations(controls); + + const [chooseBox, refreshBox] = await Promise.all([ + chooseFolders.boundingBox(), + refreshRepositories.boundingBox(), + ]); + expect(chooseBox).not.toBeNull(); + expect(refreshBox).not.toBeNull(); + expect(refreshBox!.height).toBeCloseTo(chooseBox!.height, 1); + expect(refreshBox!.height).toBeLessThanOrEqual(32); + }); + + test("uses the native picker and keeps the discovery root recoverable", async ({ + testPage, + apiClient, + backend, + seedData, + }) => { + test.setTimeout(120_000); + const selectedRoot = fs.mkdtempSync(path.join(backend.tmpDir, "desktop-discovery-root-")); + let rootSaved = false; + let directoryListingRequests = 0; + const onRequest = (request: { url(): string; method(): string }) => { + if (request.url().includes("/api/v1/fs/list-dir") && request.method() === "GET") { + directoryListingRequests += 1; + } + }; + + try { + await backend.restart({ KANDEV_DESKTOP_RUNTIME: "true" }); + await testPage.addInitScript( + ({ selectedPath }: { selectedPath: string }) => { + const win = window as typeof window & { + __TAURI_INTERNALS__?: { + invoke: (command: string, args?: Record) => Promise; + transformCallback: () => number; + }; + __kandevFolderPickerCommands?: string[]; + }; + const commands: string[] = []; + win.__kandevFolderPickerCommands = commands; + Object.defineProperty(win, "__TAURI_INTERNALS__", { + configurable: true, + value: { + transformCallback: () => 1, + invoke: async (command: string) => { + commands.push(command); + if (command === "pick_directory") { + return { status: "selected", path: selectedPath }; + } + if (command === "plugin:event|listen") return 1; + if (command === "get_native_notification_permission") return "granted"; + if (command === "request_native_notification_permission") return "granted"; + if (command === "get_update_state") { + return { + phase: "idle", + currentVersion: "", + latestVersion: null, + releaseNotes: null, + releaseUrl: null, + checkedAtEpochMs: null, + downloadedBytes: null, + totalBytes: null, + installSupported: false, + installUnsupportedReason: null, + error: null, + }; + } + return null; + }, + }, + }); + }, + { selectedPath: selectedRoot }, + ); + testPage.on("request", onRequest); + + await testPage.goto(`/settings/workspaces/${seedData.workspaceId}/repositories`); + await testPage.getByRole("button", { name: "Add Local Repository" }).click(); + const dialog = testPage.getByRole("dialog", { name: "Add Local Repository" }); + const controls = dialog.getByTestId("discovery-root-controls"); + await expect(controls).toBeVisible(); + + const addResponse = testPage.waitForResponse( + (response) => + response.url().endsWith("/api/v1/repositories/discovery/roots") && + response.request().method() === "POST" && + response.ok(), + ); + await controls + .getByRole("button", { name: "Choose folders to discover repositories" }) + .click(); + expect((await addResponse).status()).toBe(201); + rootSaved = true; + + await expect(controls.getByTitle(selectedRoot)).toBeVisible(); + expect( + await testPage.evaluate( + () => + (window as typeof window & { __kandevFolderPickerCommands?: string[] }) + .__kandevFolderPickerCommands ?? [], + ), + ).toContain("pick_directory"); + expect(directoryListingRequests).toBe(0); + + const refreshResponse = testPage.waitForResponse( + (response) => + response.url().includes("/api/v1/workspaces/") && + response.url().includes("/repositories/discovery/refresh") && + response.request().method() === "POST" && + response.ok(), + ); + await controls.getByRole("button", { name: "Refresh repositories" }).click(); + await refreshResponse; + + const removeResponse = testPage.waitForResponse( + (response) => + response.url().includes("/api/v1/repositories/discovery/roots?") && + response.request().method() === "DELETE" && + response.ok(), + ); + await controls.getByRole("button", { name: "Remove" }).click(); + await removeResponse; + rootSaved = false; + await expect(controls.getByTitle(selectedRoot)).toHaveCount(0); + } finally { + testPage.off("request", onRequest); + if (rootSaved) { + await apiClient + .rawRequest( + "DELETE", + `/api/v1/repositories/discovery/roots?path=${encodeURIComponent(selectedRoot)}`, + ) + .catch(() => undefined); + } + fs.rmSync(selectedRoot, { recursive: true, force: true }); + await backend.restart(); + } + }); +}); diff --git a/apps/web/hooks/domains/workspace/use-discovery-root-actions.ts b/apps/web/hooks/domains/workspace/use-discovery-root-actions.ts new file mode 100644 index 0000000000..63db84e04e --- /dev/null +++ b/apps/web/hooks/domains/workspace/use-discovery-root-actions.ts @@ -0,0 +1,61 @@ +"use client"; + +import type { TFunction } from "i18next"; +import { + addDesktopDiscoveryRootAction, + reconnectDesktopDiscoveryRootAction, + removeDesktopDiscoveryRootAction, +} from "@/app/actions/workspaces"; +import { useToast } from "@/components/toast-provider"; + +type DiscoveryRefresh = { + refresh: () => Promise; + load: () => Promise; +}; + +type Toast = ReturnType["toast"]; + +function reportDiscoveryError(toast: Toast, t: TFunction, error: unknown) { + toast({ + title: t("workspaces:failedToDiscoverRepositories"), + description: error instanceof Error ? error.message : t("common:requestFailed"), + variant: "error", + }); +} + +async function runDiscoveryAction( + action: () => Promise, + synchronize: () => Promise, + toast: Toast, + t: TFunction, +): Promise { + try { + await action(); + await synchronize(); + } catch (error) { + reportDiscoveryError(toast, t, error); + } +} + +export function useDiscoveryRootActions(discovery: DiscoveryRefresh, toast: Toast, t: TFunction) { + const refreshDiscovery = () => + runDiscoveryAction(() => Promise.resolve(), discovery.refresh, toast, t); + const handleChooseDiscoveryRoot = (path: string) => + runDiscoveryAction(() => addDesktopDiscoveryRootAction(path), discovery.load, toast, t); + const handleReconnectDiscoveryRoot = (oldPath: string, newPath: string) => + runDiscoveryAction( + () => reconnectDesktopDiscoveryRootAction(oldPath, newPath), + discovery.load, + toast, + t, + ); + const handleRemoveDiscoveryRoot = (path: string) => + runDiscoveryAction(() => removeDesktopDiscoveryRootAction(path), discovery.refresh, toast, t); + + return { + refreshDiscovery, + handleChooseDiscoveryRoot, + handleReconnectDiscoveryRoot, + handleRemoveDiscoveryRoot, + }; +} diff --git a/apps/web/hooks/domains/workspace/use-repository-discovery.test.ts b/apps/web/hooks/domains/workspace/use-repository-discovery.test.ts new file mode 100644 index 0000000000..f404f8ef33 --- /dev/null +++ b/apps/web/hooks/domains/workspace/use-repository-discovery.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from "vitest"; +import { + RepositoryDiscoveryCoordinator, + type RepositoryDiscoveryClient, +} from "./use-repository-discovery"; +import type { RepositoryDiscoveryResponse } from "@/lib/types/http"; + +class FakeVisibilityDocument { + visibilityState: DocumentVisibilityState = "visible"; + private readonly listeners = new Set<() => void>(); + + addEventListener = (_type: string, listener: EventListenerOrEventListenerObject) => { + this.listeners.add(listener as () => void); + }; + + removeEventListener = (_type: string, listener: EventListenerOrEventListenerObject) => { + this.listeners.delete(listener as () => void); + }; + + setVisibility(value: DocumentVisibilityState) { + this.visibilityState = value; + for (const listener of this.listeners) listener(); + } +} + +const oldScan = "2026-08-27T20:00:00.000Z"; +const currentTime = Date.parse("2026-08-27T22:00:00.000Z"); +const workspaceId = "workspace-1"; + +function response(scanTime = oldScan): RepositoryDiscoveryResponse { + return { + roots: ["/work"], + repositories: [{ path: "/work/repo", name: "repo" }], + total: 1, + root_states: [], + scan_time: scanTime, + refreshing: false, + cached: true, + home_confirmation_required: false, + failed_roots: [], + }; +} + +function client(overrides: Partial = {}): RepositoryDiscoveryClient { + return { + getSnapshot: vi.fn(async () => response()), + refresh: vi.fn(async () => response("2026-08-27T21:00:00.000Z")), + ...overrides, + }; +} + +// eslint-disable-next-line max-lines-per-function -- coordinator scenarios share setup and lifecycle assertions +describe("RepositoryDiscoveryCoordinator", () => { + it("shares one stale refresh between active leases", async () => { + const api = client(); + const document = new FakeVisibilityDocument(); + const coordinator = new RepositoryDiscoveryCoordinator(api, { + document, + now: () => currentTime, + }); + + const releaseOne = coordinator.acquire(workspaceId); + const releaseTwo = coordinator.acquire(workspaceId); + await vi.waitFor(() => expect(api.refresh).toHaveBeenCalledTimes(1)); + + expect(api.getSnapshot).toHaveBeenCalledTimes(1); + expect(coordinator.getSnapshot(workspaceId).response?.scan_time).toBe( + "2026-08-27T21:00:00.000Z", + ); + releaseOne(); + releaseTwo(); + coordinator.dispose(); + }); + + it("waits for a visible active lease before refreshing", async () => { + const api = client(); + const document = new FakeVisibilityDocument(); + document.visibilityState = "hidden"; + const coordinator = new RepositoryDiscoveryCoordinator(api, { + document, + now: () => currentTime, + }); + + const release = coordinator.acquire(workspaceId); + await vi.waitFor(() => expect(api.getSnapshot).toHaveBeenCalledTimes(1)); + expect(api.refresh).not.toHaveBeenCalled(); + + document.setVisibility("visible"); + await vi.waitFor(() => expect(api.refresh).toHaveBeenCalledTimes(1)); + release(); + document.setVisibility("hidden"); + document.setVisibility("visible"); + expect(api.refresh).toHaveBeenCalledTimes(1); + coordinator.dispose(); + }); + + it("keeps the last response when a refresh fails", async () => { + const api = client({ + refresh: vi.fn(async () => { + throw new Error("permission denied"); + }), + }); + const coordinator = new RepositoryDiscoveryCoordinator(api, { + now: () => currentTime, + }); + const release = coordinator.acquire(workspaceId); + await vi.waitFor(() => expect(api.refresh).toHaveBeenCalledTimes(1)); + + const state = coordinator.getSnapshot(workspaceId); + expect(state.response?.repositories).toHaveLength(1); + expect(state.error?.message).toBe("permission denied"); + expect(state.isRefreshing).toBe(false); + release(); + coordinator.dispose(); + }); + + it("does not auto-refresh a persisted reconnect-required root", async () => { + const api = client({ + getSnapshot: vi.fn(async () => ({ + ...response(), + root_states: [ + { + id: "root-1", + path: "/work", + display_path: "~/work", + state: "reconnect_required", + }, + ], + })), + }); + const coordinator = new RepositoryDiscoveryCoordinator(api, { + now: () => currentTime, + }); + + const release = coordinator.acquire(workspaceId); + await vi.waitFor(() => expect(api.getSnapshot).toHaveBeenCalledTimes(1)); + expect(api.refresh).not.toHaveBeenCalled(); + release(); + coordinator.dispose(); + }); + + it("joins a refresh reported by a fresh cached snapshot", async () => { + const api = client({ + getSnapshot: vi.fn(async () => ({ + ...response(new Date(currentTime).toISOString()), + refreshing: true, + })), + }); + const coordinator = new RepositoryDiscoveryCoordinator(api, { + now: () => currentTime, + }); + + const release = coordinator.acquire(workspaceId); + await vi.waitFor(() => expect(api.refresh).toHaveBeenCalledTimes(1)); + expect(api.refresh).toHaveBeenCalledWith(workspaceId, "stale_refresh"); + release(); + coordinator.dispose(); + }); + + it("loads the post-action snapshot without starting a refresh", async () => { + const api = client(); + const coordinator = new RepositoryDiscoveryCoordinator(api); + + await coordinator.load(workspaceId); + expect(api.getSnapshot).toHaveBeenCalledTimes(1); + expect(api.refresh).not.toHaveBeenCalled(); + coordinator.dispose(); + }); +}); diff --git a/apps/web/hooks/domains/workspace/use-repository-discovery.ts b/apps/web/hooks/domains/workspace/use-repository-discovery.ts new file mode 100644 index 0000000000..b6861f9096 --- /dev/null +++ b/apps/web/hooks/domains/workspace/use-repository-discovery.ts @@ -0,0 +1,356 @@ +"use client"; + +import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { + getRepositoryDiscoveryAction, + refreshRepositoryDiscoveryAction, +} from "@/app/actions/workspaces"; +import type { RepositoryDiscoveryRefreshTrigger } from "@/app/actions/repository-discovery"; +import type { RepositoryDiscoveryResponse } from "@/lib/types/http"; + +export const REPOSITORY_DISCOVERY_REFRESH_AGE_MS = 30 * 60 * 1000; + +export type RepositoryDiscoveryState = { + response: RepositoryDiscoveryResponse | null; + isLoading: boolean; + isRefreshing: boolean; + error: Error | null; +}; + +export type RepositoryDiscoveryClient = { + getSnapshot: (workspaceId: string) => Promise; + refresh: ( + workspaceId: string, + trigger?: RepositoryDiscoveryRefreshTrigger, + ) => Promise; +}; + +type VisibilityDocument = { + visibilityState: DocumentVisibilityState; + addEventListener: Document["addEventListener"]; + removeEventListener: Document["removeEventListener"]; +}; + +type CoordinatorEntry = { + state: RepositoryDiscoveryState; + listeners: Set<() => void>; + leases: number; + snapshotPromise: Promise | null; + refreshPromise: Promise | null; +}; + +const EMPTY_STATE: RepositoryDiscoveryState = { + response: null, + isLoading: false, + isRefreshing: false, + error: null, +}; + +const EMPTY_REPOSITORIES: RepositoryDiscoveryResponse["repositories"] = []; +const EMPTY_ROOTS: RepositoryDiscoveryResponse["roots"] = []; +const EMPTY_ROOT_STATES: NonNullable = []; +const EMPTY_FAILED_ROOTS: NonNullable = []; + +const DEFAULT_CLIENT: RepositoryDiscoveryClient = { + getSnapshot: (...args) => getRepositoryDiscoveryAction(...args), + refresh: (workspaceId, trigger) => + refreshRepositoryDiscoveryAction(workspaceId, undefined, trigger), +}; + +function browserDocument(): VisibilityDocument | undefined { + return typeof document === "undefined" ? undefined : document; +} + +function asError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +function normalizedResponse(response: RepositoryDiscoveryResponse): RepositoryDiscoveryResponse { + return { + ...response, + roots: response.roots ?? [], + repositories: response.repositories ?? [], + total: response.total ?? response.repositories?.length ?? 0, + root_states: response.root_states ?? [], + refreshing: response.refreshing === true, + cached: response.cached === true, + home_confirmation_required: response.home_confirmation_required === true, + failed_roots: response.failed_roots ?? [], + }; +} + +function hasEffectiveRoots(response: RepositoryDiscoveryResponse): boolean { + return (response.roots?.length ?? 0) > 0; +} + +function isStale( + response: RepositoryDiscoveryResponse | null, + now: () => number, + age: number, +): boolean { + if (!response || !hasEffectiveRoots(response)) return false; + // A failed root is an explicit recovery state. Do not retry it on every + // visible activation; Reconnect, Remove, or manual Refresh is the user's + // decision to touch that path again. + if (response.root_states?.some((root) => root.state === "reconnect_required")) { + return false; + } + if ((response.failed_roots?.length ?? 0) > 0) return false; + if (!response.scan_time) return true; + const timestamp = Date.parse(response.scan_time); + return !Number.isFinite(timestamp) || now() - timestamp >= age; +} + +/** + * Owns repository-discovery leases for one browser tab. It has no timer: + * visibility changes and explicit user actions are the only refresh triggers. + */ +export class RepositoryDiscoveryCoordinator { + private readonly entries = new Map(); + private readonly client: RepositoryDiscoveryClient; + private readonly visibilityDocument?: VisibilityDocument; + private readonly now: () => number; + private readonly refreshAge: number; + private listeningForVisibility = false; + + constructor( + client: RepositoryDiscoveryClient = DEFAULT_CLIENT, + options: { + document?: VisibilityDocument; + now?: () => number; + refreshAge?: number; + } = {}, + ) { + this.client = client; + this.visibilityDocument = options.document ?? browserDocument(); + this.now = options.now ?? Date.now; + this.refreshAge = options.refreshAge ?? REPOSITORY_DISCOVERY_REFRESH_AGE_MS; + } + + getSnapshot(workspaceId: string | null): RepositoryDiscoveryState { + if (!workspaceId) return EMPTY_STATE; + return this.entry(workspaceId).state; + } + + subscribe(workspaceId: string | null, listener: () => void): () => void { + if (!workspaceId) return () => undefined; + const entry = this.entry(workspaceId); + entry.listeners.add(listener); + return () => entry.listeners.delete(listener); + } + + acquire(workspaceId: string): () => void { + const entry = this.entry(workspaceId); + entry.leases += 1; + this.updateVisibilityListener(); + this.loadAndRefreshIfNeeded(workspaceId); + let released = false; + return () => { + if (released) return; + released = true; + entry.leases = Math.max(0, entry.leases - 1); + this.updateVisibilityListener(); + }; + } + + async refresh( + workspaceId: string, + trigger: RepositoryDiscoveryRefreshTrigger = "manual_refresh", + ): Promise { + const entry = this.entry(workspaceId); + await this.startRefresh(workspaceId, entry, trigger); + return entry.state.response; + } + + async load(workspaceId: string): Promise { + const entry = this.entry(workspaceId); + await this.startSnapshot(workspaceId, entry); + return entry.state.response; + } + + dispose(): void { + if (this.listeningForVisibility && this.visibilityDocument) { + this.visibilityDocument.removeEventListener("visibilitychange", this.handleVisibilityChange); + } + this.listeningForVisibility = false; + this.entries.clear(); + } + + private readonly handleVisibilityChange = () => { + if (!this.isVisible()) return; + for (const [workspaceId, entry] of this.entries) { + if (entry.leases > 0) this.loadAndRefreshIfNeeded(workspaceId); + } + }; + + private entry(workspaceId: string): CoordinatorEntry { + const existing = this.entries.get(workspaceId); + if (existing) return existing; + const created: CoordinatorEntry = { + state: { ...EMPTY_STATE }, + listeners: new Set(), + leases: 0, + snapshotPromise: null, + refreshPromise: null, + }; + this.entries.set(workspaceId, created); + return created; + } + + private isVisible(): boolean { + return !this.visibilityDocument || this.visibilityDocument.visibilityState === "visible"; + } + + private updateVisibilityListener(): void { + if (!this.visibilityDocument) return; + const shouldListen = [...this.entries.values()].some((entry) => entry.leases > 0); + if (shouldListen && !this.listeningForVisibility) { + this.visibilityDocument.addEventListener("visibilitychange", this.handleVisibilityChange); + this.listeningForVisibility = true; + } else if (!shouldListen && this.listeningForVisibility) { + this.visibilityDocument.removeEventListener("visibilitychange", this.handleVisibilityChange); + this.listeningForVisibility = false; + } + } + + private notify(entry: CoordinatorEntry): void { + for (const listener of entry.listeners) listener(); + } + + private setState(entry: CoordinatorEntry, patch: Partial): void { + entry.state = { ...entry.state, ...patch }; + this.notify(entry); + } + + private loadAndRefreshIfNeeded(workspaceId: string): void { + const entry = this.entry(workspaceId); + if (!entry.state.response) { + void this.startSnapshot(workspaceId, entry); + return; + } + if ( + entry.leases > 0 && + this.isVisible() && + (entry.state.response?.refreshing === true || + isStale(entry.state.response, this.now, this.refreshAge)) + ) { + void this.startRefresh(workspaceId, entry, "stale_refresh"); + } + } + + private async startSnapshot(workspaceId: string, entry: CoordinatorEntry): Promise { + if (entry.snapshotPromise) return entry.snapshotPromise; + this.setState(entry, { isLoading: true, error: null }); + entry.snapshotPromise = this.client + .getSnapshot(workspaceId) + .then((response) => { + this.setState(entry, { + response: normalizedResponse(response), + isLoading: false, + error: null, + }); + }) + .catch((error: unknown) => { + this.setState(entry, { isLoading: false, error: asError(error) }); + }) + .finally(() => { + entry.snapshotPromise = null; + if ( + entry.leases > 0 && + this.isVisible() && + (entry.state.response?.refreshing === true || + isStale(entry.state.response, this.now, this.refreshAge)) + ) { + void this.startRefresh(workspaceId, entry, "stale_refresh"); + } + }); + return entry.snapshotPromise; + } + + private async startRefresh( + workspaceId: string, + entry: CoordinatorEntry, + trigger: RepositoryDiscoveryRefreshTrigger, + ): Promise { + if (entry.refreshPromise) return entry.refreshPromise; + this.setState(entry, { + isRefreshing: true, + error: null, + }); + entry.refreshPromise = this.client + .refresh(workspaceId, trigger) + .then((response) => { + const normalized = normalizedResponse(response); + this.setState(entry, { + response: normalized, + isRefreshing: normalized.refreshing === true, + error: null, + }); + }) + .catch((error: unknown) => { + this.setState(entry, { isRefreshing: false, error: asError(error) }); + }) + .finally(() => { + entry.refreshPromise = null; + }); + return entry.refreshPromise; + } +} + +export const repositoryDiscoveryCoordinator = new RepositoryDiscoveryCoordinator(); + +function discoveryView( + state: RepositoryDiscoveryState, + refresh: () => Promise, + load: () => Promise, +) { + const response = state.response; + return { + repositories: response?.repositories ?? EMPTY_REPOSITORIES, + roots: response?.roots ?? EMPTY_ROOTS, + rootStates: response?.root_states ?? EMPTY_ROOT_STATES, + scanTime: response?.scan_time, + failedRoots: response?.failed_roots ?? EMPTY_FAILED_ROOTS, + homeConfirmationRequired: response?.home_confirmation_required === true, + isLoading: state.isLoading, + isRefreshing: state.isRefreshing || response?.refreshing === true, + error: state.error, + cached: response?.cached === true, + hasSnapshot: response !== null, + desktopRuntime: response?.desktop_runtime === true, + refresh, + load, + }; +} + +export function useRepositoryDiscovery(workspaceId: string | null, enabled = true) { + const activeWorkspaceId = enabled ? workspaceId : null; + const subscribe = useCallback( + (listener: () => void) => + activeWorkspaceId + ? repositoryDiscoveryCoordinator.subscribe(activeWorkspaceId, listener) + : () => undefined, + [activeWorkspaceId], + ); + const getSnapshot = useCallback( + () => repositoryDiscoveryCoordinator.getSnapshot(activeWorkspaceId), + [activeWorkspaceId], + ); + const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + useEffect(() => { + if (!activeWorkspaceId) return; + return repositoryDiscoveryCoordinator.acquire(activeWorkspaceId); + }, [activeWorkspaceId]); + + const refresh = useCallback(async () => { + if (!activeWorkspaceId) return null; + return repositoryDiscoveryCoordinator.refresh(activeWorkspaceId, "manual_refresh"); + }, [activeWorkspaceId]); + const load = useCallback(async () => { + if (!activeWorkspaceId) return null; + return repositoryDiscoveryCoordinator.load(activeWorkspaceId); + }, [activeWorkspaceId]); + + return discoveryView(state, refresh, load); +} diff --git a/apps/web/lib/desktop/folder-picker.test.ts b/apps/web/lib/desktop/folder-picker.test.ts new file mode 100644 index 0000000000..5354cf199b --- /dev/null +++ b/apps/web/lib/desktop/folder-picker.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createFolderPickerAdapter, + createNativeFolderPickerAdapter, + isTauriWebview, + NativeFolderPickerUnavailableError, +} from "./folder-picker"; + +function transport(invoke: (command: string) => Promise) { + return { isAvailable: () => true, invoke }; +} + +describe("native folder picker adapter", () => { + it("returns the selected directory from the narrow command", async () => { + const invoke = vi.fn(async () => ({ status: "selected", path: "/Users/example/Code" })); + const adapter = createNativeFolderPickerAdapter(transport(invoke), () => true); + + await expect(adapter.pickDirectory()).resolves.toEqual({ + status: "selected", + path: "/Users/example/Code", + }); + expect(invoke).toHaveBeenCalledWith("pick_directory"); + }); + + it("preserves cancellation as an outcome", async () => { + const adapter = createNativeFolderPickerAdapter( + transport(async () => ({ status: "cancelled" })), + () => true, + ); + + await expect(adapter.pickDirectory()).resolves.toEqual({ status: "cancelled" }); + }); + + it("does not use the bridge when the boot capability is absent", async () => { + const invoke = vi.fn(async () => ({ status: "selected", path: "/tmp" })); + const adapter = createNativeFolderPickerAdapter(transport(invoke), () => false); + + await expect(adapter.pickDirectory()).rejects.toBeInstanceOf( + NativeFolderPickerUnavailableError, + ); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("distinguishes a browser from a Tauri WebView", () => { + expect(isTauriWebview({} as Window)).toBe(false); + expect(isTauriWebview({ __TAURI_INTERNALS__: { invoke: vi.fn() } } as unknown as Window)).toBe( + true, + ); + }); + + it("does not select the native adapter for an ordinary browser", () => { + const adapter = createFolderPickerAdapter( + {} as Window, + transport(async () => null), + ); + + expect(adapter.isAvailable()).toBe(false); + }); +}); diff --git a/apps/web/lib/desktop/folder-picker.ts b/apps/web/lib/desktop/folder-picker.ts new file mode 100644 index 0000000000..5f27a4be03 --- /dev/null +++ b/apps/web/lib/desktop/folder-picker.ts @@ -0,0 +1,79 @@ +import { readBootPayload } from "@/src/boot-payload"; + +import { createTauriInvokeTransport } from "./tauri-event-transport"; +import type { DesktopInvokeTransport } from "./updater-adapter"; + +export type FolderPickerOutcome = + | { status: "selected"; path: string } + | { status: "cancelled" } + | { status: "failed"; message?: string }; + +export class NativeFolderPickerUnavailableError extends Error { + readonly code = "native-folder-picker-unavailable"; + + constructor() { + super(); + this.name = "NativeFolderPickerUnavailableError"; + } +} + +export type NativeFolderPickerAdapter = { + isAvailable: () => boolean; + pickDirectory: () => Promise; +}; + +type WindowWithTauri = Window & { + __TAURI_INTERNALS__?: unknown; +}; + +function browserWindow(): Window | undefined { + return typeof window === "undefined" ? undefined : window; +} + +export function isTauriWebview(win: Window | undefined = browserWindow()): boolean { + return Boolean((win as WindowWithTauri | undefined)?.__TAURI_INTERNALS__); +} + +function nativeCapability(win: Window | undefined = browserWindow()): boolean { + if (!win) return false; + return readBootPayload(win).runtime?.nativeFolderPickerAvailable === true; +} + +function parseOutcome(value: unknown): FolderPickerOutcome { + if (!value || typeof value !== "object") return { status: "failed" }; + const record = value as Record; + if (record.status === "cancelled") return { status: "cancelled" }; + if (record.status === "selected" && typeof record.path === "string" && record.path !== "") { + return { status: "selected", path: record.path }; + } + if (record.status === "failed") { + return { + status: "failed", + ...(typeof record.message === "string" ? { message: record.message } : {}), + }; + } + return { status: "failed" }; +} + +export function createNativeFolderPickerAdapter( + transport: DesktopInvokeTransport, + capability: () => boolean, +): NativeFolderPickerAdapter { + const isAvailable = () => capability() && transport.isAvailable(); + return { + isAvailable, + async pickDirectory() { + if (!isAvailable()) throw new NativeFolderPickerUnavailableError(); + return parseOutcome(await transport.invoke("pick_directory")); + }, + }; +} + +export function createFolderPickerAdapter( + win: Window | undefined = browserWindow(), + transport: DesktopInvokeTransport = createTauriInvokeTransport(), +): NativeFolderPickerAdapter { + return createNativeFolderPickerAdapter(transport, () => nativeCapability(win ?? browserWindow())); +} + +export const nativeFolderPicker = createFolderPickerAdapter(); diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index ae9097fc9d..ab1e2b4d53 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -715,10 +715,27 @@ export type LocalRepository = { default_branch?: string; }; +export type DesktopDiscoveryRoot = { + id: string; + path: string; + display_path: string; + state: "connected" | "reconnect_required" | string; + last_scan_at?: string; + last_failure_at?: string; + last_failure_code?: string; +}; + export type RepositoryDiscoveryResponse = { roots: string[]; repositories: LocalRepository[]; total: number; + desktop_runtime?: boolean; + root_states?: DesktopDiscoveryRoot[]; + scan_time?: string; + refreshing?: boolean; + cached?: boolean; + home_confirmation_required?: boolean; + failed_roots?: string[]; }; export type RepositoryPathValidationResponse = { diff --git a/apps/web/src/boot-payload.test.ts b/apps/web/src/boot-payload.test.ts index 77a9fd0661..0092eedc56 100644 --- a/apps/web/src/boot-payload.test.ts +++ b/apps/web/src/boot-payload.test.ts @@ -85,6 +85,27 @@ describe("readBootPayload", () => { expect(readBootPayload(win).runtime?.titlePrefix).toBe("TEST"); }); + it("leaves the title prefix undefined when the runtime block omits it", () => { + const win = { + __KANDEV_BOOT_PAYLOAD__: { runtime: { apiPrefix: "/api/v1" } }, + } as unknown as Window; + + expect(readBootPayload(win).runtime?.titlePrefix).toBeUndefined(); + }); +}); + +describe("readBootPayload runtime metadata", () => { + it("reads the native folder picker capability from the runtime block", () => { + const win = { + __KANDEV_BOOT_PAYLOAD__: { + runtime: { nativeFolderPickerAvailable: true, desktopRuntime: true }, + }, + } as unknown as Window; + + expect(readBootPayload(win).runtime?.nativeFolderPickerAvailable).toBe(true); + expect(readBootPayload(win).runtime?.desktopRuntime).toBe(true); + }); + it("keeps the backend boot identity from the runtime block", () => { const win = { __KANDEV_BOOT_PAYLOAD__: { runtime: { bootId: "boot-123" } }, @@ -104,14 +125,6 @@ describe("readBootPayload", () => { expect(readBootPayload(missing).runtime?.bootId).toBeUndefined(); expect(readBootPayload(malformed).runtime?.bootId).toBeUndefined(); }); - - it("leaves the title prefix undefined when the runtime block omits it", () => { - const win = { - __KANDEV_BOOT_PAYLOAD__: { runtime: { apiPrefix: "/api/v1" } }, - } as unknown as Window; - - expect(readBootPayload(win).runtime?.titlePrefix).toBeUndefined(); - }); }); describe("readBootPayload plugins", () => { diff --git a/apps/web/src/boot-payload.ts b/apps/web/src/boot-payload.ts index b2153c1348..e685d89b04 100644 --- a/apps/web/src/boot-payload.ts +++ b/apps/web/src/boot-payload.ts @@ -41,6 +41,10 @@ export type BootRuntime = { * which never renders through the shell. */ titlePrefix?: string; + /** True only when the Tauri shell launched the backend with its picker bridge. */ + nativeFolderPickerAvailable?: boolean; + /** True when the backend was launched by the desktop shell. */ + desktopRuntime?: boolean; }; export type BootRouteData = { @@ -176,6 +180,8 @@ function readRuntime(value: Record): BootRuntime { nonProduction: value.nonProduction === true ? true : undefined, locale: readString(value.locale), titlePrefix: readString(value.titlePrefix), + nativeFolderPickerAvailable: value.nativeFolderPickerAvailable === true ? true : undefined, + desktopRuntime: value.desktopRuntime === true ? true : undefined, }; } diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json index d2e7f3a556..be0579ee24 100644 --- a/apps/web/src/locales/en/common.json +++ b/apps/web/src/locales/en/common.json @@ -166,6 +166,8 @@ "failedToCreateFolder": "Failed to create folder", "failedToCreateRepository": "Failed to create repository", "failedToLoadDirectory": "Failed to load directory", + "nativeFolderPickerFailed": "The native folder picker could not open.", + "nativeFolderPickerUnavailable": "The native folder picker is unavailable. Reload Kandev and try again.", "failedToLoadRoutine": "Failed to load routine", "failedToLoadSetup": "Failed to load setup", "failedToRenderDiagram": "Failed to render diagram", diff --git a/apps/web/src/locales/en/workspaces.json b/apps/web/src/locales/en/workspaces.json index bc82593606..862e2d61a1 100644 --- a/apps/web/src/locales/en/workspaces.json +++ b/apps/web/src/locales/en/workspaces.json @@ -46,6 +46,10 @@ "devScriptChip": "dev script", "devScriptHelp": "Used to start the preview dev server for this repository. Use <0>{{port}} for automatic port allocation.", "discoverRepositoryDescription": "Select a discovered repository or provide an absolute path to validate.", + "chooseFoldersToDiscoverRepositories": "Choose folders to discover repositories", + "chooseFoldersToDiscoverRepositoriesDescription": "Select Home or a narrower folder. Kandev scans it only while you use repository selection.", + "continueHomeDiscovery": "Continue Home Discovery", + "homeDiscoveryConfirmationDescription": "This installation previously searched Home. Continue only if you want to allow Home discovery.", "discoveredRepositories": "Discovered repositories", "edit": "Edit", "environmentSecrets": "Environment secrets", @@ -92,6 +96,10 @@ "patternSyntax": "Pattern syntax", "remoteRepository": "Remote repository", "repositories": "Repositories", + "refreshRepositories": "Refresh repositories", + "reconnectDiscoveryRoot": "Reconnect", + "removeDiscoveryRoot": "Remove", + "repositoryDiscoveryNoRoots": "Choose a folder to start repository discovery.", "resourceCount_one": "{{count}} resource", "resourceCount_other": "{{count}} resources", "repositoriesInThisWorkspace": "Repositories in this workspace", diff --git a/apps/web/src/locales/pseudo/common.json b/apps/web/src/locales/pseudo/common.json index cc2bdbf09a..d3ad6cf656 100644 --- a/apps/web/src/locales/pseudo/common.json +++ b/apps/web/src/locales/pseudo/common.json @@ -166,6 +166,8 @@ "failedToCreateFolder": "Ƒàĩĺēď ţō ćŕēàţē ƒōĺďēŕ", "failedToCreateRepository": "Ƒàĩĺēď ţō ćŕēàţē ŕēƥōśĩţōŕŷ", "failedToLoadDirectory": "Ƒàĩĺēď ţō ĺōàď ďĩŕēćţōŕŷ", + "nativeFolderPickerFailed": "Ţħē ńàţĩṽē ƒōĺďēŕ ƥĩćķēŕ ćōũĺď ńōţ ōƥēń.", + "nativeFolderPickerUnavailable": "Ţħē ńàţĩṽē ƒōĺďēŕ ƥĩćķēŕ ĩś ũńàṽàĩĺàƀĺē. Ŕēĺōàď Ķàńďēṽ àńď ţŕŷ àĝàĩń.", "failedToLoadRoutine": "Ƒàĩĺēď ţō ĺōàď ŕōũţĩńē", "failedToLoadSetup": "Ƒàĩĺēď ţō ĺōàď śēţũƥ", "failedToRenderDiagram": "Ƒàĩĺēď ţō ŕēńďēŕ ďĩàĝŕàḿ", diff --git a/apps/web/src/locales/pseudo/workspaces.json b/apps/web/src/locales/pseudo/workspaces.json index bdd863ecc7..e231977287 100644 --- a/apps/web/src/locales/pseudo/workspaces.json +++ b/apps/web/src/locales/pseudo/workspaces.json @@ -46,6 +46,10 @@ "devScriptChip": "ďēv śćŕĩƥţ", "devScriptHelp": "Ũśēď ţō śţàŕţ ţĥē ƥŕēvĩēŵ ďēv śēŕvēŕ ƒōŕ ţĥĩś ŕēƥōśĩţōŕŷ. Ũśē <0>{{port}} ƒōŕ àũţōḿàţĩć ƥōŕţ àĺĺōćàţĩōń.", "discoverRepositoryDescription": "Śēĺēćţ à ďĩśćōvēŕēď ŕēƥōśĩţōŕŷ ōŕ ƥŕōvĩďē àń àƀśōĺũţē ƥàţĥ ţō vàĺĩďàţē.", + "chooseFoldersToDiscoverRepositories": "Ćħōōśē ƒōĺďēŕś ţō ďĩśćōvēŕ ŕēƥōśĩţōŕĩēś", + "chooseFoldersToDiscoverRepositoriesDescription": "Śēĺēćţ Ĥōḿē ōŕ à ńàŕŕōŵēŕ ƒōĺďēŕ. Ķàńďēṽ śćàńś ĩţ ōńĺŷ ŵħĩĺē ŷōũ ũśē ŕēƥōśĩţōŕŷ śēĺēćţĩōń.", + "continueHomeDiscovery": "Ćōńţĩńũē Ĥōḿē Ďĩśćōṽēŕŷ", + "homeDiscoveryConfirmationDescription": "Ţħĩś ĩńśţàĺĺàţĩōń ƥŕēṽĩōũśĺŷ śēàŕćĥēď Ĥōḿē. Ćōńţĩńũē ōńĺŷ ĩƒ ŷōũ ŵàńţ ţō àĺĺōŵ Ĥōḿē ďĩśćōṽēŕŷ.", "discoveredRepositories": "Ďĩśćōvēŕēď ŕēƥōśĩţōŕĩēś", "edit": "Ēďĩţ", "environmentSecrets": "Ēńvĩŕōńḿēńţ śēćŕēţś", @@ -92,6 +96,10 @@ "patternSyntax": "Ƥàţţēŕń śŷńţàx", "remoteRepository": "Ŕēḿōţē ŕēƥōśĩţōŕŷ", "repositories": "Ŕēƥōśĩţōŕĩēś", + "refreshRepositories": "Ŕēƒŕēśĥ ŕēƥōśĩţōŕĩēś", + "reconnectDiscoveryRoot": "Ŕēćōńńēćţ", + "removeDiscoveryRoot": "Ŕēḿōṽē", + "repositoryDiscoveryNoRoots": "Ćħōōśē à ƒōĺďēŕ ţō śţàŕţ ŕēƥōśĩţōŕŷ ďĩśćōṽēŕŷ.", "resourceCount_one": "{{count}} ŕēśōũŕćē", "resourceCount_other": "{{count}} ŕēśōũŕćēś", "repositoriesInThisWorkspace": "Ŕēƥōśĩţōŕĩēś ĩń ţĥĩś ŵōŕķśƥàćē", diff --git a/apps/web/src/locales/pt-pt/common.json b/apps/web/src/locales/pt-pt/common.json index b9b0b4ea53..0d40d46c3b 100644 --- a/apps/web/src/locales/pt-pt/common.json +++ b/apps/web/src/locales/pt-pt/common.json @@ -164,6 +164,8 @@ "failedToCreateFolder": "Não foi possível criar a pasta", "failedToCreateRepository": "Não foi possível criar o repositório", "failedToLoadDirectory": "Não foi possível carregar o diretório", + "nativeFolderPickerFailed": "Não foi possível abrir o seletor de pastas nativo.", + "nativeFolderPickerUnavailable": "O seletor de pastas nativo não está disponível. Recarregue o Kandev e tente novamente.", "failedToLoadRoutine": "Não foi possível carregar a rotina", "failedToLoadSetup": "Não foi possível carregar a configuração", "failedToRenderDiagram": "Falha ao renderizar o diagrama", diff --git a/apps/web/src/locales/pt-pt/workspaces.json b/apps/web/src/locales/pt-pt/workspaces.json index 1f6cd96af0..a3ceedb613 100644 --- a/apps/web/src/locales/pt-pt/workspaces.json +++ b/apps/web/src/locales/pt-pt/workspaces.json @@ -46,6 +46,10 @@ "devScriptChip": "script de dev", "devScriptHelp": "Usado para iniciar o servidor de dev da pré-visualização deste repositório. Use <0>{{port}} para atribuição automática da porta.", "discoverRepositoryDescription": "Selecione um repositório descoberto ou indique um caminho absoluto para validar.", + "chooseFoldersToDiscoverRepositories": "Escolher pastas para descobrir repositórios", + "chooseFoldersToDiscoverRepositoriesDescription": "Selecione a pasta pessoal ou uma pasta mais específica. O Kandev só a pesquisa enquanto usa a seleção de repositórios.", + "continueHomeDiscovery": "Continuar a descoberta na pasta pessoal", + "homeDiscoveryConfirmationDescription": "Esta instalação pesquisava anteriormente a pasta pessoal. Continue apenas se quiser permitir essa descoberta.", "discoveredRepositories": "Repositórios descobertos", "edit": "Editar", "environmentSecrets": "Segredos de ambiente", @@ -91,6 +95,10 @@ "patternSyntax": "Sintaxe dos padrões", "remoteRepository": "Repositório remoto", "repositories": "Repositórios", + "refreshRepositories": "Atualizar repositórios", + "reconnectDiscoveryRoot": "Ligar novamente", + "removeDiscoveryRoot": "Remover", + "repositoryDiscoveryNoRoots": "Escolha uma pasta para iniciar a descoberta de repositórios.", "repositoriesInThisWorkspace": "Repositórios nesta área de trabalho", "repository": "Repositório", "repositoryName": "Nome do repositório", diff --git a/apps/web/src/locales/zh-cn/common.json b/apps/web/src/locales/zh-cn/common.json index 3d5730c728..97b5ee4f8e 100644 --- a/apps/web/src/locales/zh-cn/common.json +++ b/apps/web/src/locales/zh-cn/common.json @@ -166,6 +166,8 @@ "failedToCreateFolder": "创建文件夹失败", "failedToCreateRepository": "创建仓库失败", "failedToLoadDirectory": "加载目录失败", + "nativeFolderPickerFailed": "无法打开原生文件夹选择器。", + "nativeFolderPickerUnavailable": "原生文件夹选择器不可用。请重新加载 Kandev 后重试。", "failedToLoadRoutine": "加载例程失败", "failedToLoadSetup": "加载设置失败", "failedToRenderDiagram": "图表渲染失败", diff --git a/apps/web/src/locales/zh-cn/workspaces.json b/apps/web/src/locales/zh-cn/workspaces.json index 620f023857..6cbd744ae4 100644 --- a/apps/web/src/locales/zh-cn/workspaces.json +++ b/apps/web/src/locales/zh-cn/workspaces.json @@ -46,6 +46,10 @@ "devScriptChip": "开发脚本", "devScriptHelp": "用于启动此仓库的预览开发服务器。使用 <0>{{port}} 自动分配端口。", "discoverRepositoryDescription": "选择已发现的仓库,或提供绝对路径进行验证。", + "chooseFoldersToDiscoverRepositories": "选择用于发现仓库的文件夹", + "chooseFoldersToDiscoverRepositoriesDescription": "选择主目录或更小的文件夹。Kandev 只会在你使用仓库选择功能时扫描它。", + "continueHomeDiscovery": "继续发现主目录中的仓库", + "homeDiscoveryConfirmationDescription": "此安装以前会搜索主目录。只有在你允许发现主目录时才继续。", "discoveredRepositories": "已发现的仓库", "edit": "编辑", "environmentSecrets": "环境密钥", @@ -92,6 +96,10 @@ "patternSyntax": "模式语法", "remoteRepository": "远程仓库", "repositories": "仓库", + "refreshRepositories": "刷新仓库", + "reconnectDiscoveryRoot": "重新连接", + "removeDiscoveryRoot": "移除", + "repositoryDiscoveryNoRoots": "选择一个文件夹以开始发现仓库。", "resourceCount_one": "{{count}} 个资源", "resourceCount_other": "{{count}} 个资源", "repositoriesInThisWorkspace": "此工作区中的仓库", diff --git a/apps/web/src/locales/zh-hk/common.json b/apps/web/src/locales/zh-hk/common.json index 9d9dee5cc9..f07d9415f9 100644 --- a/apps/web/src/locales/zh-hk/common.json +++ b/apps/web/src/locales/zh-hk/common.json @@ -166,6 +166,8 @@ "failedToCreateFolder": "建立資料夾失敗", "failedToCreateRepository": "建立儲存庫失敗", "failedToLoadDirectory": "載入目錄失敗", + "nativeFolderPickerFailed": "無法開啟原生資料夾選擇器。", + "nativeFolderPickerUnavailable": "原生資料夾選擇器無法使用。請重新載入 Kandev 後再試。", "failedToLoadRoutine": "載入例程失敗", "failedToLoadSetup": "載入設定失敗", "failedToRenderDiagram": "圖表渲染失敗", diff --git a/apps/web/src/locales/zh-hk/workspaces.json b/apps/web/src/locales/zh-hk/workspaces.json index d897b86a8d..c9bf9aa49d 100644 --- a/apps/web/src/locales/zh-hk/workspaces.json +++ b/apps/web/src/locales/zh-hk/workspaces.json @@ -46,6 +46,10 @@ "devScriptChip": "開發腳本", "devScriptHelp": "用於啓動此儲存庫的預覽開發伺服器。使用 <0>{{port}} 自動分配端口。", "discoverRepositoryDescription": "選擇已發現的儲存庫,或提供絕對路徑進行驗證。", + "chooseFoldersToDiscoverRepositories": "選擇用於發現儲存庫的資料夾", + "chooseFoldersToDiscoverRepositoriesDescription": "選取主目錄或更窄的資料夾。Kandev 只會在你使用儲存庫選擇功能時掃描它。", + "continueHomeDiscovery": "繼續發現主目錄中的儲存庫", + "homeDiscoveryConfirmationDescription": "此安裝以前會搜尋主目錄。只有在你允許發現主目錄時才繼續。", "discoveredRepositories": "已發現的儲存庫", "edit": "編輯", "environmentSecrets": "環境密鑰", @@ -92,6 +96,10 @@ "patternSyntax": "模式語法", "remoteRepository": "遠端儲存庫", "repositories": "儲存庫", + "refreshRepositories": "重新整理儲存庫", + "reconnectDiscoveryRoot": "重新連線", + "removeDiscoveryRoot": "移除", + "repositoryDiscoveryNoRoots": "選擇資料夾以開始發現儲存庫。", "resourceCount_one": "{{count}} 個資源", "resourceCount_other": "{{count}} 個資源", "repositoriesInThisWorkspace": "此工作區中的儲存庫", diff --git a/apps/web/src/locales/zh-tw/common.json b/apps/web/src/locales/zh-tw/common.json index 6c992e7f50..72b01ac3e1 100644 --- a/apps/web/src/locales/zh-tw/common.json +++ b/apps/web/src/locales/zh-tw/common.json @@ -166,6 +166,8 @@ "failedToCreateFolder": "建立資料夾失敗", "failedToCreateRepository": "建立儲存庫失敗", "failedToLoadDirectory": "載入目錄失敗", + "nativeFolderPickerFailed": "無法開啟原生資料夾選擇器。", + "nativeFolderPickerUnavailable": "原生資料夾選擇器無法使用。請重新載入 Kandev 後再試。", "failedToLoadRoutine": "載入例程失敗", "failedToLoadSetup": "載入設定失敗", "failedToRenderDiagram": "圖表渲染失敗", diff --git a/apps/web/src/locales/zh-tw/workspaces.json b/apps/web/src/locales/zh-tw/workspaces.json index 7e15808efd..d9a9e20fba 100644 --- a/apps/web/src/locales/zh-tw/workspaces.json +++ b/apps/web/src/locales/zh-tw/workspaces.json @@ -46,6 +46,10 @@ "devScriptChip": "開發指令碼", "devScriptHelp": "用於啟動此儲存庫的預覽開發伺服器。使用 <0>{{port}} 自動分配埠。", "discoverRepositoryDescription": "選擇已發現的儲存庫,或提供絕對路徑進行驗證。", + "chooseFoldersToDiscoverRepositories": "選擇用於發現儲存庫的資料夾", + "chooseFoldersToDiscoverRepositoriesDescription": "選取主目錄或更窄的資料夾。Kandev 只會在你使用儲存庫選擇功能時掃描它。", + "continueHomeDiscovery": "繼續發現主目錄中的儲存庫", + "homeDiscoveryConfirmationDescription": "此安裝以前會搜尋主目錄。只有在你允許發現主目錄時才繼續。", "discoveredRepositories": "已發現的儲存庫", "edit": "編輯", "environmentSecrets": "環境金鑰", @@ -92,6 +96,10 @@ "patternSyntax": "模式語法", "remoteRepository": "遠端儲存庫", "repositories": "儲存庫", + "refreshRepositories": "重新整理儲存庫", + "reconnectDiscoveryRoot": "重新連線", + "removeDiscoveryRoot": "移除", + "repositoryDiscoveryNoRoots": "選擇資料夾以開始發現儲存庫。", "resourceCount_one": "{{count}} 個資源", "resourceCount_other": "{{count}} 個資源", "repositoriesInThisWorkspace": "此工作區中的儲存庫", diff --git a/docs/decisions/2026-08-27-explicit-desktop-repository-discovery-roots.md b/docs/decisions/2026-08-27-explicit-desktop-repository-discovery-roots.md new file mode 100644 index 0000000000..6a9714a5c1 --- /dev/null +++ b/docs/decisions/2026-08-27-explicit-desktop-repository-discovery-roots.md @@ -0,0 +1,101 @@ +# Use Explicit Roots for Desktop Repository Discovery + +- Status: proposed +- Date: 2026-08-27 +- Area: desktop, backend, frontend, security, operations + +## Context + +The repository discovery endpoint scans the configured roots. When no root is +configured, it scans the process user's home to depth five. Several product +surfaces call discovery when they open. + +That behavior is useful for a server or local web launch. In a macOS desktop +application, an automatic home scan can touch Desktop, Documents, Downloads, or +another protected location. macOS can then display a permission dialog without +a visible user action. + +The current desktop application is unsigned. Apple explains that unsigned code +has no stable designated requirement. Ad hoc signatures identify only one code +version. Therefore macOS cannot reliably retain privacy choices across updates. + +## Decision + +Server-launched backends retain configured-root discovery and the Home fallback. + +Desktop-launched backends retain operator-configured roots. They also use roots +that a user selects through the native folder picker or HTTP folder browser. + +A desktop backend without an effective root does not scan Home. An existing +implicit-Home installation receives a confirmation action during upgrade. + +If macOS Home is a root, discovery skips direct Desktop, Documents, and +Downloads children. If one protected folder is a root, discovery scans it. + +The desktop bridge exposes one origin-checked folder-selection command. It does +not expose a generic filesystem plugin. The Tauri WebView does not use the HTTP +directory-listing API. + +An ordinary browser can use the HTTP folder browser on a desktop backend. The +backend marker selects policy, while the client capability selects the picker. + +Repository-selection surfaces show cached results first. They request a refresh +only while visible and only when the cache is at least 30 minutes old. No +repository-discovery timer runs while those surfaces are closed. + +If access fails, Kandev stops automatic refresh for that root and offers a +Reconnect action. The application does not promise permanent consent while it +remains unsigned. + +## Consequences + +- A new desktop user performs one explicit selection before automatic local + repository discovery begins. +- The server experience remains convenient and compatible with configured roots. +- Selecting Home does not scan its direct protected children by default. +- Configured roots remain effective after a desktop upgrade. +- Existing implicit-Home users receive a visible migration action. +- Desktop updates can still require reconnection until releases have a stable + Developer ID signature. +- All repository-selection surfaces need one shared discovery-state contract. +- Desktop root records become a separate preference from saved repositories and + operator-managed server configuration. +- Logs can identify the operation and target that caused an access denial. + +## Alternatives considered + +### Continue automatic home scans + +Rejected because the application can request protected-folder access while it +is idle or while an unrelated dialog opens. + +### Request Full Disk Access + +Rejected because the application cannot grant it programmatically, and broad +access is not necessary for repository discovery. + +### Add every protected-folder usage description + +Rejected as a fix for prompt frequency because descriptions do not remove +prompts. The selected design still uses them to improve prompt text. + +### Expose the Tauri filesystem or dialog plugin directly to the SPA + +Rejected because direct exposure broadens the loopback WebView's native +authority. A narrow application command is sufficient. + +### Run a fixed background refresh timer after consent + +Rejected because a stale or lost grant can produce a prompt while the user is +away. A visible-surface freshness check provides current results without idle +filesystem access. + +### Keep protected children in a Home scan + +Rejected because one Home selection can cause three macOS dialogs. Users can +select a protected folder directly when it contains repositories. + +### Remove configured roots from desktop policy + +Rejected because configured roots already represent an operator choice. +Removing them breaks repository discovery during an upgrade. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 5333dcb965..d48eafb83d 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -219,6 +219,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-27-bounded-agent-profile-recency | [Store Agent Profile Recency in Bounded Context Rows](2026-08-27-bounded-agent-profile-recency.md) | accepted | backend, frontend, protocol | 2026-08-27 | | 2026-08-27-protect-deferred-launch-attribution | [Protect Deferred Launch Attribution](2026-08-27-protect-deferred-launch-attribution.md) | accepted | backend, protocol | 2026-08-27 | | 2026-08-27-preserve-legacy-sqlite-before-default-initialization | [Preserve Legacy SQLite Data Before Default Initialization](2026-08-27-preserve-legacy-sqlite-before-default-initialization.md) | accepted | backend, cli, operations | 2026-08-27 | +| 2026-08-27-explicit-desktop-repository-discovery-roots | [Use Explicit Roots for Desktop Repository Discovery](2026-08-27-explicit-desktop-repository-discovery-roots.md) | proposed | desktop, backend, frontend, security, operations | 2026-08-27 | | 2026-08-27-mixed-git-change-facets | [Preserve mixed Git changes as path facets](2026-08-27-mixed-git-change-facets.md) | accepted | backend, agentctl, frontend, protocol | 2026-08-27 | | 2026-08-28-bind-github-auto-merge-attempts-to-reviewed-head | [Bind GitHub Auto-Merge Attempts to the Reviewed Head](2026-08-28-bind-github-auto-merge-attempts-to-reviewed-head.md) | accepted | backend, frontend, protocol, security, GitHub | 2026-08-28 | | 2026-08-28-viewport-activation-owns-thread-streams | [Viewport Activation Owns Threads Session Streams](2026-08-28-viewport-activation-owns-thread-streams.md) | proposed | backend, frontend, protocol | 2026-08-28 | diff --git a/docs/plans/desktop-repository-discovery-consent/plan.md b/docs/plans/desktop-repository-discovery-consent/plan.md new file mode 100644 index 0000000000..c5346a3ad5 --- /dev/null +++ b/docs/plans/desktop-repository-discovery-consent/plan.md @@ -0,0 +1,161 @@ +--- +created: 2026-08-27 +status: completed +requirements: + - REQ-WORKSPACES-LOCAL-REPOSITORIES-002 + - REQ-WORKSPACES-LOCAL-REPOSITORIES-003 + - REQ-WORKSPACES-LOCAL-REPOSITORIES-004 +system_design: + - ../../specs/workspaces/system-design/local-repositories.md +legacy_specs: [] +--- + +# Implementation Plan: Desktop Repository Discovery Consent + +## Overview + +Add the desktop picker boundary first. Then add backend policy and state before +the shared user interface. Add diagnostics and idle polling after the primary +prompt fix because these changes use the new operation context. + +## Scope + +### In scope + +- Add a narrow native folder-selection boundary for the Tauri WebView. +- Add launch-aware discovery policy, install-wide desktop roots, and caching. +- Migrate repository-selection surfaces to one activation coordinator. +- Add operation-level access diagnostics. +- Complete one final workspace scan before idle polling stops. +- Update public recovery and configuration documentation. + +### Out of scope + +- Sign or notarize the desktop application. +- Grant Full Disk Access or guarantee consent after an unsigned update. +- Add general native filesystem authority to the SPA. +- Change repository exact-path grants or worktree placement. + +## Technical approach + +### Native boundary + +Add `tauri-plugin-dialog` under the `desktop-runtime` feature in +`apps/desktop/src-tauri/Cargo.toml`. Register it behind one custom command. +Expose that command through `src/lib.rs`, a custom permission, and the default +capability. Add the desktop process marker in `src/backend.rs`. + +### Discovery policy and state + +Change `repository_discovery.go` to choose effective roots from backend launch +mode. Add install-wide desktop-root and migration records under +`internal/task/repository/sqlite`. Add root snapshots and a single-flight cache +in `internal/task/service`. + +Extend the existing walk policy so a macOS Home root excludes its direct +`Desktop`, `Documents`, and `Downloads` children. Keep exact protected-folder +roots scannable. + +### Client selection and activation + +Add one shared discovery hook and one per-tab activation coordinator. The +coordinator combines its lease count with `document.visibilityState`. + +Use the boot picker capability to select the Tauri adapter. Use backend launch +policy for effective roots. Keep the HTTP folder browser for ordinary browser +clients, including a browser on the loopback desktop backend. + +The phone composition reuses the existing Add Workspace Sources `Drawer`. It +keeps one internal scroll owner, safe-area clearance, 44-pixel primary actions, +and shared discovery state with desktop. + +### Diagnostics and polling + +Pass an operation context through discovery, directory listing, validation, +file monitoring, and Git polling. Add bounded access-denial warnings. + +Use `releaseActivity(executionActivityKey(executionID))` as the turn-completion +seam. Request one final file and Git scan, release runtime interest, and deliver +the calculated `paused` mode. Change the 60-second no-push fallback to final +scan and pause. + +## Tests + +| Acceptance criteria | Evidence | +| --- | --- | +| AC-002.1, AC-002.2, AC-002.6, AC-002.14 | Go policy and walker cases in `repository_discovery_test.go` and platform-focused test files | +| AC-002.7 to AC-002.9, AC-002.15, AC-002.16 | Service and SQLite tests for root state, migration, restart, and workspace scope | +| AC-002.4, AC-002.5, AC-002.10, AC-002.12 | Rust command, origin, cancellation, and capability tests through the library target | +| AC-003.1 to AC-003.7 | Go cache tests and frontend coordinator tests with a fake clock and filesystem spy | +| AC-004.1 to AC-004.3 | Go structured-log and bounded-warning tests in the owning packages | +| AC-004.4, AC-004.5 | Lifecycle and agentctl transition tests with a fake clock and filesystem spy | + +## E2E tests + +- `tests/task/repository-discovery-consent.spec.ts` uses a stubbed native-picker + adapter for AC-002.2 to AC-002.9, AC-002.12, AC-002.13, and AC-003.2 to + AC-003.8. +- `tests/task/mobile-repository-discovery.spec.ts` runs in `mobile-chrome`. It + covers AC-002.11 and the same selection, refresh, and recovery outcomes. +- The desktop `e2e` command remains a launch smoke test. Manual macOS QA covers + `NSOpenPanel` and the privacy dialogs. + +## Work orders + +- [x] [Task 01: Add the desktop folder-selection boundary](task-01-desktop-folder-selection-boundary.md) +- [x] [Task 02: Add runtime-aware discovery state](task-02-runtime-aware-discovery-cache.md) +- [x] [Task 03: Update repository discovery user experience](task-03-repository-discovery-ux.md) +- [x] [Task 04: Add filesystem access diagnostics](task-04-filesystem-access-diagnostics.md) +- [x] [Task 05: Reduce idle workspace access](task-05-idle-workspace-access.md) +- [x] [Task 06: Document desktop discovery recovery](task-06-document-discovery-recovery.md) + +Tasks 01 and 02 define shared contracts. Task 03 depends on both. Task 04 +depends on Task 02 for operation context. Task 05 depends on Task 04. Task 06 +follows the completed user and diagnostic behavior. + +## Verification results + +Completed on 2026-08-28. + +- Desktop Rust formatting, library tests (66 passed), and feature checks pass. +- Backend targeted tests, race tests, and `make lint` pass with zero issues. +- Frontend focused Vitest tests (45 passed), typecheck, lint, and i18n checks pass. +- Desktop Chromium and mobile Chromium discovery E2E tests pass against rebuilt artifacts. +- Public documentation validation and specification lint pass. + +The manual macOS NSOpenPanel, privacy-dialog, and unsigned-update checks remain +platform-dependent follow-up QA; this Linux workspace cannot execute them. + +Final automated commands: + +```bash +cd apps/desktop/src-tauri && rtk cargo fmt --check +cd apps/desktop/src-tauri && rtk cargo test --features desktop-runtime +cd apps/desktop/src-tauri && rtk cargo check --features desktop-runtime +cd apps/backend && rtk go test ./internal/task/... ./internal/agent/runtime/lifecycle/... ./internal/agent/runtime/agentctl/... ./internal/agentctl/server/... +cd apps/web && rtk pnpm exec vitest run components lib/desktop app/office/projects +cd apps/web && rtk pnpm run typecheck +cd apps/web && rtk pnpm run i18n:check +cd apps/web && rtk pnpm e2e:run tests/task/repository-discovery-consent.spec.ts +cd apps/web && rtk pnpm e2e:run --project mobile-chrome tests/task/mobile-repository-discovery.spec.ts +cd apps && rtk pnpm --filter @kandev/desktop e2e +rtk make -C apps/backend lint +cd apps && rtk pnpm --filter @kandev/web lint +rtk node --test scripts/validate-public-docs.test.mjs +rtk node scripts/validate-public-docs.mjs +rtk python3 scripts/lint-spec-files.py --all +``` + +Manual macOS QA records the operating-system version, unsigned build identity, +selected root, observed dialog, log event, and recovery result. It covers first +run, Home selection, protected-folder selection, replacement, denial, and +Reconnect. Linux and Windows checks cover picker selection and cancellation. + +## Risks + +- An unsigned replacement can receive a new macOS privacy identity. Reconnect + remains a recovery action, not a permanent consent guarantee. +- A Home selection can recreate the complaint if the walker includes protected + direct children. Platform tests must cover both Home and exact-root cases. +- A lost `paused` transition can leave 30-second polling active. Lifecycle and + agentctl tests must prove the complete transition order. diff --git a/docs/plans/desktop-repository-discovery-consent/task-01-desktop-folder-selection-boundary.md b/docs/plans/desktop-repository-discovery-consent/task-01-desktop-folder-selection-boundary.md new file mode 100644 index 0000000000..bd68cd183e --- /dev/null +++ b/docs/plans/desktop-repository-discovery-consent/task-01-desktop-folder-selection-boundary.md @@ -0,0 +1,107 @@ +--- +id: "01-desktop-folder-selection-boundary" +title: "Add the desktop folder-selection boundary" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - "REQ-WORKSPACES-LOCAL-REPOSITORIES-002" +acceptance_criteria: + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.4" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.5" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.10" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.12" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.17" +system_design: + - "../../specs/workspaces/system-design/local-repositories.md" +--- + +# Task 01: Add the Desktop Folder-Selection Boundary + +## Summary + +Add the narrow native picker boundary and the backend launch marker. Expose a +typed client capability without general filesystem authority. + +## In scope + +- Add `tauri-plugin-dialog` to the desktop runtime and lockfile. +- Add an origin-checked custom command, capability, and command permission. +- Export the picker through `src/lib.rs` for library-target tests. +- Add macOS usage descriptions and the desktop process marker. +- Add the typed boot capability and frontend adapter. + +## Out of scope + +- Save discovery roots or scan repositories. +- Grant direct dialog-plugin or filesystem-plugin access to the WebView. + +## Acceptance + +- The custom command accepts no path and returns selected, cancelled, or failed. +- The Tauri capability permits the custom command, but no generic dialog command. +- Rust and frontend tests cover origin rejection and browser unavailability. + +## Verification + +```bash +cd apps/desktop/src-tauri && rtk cargo fmt --check +cd apps/desktop/src-tauri && rtk cargo test --features desktop-runtime +cd apps/desktop/src-tauri && rtk cargo check --features desktop-runtime +cd apps/backend && rtk go test ./internal/common/config ./internal/backendapp +cd apps/web && rtk pnpm exec vitest run lib/desktop +``` + +Manual macOS verification opens and cancels the directory picker from an +unsigned build. Record the bundle usage text that appears. + +## Files likely touched + +- `apps/desktop/src-tauri/Cargo.toml` +- `apps/desktop/src-tauri/Cargo.lock` +- `apps/desktop/src-tauri/src/main.rs` +- `apps/desktop/src-tauri/src/lib.rs` +- New focused Rust picker module +- `apps/desktop/src-tauri/src/backend.rs` +- `apps/desktop/src-tauri/capabilities/default.json` +- New custom permission under `apps/desktop/src-tauri/permissions/` +- `apps/desktop/src-tauri/tauri.conf.json` +- New macOS `Info.plist` or equivalent bundle configuration +- `apps/backend/internal/common/config/catalog.go` +- Backend boot-payload types and tests +- `apps/web/lib/desktop/` adapter files and tests + +## Dependencies + +None. + +## Risks + +- A plugin permission can widen WebView authority if it exposes the plugin API. +- The binary target has `test = false`, so tests must use the library target. + +## Parallelism + +`sequential` + +## Inputs + +- REQ-WORKSPACES-LOCAL-REPOSITORIES-002 and its mapped design sections. +- Current Tauri notification and external-link origin checks. +- Current boot-payload capability pattern. + +## Results + +Completed. + +- Added the feature-gated Tauri dialog dependency and one origin-checked + `pick_directory` command with cancellation and failure outcomes. +- Added the desktop launch marker, boot capability, custom command permission, + and macOS folder-usage bundle text. The WebView adapter selects the native + command only when both the client and backend capability are present. +- Verification passed: Rust formatting, `cargo test --features + desktop-runtime --lib` (66 tests), `cargo check --features desktop-runtime`, + backend config/runtime tests, and focused folder-picker Vitest tests. + +Manual macOS NSOpenPanel and unsigned-update privacy QA remains platform-dependent. diff --git a/docs/plans/desktop-repository-discovery-consent/task-02-runtime-aware-discovery-cache.md b/docs/plans/desktop-repository-discovery-consent/task-02-runtime-aware-discovery-cache.md new file mode 100644 index 0000000000..58b5701772 --- /dev/null +++ b/docs/plans/desktop-repository-discovery-consent/task-02-runtime-aware-discovery-cache.md @@ -0,0 +1,107 @@ +--- +id: "02-runtime-aware-discovery-cache" +title: "Add runtime-aware discovery state" +status: completed +wave: 2 +depends_on: ["01-desktop-folder-selection-boundary"] +plan: "plan.md" +requirements: + - "REQ-WORKSPACES-LOCAL-REPOSITORIES-002" + - "REQ-WORKSPACES-LOCAL-REPOSITORIES-003" +acceptance_criteria: + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.1" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.2" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.6" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.7" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.8" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.9" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.14" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.15" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.16" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-003.1" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-003.3" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-003.4" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-003.5" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-003.6" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-003.7" +system_design: + - "../../specs/workspaces/system-design/local-repositories.md" +--- + +# Task 02: Add Runtime-Aware Discovery State + +## Summary + +Add backend launch policy, install-wide desktop roots, migration state, and a +bounded discovery cache. Add the macOS Home traversal exception. + +## In scope + +- Resolve server and desktop effective roots. +- Persist selected roots and implicit-Home migration state at install scope. +- Retain configuration-owned roots without copying them into SQLite. +- Add root management, snapshot, and refresh endpoints. +- Add cache freshness, single-flight scans, and last-success preservation. +- Add platform-aware direct-child exclusions for a macOS Home root. + +## Out of scope + +- Change saved repository grants. +- Add user authorization beyond the trusted-local-user model. +- Implement the repository-selection user interface. + +## Acceptance + +- Filesystem-spy tests prove that a desktop backend with no effective root does not walk Home. +- Migration and restart tests preserve configured roots and require confirmation for implicit Home. +- Walker tests skip protected direct children of Home and scan an exact protected root. + +## Verification + +```bash +cd apps/backend && rtk go test ./internal/task/service ./internal/task/handlers ./internal/task/repository/sqlite +cd apps/backend && rtk go test -race ./internal/task/service -run 'Discovery|DesktopRoot|Cache' +``` + +## Files likely touched + +- `apps/backend/internal/task/service/repository_discovery.go` +- New discovery cache and desktop-root service modules +- `apps/backend/internal/task/handlers/` +- `apps/backend/internal/task/dto/` +- `apps/backend/internal/task/models/` +- `apps/backend/internal/task/repository/interface.go` +- `apps/backend/internal/task/repository/sqlite/` + +## Dependencies + +- Task 01 supplies the internal desktop launch marker. + +## Risks + +- An incorrect Home-root comparison can scan protected descendants. +- Workspace-scoped endpoints can cause an accidental workspace-scoped store. +- An upgrade migration can remove configured discovery or start a silent scan. + +## Parallelism + +`sequential` + +## Inputs + +- REQ-WORKSPACES-LOCAL-REPOSITORIES-002 and 003. +- `repository_discovery.go` and its platform tests. +- Existing task SQLite repository and migration patterns. + +## Results + +Completed. + +- Added launch-aware effective-root policy, install-wide desktop-root SQLite + records, legacy Home confirmation migration, scoped root APIs, and the + 30-minute single-flight discovery cache. +- Added safe Home traversal that skips direct macOS Desktop, Documents, and + Downloads children while keeping an explicitly selected protected root + scannable. +- Verification passed: the complete targeted task/backend test set, discovery + policy and SQLite tests, and race-enabled task-service discovery tests. diff --git a/docs/plans/desktop-repository-discovery-consent/task-03-repository-discovery-ux.md b/docs/plans/desktop-repository-discovery-consent/task-03-repository-discovery-ux.md new file mode 100644 index 0000000000..1da7be4724 --- /dev/null +++ b/docs/plans/desktop-repository-discovery-consent/task-03-repository-discovery-ux.md @@ -0,0 +1,116 @@ +--- +id: "03-repository-discovery-ux" +title: "Update repository discovery user experience" +status: completed +wave: 3 +depends_on: + - "01-desktop-folder-selection-boundary" + - "02-runtime-aware-discovery-cache" +plan: "plan.md" +requirements: + - "REQ-WORKSPACES-LOCAL-REPOSITORIES-002" + - "REQ-WORKSPACES-LOCAL-REPOSITORIES-003" +acceptance_criteria: + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.3" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.4" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.5" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.11" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.12" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.13" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.15" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-003.2" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-003.3" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-003.4" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-003.5" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-003.7" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-003.8" +system_design: + - "../../specs/workspaces/system-design/local-repositories.md" +--- + +# Task 03: Update Repository Discovery User Experience + +## Summary + +Move all repository-selection consumers to one discovery hook and activation +coordinator. Select the folder interface from client capability. + +## In scope + +- Add per-tab activation leases and document-visibility handling. +- Add selection, confirmation, refresh, reconnect, and removal actions. +- Use the native adapter in Tauri and the HTTP browser in ordinary browsers. +- Migrate all current repository-selection consumers. +- Add five-locale copy, desktop-viewport web E2E, and mobile web E2E. +- Reuse the Add Workspace Sources `Drawer` for the phone composition. + +## Out of scope + +- Automate the native macOS picker or privacy dialog in Playwright. +- Add a general desktop filesystem browser. +- Duplicate discovery state for mobile presentation. + +## Acceptance + +- Unit tests prove that one coordinator owns leases, stale refresh, and tab visibility. +- Stubbed-picker E2E proves desktop selection, cache, migration, and recovery flows. +- Mobile E2E proves the same outcome in the existing drawer with no horizontal overflow. + +## Verification + +```bash +cd apps/web && rtk pnpm exec vitest run components/task-create-dialog-effects.test.ts app/office/projects components/task/add-workspace-sources components/automations +cd apps/web && rtk pnpm run typecheck +cd apps/web && rtk pnpm run i18n:check +cd apps/web && rtk pnpm e2e:run tests/task/repository-discovery-consent.spec.ts +cd apps/web && rtk pnpm e2e:run --project mobile-chrome tests/task/mobile-repository-discovery.spec.ts +``` + +Inspect a phone screenshot. Check a single internal scroll owner, safe-area +clearance, 44-pixel actions, focus return, and document-width containment. + +## Files likely touched + +- `apps/web/components/task-create-dialog-effects.ts` +- New shared workspace discovery hook and activation coordinator +- `apps/web/components/task/add-workspace-sources/` +- `apps/web/app/settings/workspace/workspace-repositories-client.tsx` +- `apps/web/app/office/projects/` +- `apps/web/components/automations/config-section.tsx` +- `apps/web/components/folder-picker.tsx` +- Workspace API clients, unit tests, E2E tests, and locale catalogs + +## Dependencies + +- Task 01 supplies the native adapter. +- Task 02 supplies root and snapshot APIs. + +## Risks + +- Independent visibility effects can recreate background scans. +- A browser on a desktop backend can receive the wrong picker if policy and + client capability use one flag. +- A phone layout can lose actions if it compresses the desktop dialog. + +## Parallelism + +`sequential` + +## Inputs + +- REQ-WORKSPACES-LOCAL-REPOSITORIES-002 and 003. +- Add Workspace Sources mobile `Drawer` as the nearest shipped exemplar. +- Current task, settings, automations, and Office discovery consumers. + +## Results + +Completed. + +- Added the per-tab lease and visibility coordinator, migrated task, settings, + Office, automation, and workspace-source consumers, and exposed manual + refresh and recovery controls. +- Added the native Tauri folder picker boundary, browser HTTP picker behavior, + desktop root controls, responsive mobile composition, and all locale keys. +- Verification passed: focused Vitest tests (45 tests), web typecheck, web + lint, i18n checks, and the desktop Chromium and mobile Chromium discovery E2E + tests against rebuilt backend and web artifacts. diff --git a/docs/plans/desktop-repository-discovery-consent/task-04-filesystem-access-diagnostics.md b/docs/plans/desktop-repository-discovery-consent/task-04-filesystem-access-diagnostics.md new file mode 100644 index 0000000000..a55429384b --- /dev/null +++ b/docs/plans/desktop-repository-discovery-consent/task-04-filesystem-access-diagnostics.md @@ -0,0 +1,90 @@ +--- +id: "04-filesystem-access-diagnostics" +title: "Add filesystem access diagnostics" +status: completed +wave: 4 +depends_on: ["02-runtime-aware-discovery-cache"] +plan: "plan.md" +requirements: + - "REQ-WORKSPACES-LOCAL-REPOSITORIES-004" +acceptance_criteria: + - "AC-WORKSPACES-LOCAL-REPOSITORIES-004.1" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-004.2" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-004.3" +system_design: + - "../../specs/workspaces/system-design/local-repositories.md" +--- + +# Task 04: Add Filesystem Access Diagnostics + +## Summary + +Add structured operation context to filesystem access. Bound repeated access +warnings and preserve enough data to identify the triggering operation. + +## In scope + +- Add operation, trigger, target, runtime, and identity fields. +- Use the context in discovery, directory listing, validation, monitoring, and Git polling. +- Add bounded warning output and suppressed-count reporting. +- Add focused tests for denied and repeated operations. + +## Out of scope + +- Change poll-mode transitions. +- Change root consent, cache, or retry policy. +- Export diagnostics automatically. + +## Acceptance + +- Tests assert all required fields for each named filesystem operation. +- Repeated-denial tests assert the warning bound and suppressed count. +- Log tests do not depend on macOS to produce a deterministic denial. + +## Verification + +```bash +cd apps/backend && rtk go test ./internal/task/service ./internal/agent/runtime/lifecycle ./internal/agentctl/server/process +rtk make -C apps/backend lint +``` + +## Files likely touched + +- `apps/backend/internal/task/service/repository_discovery.go` +- `apps/backend/internal/task/service/directory_listing.go` +- Repository validation service files +- `apps/backend/internal/agentctl/server/process/workspace_monitor.go` +- `apps/backend/internal/agentctl/server/process/workspace_git_poll.go` +- New operation-context and bounded-warning helpers +- Focused tests beside each owning package + +## Dependencies + +- Task 02 defines discovery triggers and access-failure states. + +## Risks + +- Warning fields can expose local paths in exported diagnostics. +- A shared logging helper can hide ownership if it becomes too generic. + +## Parallelism + +`sequential` + +## Inputs + +- REQ-WORKSPACES-LOCAL-REPOSITORIES-004. +- Existing zap logging conventions in the owning packages. +- Current discovery, directory-listing, validation, monitor, and Git paths. + +## Results + +Completed. + +- Added structured filesystem operation context, bounded access-denial + warnings, discovery/listing/validation diagnostics, and access-loss pause + behavior for workspace tracking. +- Added operation-specific coverage for monitor, files, Git status, and + discovery paths without exposing unbounded local-path diagnostics. +- Verification passed: targeted backend tests, race-enabled lifecycle/process/ + task-service tests, and `make lint` with zero issues. diff --git a/docs/plans/desktop-repository-discovery-consent/task-05-idle-workspace-access.md b/docs/plans/desktop-repository-discovery-consent/task-05-idle-workspace-access.md new file mode 100644 index 0000000000..54684e706d --- /dev/null +++ b/docs/plans/desktop-repository-discovery-consent/task-05-idle-workspace-access.md @@ -0,0 +1,95 @@ +--- +id: "05-idle-workspace-access" +title: "Reduce idle workspace access" +status: completed +wave: 5 +depends_on: ["04-filesystem-access-diagnostics"] +plan: "plan.md" +requirements: + - "REQ-WORKSPACES-LOCAL-REPOSITORIES-004" +acceptance_criteria: + - "AC-WORKSPACES-LOCAL-REPOSITORIES-004.4" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-004.5" +system_design: + - "../../specs/workspaces/system-design/local-repositories.md" +--- + +# Task 05: Reduce Idle Workspace Access + +## Summary + +Complete one final file and Git scan at turn end. Then deliver paused mode to +agentctl and stop automatic retries for a denied tracker. + +## In scope + +- Use execution activity release as the turn-completion signal. +- Request one final workspace refresh before runtime interest ends. +- Deliver `paused` from the lifecycle mode aggregator. +- Change the 60-second no-push fallback to final scan and pause. +- Resume on focus, a new operation, or explicit retry. + +## Out of scope + +- Change fast or slow polling intervals. +- Attribute normal `~/.kandev/tasks` polling to macOS TCC prompts. +- Change repository discovery refresh behavior. + +## Acceptance + +- Lifecycle tests prove final refresh occurs before runtime interest is released. +- Gateway and process tests prove paused delivery and the no-push transition. +- Denial tests prove that only visible user activity can retry a denied tracker. + +## Verification + +```bash +cd apps/backend && rtk go test ./internal/agent/runtime/lifecycle ./internal/agent/runtime/agentctl ./internal/agentctl/server/api ./internal/agentctl/server/process +cd apps/backend && rtk go test -race ./internal/agent/runtime/lifecycle ./internal/agentctl/server/process +rtk make -C apps/backend lint +``` + +## Files likely touched + +- `apps/backend/internal/agent/runtime/lifecycle/activity.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_events.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_subscription.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_subscription_test.go` +- `apps/backend/internal/agent/runtime/agentctl/workspace_state.go` +- `apps/backend/internal/agentctl/server/api/workspace_state.go` +- `apps/backend/internal/agentctl/server/process/manager.go` +- `apps/backend/internal/agentctl/server/process/workspace_poll_mode.go` +- `apps/backend/internal/agentctl/server/process/workspace_monitor.go` +- `apps/backend/internal/agentctl/server/process/workspace_git_poll.go` + +## Dependencies + +- Task 04 supplies the operation and denial diagnostics. + +## Risks + +- A pause before the final scan can leave a stale sidebar diff. +- A suppressed paused push can leave an idle tracker in slow mode. +- Detached final-refresh work can race with a new turn. + +## Parallelism + +`sequential` + +## Inputs + +- REQ-WORKSPACES-LOCAL-REPOSITORIES-004. +- Lifecycle activity leases and workspace mode aggregation. +- Agentctl poll-mode grace and refresh behavior. + +## Results + +Completed. + +- Added the agentctl workspace refresh endpoint and lifecycle final file/Git + scan before releasing turn activity. +- Updated workspace poll-mode and subscription delivery so idle workspaces + pause without losing the final state, while manual refresh and user selection + can recover access. +- Verification passed: targeted lifecycle, agentctl API, and process tests plus + race-enabled lifecycle and process tests. diff --git a/docs/plans/desktop-repository-discovery-consent/task-06-document-discovery-recovery.md b/docs/plans/desktop-repository-discovery-consent/task-06-document-discovery-recovery.md new file mode 100644 index 0000000000..ba1b239885 --- /dev/null +++ b/docs/plans/desktop-repository-discovery-consent/task-06-document-discovery-recovery.md @@ -0,0 +1,94 @@ +--- +id: "06-document-discovery-recovery" +title: "Document desktop discovery recovery" +status: completed +wave: 6 +depends_on: + - "03-repository-discovery-ux" + - "04-filesystem-access-diagnostics" + - "05-idle-workspace-access" +plan: "plan.md" +requirements: + - "REQ-WORKSPACES-LOCAL-REPOSITORIES-002" + - "REQ-WORKSPACES-LOCAL-REPOSITORIES-003" + - "REQ-WORKSPACES-LOCAL-REPOSITORIES-004" +acceptance_criteria: + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.15" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-002.17" + - "AC-WORKSPACES-LOCAL-REPOSITORIES-004.2" +system_design: + - "../../specs/workspaces/system-design/local-repositories.md" +--- + +# Task 06: Document Desktop Discovery Recovery + +## Summary + +Update public desktop and configuration documents. Explain explicit roots, +Reconnect, unsigned-update limits, and diagnostic path disclosure. + +## In scope + +- Explain server roots and desktop-selected roots. +- Explain Home exclusions and explicit protected-folder selection. +- Explain cache refresh, denial, removal, and Reconnect. +- Disclose local paths in exported diagnostics. +- Update scoped engineering guidance only if implementation changes a convention. + +## Out of scope + +- Promise permanent macOS consent for an unsigned application. +- Add a signing or Apple developer-account procedure. +- Repeat the full system design in public documentation. + +## Acceptance + +- Public docs distinguish backend launch policy from client picker capability. +- Recovery text states the unsigned-update limit and gives the Reconnect action. +- Documentation validation and specification lint pass. + +## Verification + +```bash +rtk node --test scripts/validate-public-docs.test.mjs +rtk node scripts/validate-public-docs.mjs +rtk python3 scripts/lint-spec-files.py --all +``` + +## Files likely touched + +- `docs/public/desktop-app.md` +- `docs/public/configuration.md` +- Diagnostic guidance under `docs/public/` +- Scoped `AGENTS.md` files only when implementation changes a convention +- Plan Results sections after implementation + +## Dependencies + +- Tasks 03, 04, and 05 define the final behavior and recovery data. + +## Risks + +- Documentation can imply that a folder selection survives every unsigned update. +- Diagnostic guidance can omit that local paths are sensitive data. + +## Parallelism + +`sequential` + +## Inputs + +- Completed implementation results from Tasks 01 through 05. +- REQ-WORKSPACES-LOCAL-REPOSITORIES-002 through 004. +- Current public desktop and configuration documents. + +## Results + +Completed. + +- Documented launch policy, browser versus native picker capability, Home + confirmation, protected-folder behavior, cache refresh, reconnect/remove + recovery, unsigned-update limits, and sensitive diagnostic paths. +- Updated public coverage metadata and the plan package results. +- Verification passed: public documentation tests and validation plus + `python3 scripts/lint-spec-files.py --all`. diff --git a/docs/public/configuration.md b/docs/public/configuration.md index 0a82b816c6..b409add585 100644 --- a/docs/public/configuration.md +++ b/docs/public/configuration.md @@ -314,6 +314,20 @@ Debug output may contain repository paths, subprocess output, prompts, file cont Discovery roots bound automatic filesystem traversal, so scope them narrowly. They do not authorize explicitly selected repository paths: **Add Local Repository** validates and saves the exact accessible Git repository the user chooses without widening automatic scans. Worktrees and clones can contain credentials or generated files ignored by Git; review repository copy-file and setup/cleanup settings before remote execution. See [Git operations](git-operations.md). +The `repositoryDiscovery.roots` setting belongs to the backend process. A +server-launched backend uses these configured roots and, when no configured +root is available, its server user's Home directory. A desktop-launched +backend keeps the configured roots but does not use Home as an implicit +fallback. In Desktop, use **Add Local Repository** to choose one or more +install-wide discovery folders. Those selections are stored in Kandev's +database, not copied into `config.yaml`, and are shared by workspaces in that +desktop installation. See [Desktop app repository discovery](desktop-app.md#repository-discovery-and-macos-access). + +The desktop process marker and native picker capability are internal launch +details. A browser can connect to a desktop backend and use the HTTP folder +picker; the browser's picker capability does not change the backend's +discovery policy. + ### Debug configuration | YAML key | Environment variable | Default | Current behavior | diff --git a/docs/public/coverage.json b/docs/public/coverage.json index d8149896f2..d59c3aab45 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -61,11 +61,19 @@ ], "sources": [ "apps/backend/internal/task/handlers/workspace_handlers.go", - "apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx" + "apps/backend/internal/task/service/repository_discovery.go", + "apps/backend/internal/task/service/repository_discovery_state.go", + "apps/backend/internal/task/repository/sqlite/desktop_discovery_root.go", + "apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx", + "apps/web/hooks/domains/workspace/use-repository-discovery.ts", + "apps/web/app/settings/workspace/workspace-repositories-dialog.tsx" ], "tests": [ "apps/backend/internal/task/handlers/workspace_handlers_test.go", - "apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx" + "apps/backend/internal/task/service/repository_discovery_test.go", + "apps/backend/internal/task/repository/sqlite/desktop_discovery_root_test.go", + "apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx", + "apps/web/hooks/domains/workspace/use-repository-discovery.test.ts" ], "settingsRoutes": [ "/settings/workspace", diff --git a/docs/public/desktop-app.md b/docs/public/desktop-app.md index 9c08a06e12..d399fb372b 100644 --- a/docs/public/desktop-app.md +++ b/docs/public/desktop-app.md @@ -121,6 +121,41 @@ The application menu exposes New Task, Settings (`Cmd/Ctrl+,`), contextual Close Uninstalling the desktop application does not delete the Kandev home. Keep it to preserve workspaces and settings, or back it up and remove it separately only after all Kandev processes and executors are stopped. See [Operations](operations.md). +## Repository discovery and macOS access + +The desktop backend does not scan your Home directory until you select a +discovery folder. Open **Settings → Workspaces → Repositories → Add Local +Repository**, then choose Home or a narrower folder. The selected folders are +saved for the desktop installation and are available to its workspaces. A +normal browser connected to the same backend uses the HTTP folder picker. The +browser does not receive the desktop app's native picker authority. + +After an upgrade, an existing installation can show **Continue Home +Discovery**. This is a confirmation action. Kandev does not start a new Home +scan without that action. A Home scan skips repositories directly below +`Desktop`, `Documents`, and `Downloads` on macOS. Select one of those folders +explicitly when it contains repositories you want to discover. Selecting that +protected folder itself is allowed. + +Discovery results are cached for up to 30 minutes while a visible repository +surface is open. Empty or filtered results have a **Refresh** action. If a +selected folder becomes unavailable, automatic refresh stops and the folder +shows **Reconnect** and **Remove** actions. Reconnect selects the folder again +and starts a fresh scan. Removal forgets that desktop discovery folder; it does +not remove repositories or files. + +macOS privacy access belongs to the application identity. A replacement that +is unsigned, re-signed, or otherwise has a different identity can require +access again. Kandev cannot promise that consent survives every unsigned +update. Use **Reconnect** or choose the folder again when macOS asks for +access. Do not grant broad Full Disk Access only to work around a missing +repository. + +Backend logs and exported diagnostics can contain local repository, workspace, +and task paths. Treat log files and diagnostic bundles as sensitive data before +sharing them. Redact paths and command output that are not required for the +investigation. + ## Agent CLI discovery Applications started from Finder, Launchpad, desktop menus, or the Windows Start menu often receive less `PATH` configuration than terminal shells. Kandev preserves the available path and appends common locations: diff --git a/docs/specs/desktop/requirements/desktop-tauri-app.md b/docs/specs/desktop/requirements/desktop-tauri-app.md index 32acd8d730..b352170ba0 100644 --- a/docs/specs/desktop/requirements/desktop-tauri-app.md +++ b/docs/specs/desktop/requirements/desktop-tauri-app.md @@ -2,7 +2,7 @@ status: active system: desktop created: 2026-06-23 -updated: 2026-08-19 +updated: 2026-08-27 owners: - tbd --- @@ -28,6 +28,9 @@ Kandev's installed desktop app should behave like a native application without d - **AC-DESKTOP-DESKTOP-TAURI-APP-001.6:** persisted window size, position, and maximized state, restored onto a visible display; - **AC-DESKTOP-DESKTOP-TAURI-APP-001.7:** signed, prompt-before-install desktop updates through the existing System > Updates page; - **AC-DESKTOP-DESKTOP-TAURI-APP-001.8:** native notifications for selected turn-finished, clarification-requested, and session-failure events; +- **AC-DESKTOP-DESKTOP-TAURI-APP-001.9:** an origin-checked native directory + picker for explicit repository discovery and task-folder selection, without + exposing general filesystem access to the SPA. ## System design diff --git a/docs/specs/desktop/system-design/desktop-tauri-app.md b/docs/specs/desktop/system-design/desktop-tauri-app.md index 0176c79b8e..1566e5d5b1 100644 --- a/docs/specs/desktop/system-design/desktop-tauri-app.md +++ b/docs/specs/desktop/system-design/desktop-tauri-app.md @@ -4,7 +4,7 @@ system: desktop requirements: - REQ-DESKTOP-DESKTOP-TAURI-APP-001 created: 2026-06-23 -updated: 2026-08-19 +updated: 2026-08-27 owners: - tbd --- @@ -189,10 +189,13 @@ when native permission is denied or native delivery fails. ## Desktop bridge and permissions The Go-served SPA may receive a small versioned set of desktop events and commands for menu -actions, updater state/actions, native notification delivery, focus, and safe external links. The +actions, updater state/actions, native notification delivery, explicit directory selection, +focus, and safe external links. The capability schema allows loopback ports because the port is selected at launch, but every privileged command verifies the exact health-verified backend origin before executing. The bridge -exposes no arbitrary shell command, filesystem access, or unrestricted URL opening. +exposes no arbitrary shell command, directory enumeration, caller-selected filesystem access, or +unrestricted URL opening. Its directory command only returns a folder selected through the native +system panel. External `http`, `https`, and `mailto` links open in the system browser/client. Internal loopback navigation, downloads, and blob URLs remain in the WebView unless an existing workflow specifies @@ -263,6 +266,8 @@ display before being shown. - A native notification never infers a task route from an ordinary focus, Dock reopen, or second-instance activation event. - External links with unsupported schemes remain blocked. +- Native folder-picker cancellation changes no repository grant and starts no scan. +- A folder-picker request from an origin other than the owned backend is rejected. ## Persistence guarantees @@ -333,6 +338,7 @@ display before being shown. remain responsive and preserve browser/mobile behavior. - Replacing npm, Homebrew, Docker, runtime tarball, or existing package-manager update channels. - Rewriting the shared frontend or backend in Rust. +- General native filesystem plugin access from the SPA. ## Implementation plans and decisions @@ -340,3 +346,4 @@ display before being shown. - [Desktop Native Integration plan](../../../plans/desktop-native-integration/plan.md) - [ADR-0026: Tauri desktop shell over native runtime](../../../decisions/0026-tauri-desktop-shell.md) - [ADR-0039: Native desktop integration boundary](../../../decisions/0039-native-desktop-integration-boundary.md) +- [Desktop Repository Discovery Consent plan](../../../plans/desktop-repository-discovery-consent/plan.md) diff --git a/docs/specs/workspaces/requirements/local-repositories.md b/docs/specs/workspaces/requirements/local-repositories.md index 8f537c7606..05e1072625 100644 --- a/docs/specs/workspaces/requirements/local-repositories.md +++ b/docs/specs/workspaces/requirements/local-repositories.md @@ -10,7 +10,10 @@ owners: ## Overview -Users need to connect repositories already present on the machine running Kandev, including native Windows repositories outside the user's home directory. Explicitly adding one repository should work without widening automatic filesystem scans or editing packaged runtime configuration. +Users need to connect repositories already present on the machine running Kandev. +Server launches can discover repositories from operator-configured roots or the +server user's home. Desktop launches need explicit user-selected discovery roots +so macOS does not receive unexpected protected-folder access. ## Requirements @@ -31,6 +34,105 @@ Users need to connect repositories already present on the machine running Kandev - **AC-WORKSPACES-LOCAL-REPOSITORIES-001.7:** A repository with an `origin` remote refreshes and uses `origin/` for Merge and Rebase. - **AC-WORKSPACES-LOCAL-REPOSITORIES-001.8:** A missing local base branch causes a clear error before Merge or Rebase changes repository history. +### REQ-WORKSPACES-LOCAL-REPOSITORIES-002: Runtime-aware repository discovery + +**Intent:** Repository discovery must preserve server convenience without causing +unexpected filesystem access from the desktop application. + +**User story:** As a desktop user, I want to choose where Kandev searches, so +that repository discovery does not request access while I am idle. + +#### Acceptance criteria + +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.1:** A server launch shall use + `repositoryDiscovery.roots` and shall use the server user's home when those + roots are empty. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.2:** A desktop launch without a saved + or operator-configured root shall not scan the user's home or open a protected + directory. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.3:** A desktop repository-selection + surface shall show saved repositories before it offers automatic discovery. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.4:** A desktop user shall start root + selection from a visible action that opens the native folder picker. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.5:** The native folder picker shall + start at the user's home and shall allow selection of home or a narrower root. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.6:** Kandev shall scan only roots that + the desktop user selected or an operator configured. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.7:** Selecting a root shall save its + canonical path and shall start one immediate discovery scan. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.8:** A saved desktop root shall remain + available after application restart until the user removes it or access fails. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.9:** When access to a saved root fails, + Kandev shall stop automatic refresh for that root and offer a Reconnect action. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.10:** The desktop SPA shall receive only + a narrow native folder-selection command. It shall not receive general native + filesystem authority. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.11:** Browser and phone clients shall + retain server discovery and the server folder browser when they use a + server-launched backend. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.12:** The Tauri WebView shall use the + native folder picker. It shall not call the HTTP directory-listing API. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.13:** An ordinary browser that connects + to a desktop-launched backend shall use desktop discovery policy. It can use + the HTTP folder browser for explicit selection. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.14:** A macOS scan with the user's home + as its root shall skip direct `Desktop`, `Documents`, and `Downloads` + children. A scan shall include one of these folders when that folder is a root. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.15:** On upgrade, a desktop launch shall + retain all operator-configured roots. An existing implicit-home installation + shall show a confirmation action without an automatic home scan. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.16:** Desktop discovery roots shall have + install-wide scope. A workspace-scoped discovery response shall apply the same + effective roots for each workspace. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-002.17:** A macOS privacy dialog that Kandev + can describe shall state why repository or task-folder access is necessary. + +### REQ-WORKSPACES-LOCAL-REPOSITORIES-003: Bounded discovery refresh + +**Intent:** Repository choices need recent data without a background scan that +can display a permission dialog after the user leaves the application. + +#### Acceptance criteria + +- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.1:** Discovery results shall include the + scan time, root state, and whether a refresh is in progress. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.2:** Opening a repository-selection + surface shall show cached results immediately when they exist. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.3:** A visible repository-selection + surface shall start no more than one refresh when the cache is at least 30 + minutes old. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.4:** Kandev shall not run a repository + discovery timer while all repository-selection surfaces are closed. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.5:** A user action shall allow an + immediate refresh without waiting for the 30-minute freshness period. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.6:** Concurrent requests for the same + roots shall share one scan. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.7:** A failed refresh shall preserve the + last successful result and shall expose the failed root and recovery action. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-003.8:** An empty or filtered discovery + result shall show a visible manual Refresh action. + +### REQ-WORKSPACES-LOCAL-REPOSITORIES-004: Filesystem access diagnostics + +**Intent:** Users and support need to identify the operation, path, and trigger +behind a macOS access failure. + +#### Acceptance criteria + +- **AC-WORKSPACES-LOCAL-REPOSITORIES-004.1:** Discovery, directory listing, + repository validation, Git polling, and file monitoring shall identify their + operation and trigger in structured logs. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-004.2:** A denied filesystem operation shall + write a warning with the canonical path, operation, runtime mode, trigger, + task or session identity when present, and operating-system error. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-004.3:** Repeated identical poll failures + shall use bounded warning output and shall report the suppressed count. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-004.4:** After one final file and Git scan, + an idle session without a focused client or in-flight operation shall not keep + polling active. +- **AC-WORKSPACES-LOCAL-REPOSITORIES-004.5:** A denied watched path shall stop + automatic polling until a visible user action retries access. + ## Migrated source detail ## Why @@ -76,13 +178,18 @@ The existing `repositories` record is the durable grant: | `source_type` | `local` for explicitly selected on-machine repositories; `provider` may remain pathless. | | `local_path` | Canonical absolute path for a saved local repository. Empty is permitted for pathless provider repositories. | -No parent-directory grant or install-wide discovery-root record is created. +Saved repositories remain exact-path grants. Desktop discovery roots are a +separate install-wide discovery preference and do not save every repository +found below a root. ## API Surface - `GET /api/v1/workspaces/:id/repositories/discover` continues scanning only configured discovery roots. A caller-provided `root` must remain within those roots. +- Desktop discovery-root endpoints list, add, reconnect, and remove roots only + for a desktop-owned launch. The Tauri client gets a path from the native + picker. An ordinary browser can get a path from the HTTP folder browser. - `GET /api/v1/workspaces/:id/repositories/validate?path=...` validates an explicitly selected path without applying discovery-root containment. It returns the existing path, existence, Git, default-branch, and message fields. The legacy `allowed` field is @@ -98,10 +205,10 @@ No parent-directory grant or install-wide discovery-root record is created. ## Permissions -This feature follows Kandev's current trusted-local-user model: explicitly selecting or saving a -repository is a user grant to access that exact path. Automatic discovery remains constrained by -deployment configuration. A future multi-user authorization model must gate repository grants -directly rather than treating discovery roots as an authorization mechanism. +This feature follows Kandev's current trusted-local-user model. Selecting a +desktop discovery root grants scanning below that root. Saving one repository +grants later repository operations only for that exact canonical path. Server +discovery remains constrained by deployment configuration. ## Failure Modes @@ -119,6 +226,13 @@ directly rather than treating discovery roots as an authorization mechanism. | No `origin` remote and the selected local base branch is missing | Merge and Rebase report that the local base branch does not exist. The operation does not change repository history. | | An `origin` fetch fails | Merge and Rebase report the fetch error. They do not use a local branch as a fallback. | | Automatic scan requests an unconfigured root | Discovery rejects the request and does not scan it. | +| Desktop has no effective root | Discovery is not called; the UI offers explicit folder selection. | +| Desktop has configured discovery roots | Discovery retains those roots and does not require new consent. | +| Existing desktop install used the implicit home fallback | Discovery does not scan Home. The UI offers a Continue Home Discovery action. | +| Home is a macOS discovery root | Discovery skips direct Desktop, Documents, and Downloads children. | +| Desktop, Documents, or Downloads is an explicit root | Discovery scans the selected root and macOS can request access. | +| A saved desktop root becomes inaccessible | The cached result remains visible, automatic refresh stops, and the UI offers Reconnect. | +| macOS forgets access after an unsigned update | The first failed operation logs the target and the UI offers Reconnect without retrying in the background. | | Destructive request supplies only an untrusted raw path | The operation fails closed and does not run Git. | ## Persistence Guarantees @@ -156,8 +270,10 @@ the repository record removes that exact durable grant from the workspace. ## Out of Scope -- A settings page for managing install-wide discovery roots. -- Automatically trusting an entire drive, home-directory parent, or repository parent directory. +- Editing operator-configured server discovery roots from the UI. +- Automatically trusting an entire drive or the parent of the user's home. +- Full Disk Access automation or any claim that an unsigned build can retain a + stable macOS privacy identity across updates. - Changing provider clone placement or container host-path mounting. - Introducing multi-user authentication or repository-grant roles. - Making packaged runtime configuration files writable from the UI. @@ -167,3 +283,4 @@ the repository record removes that exact durable grant from the workspace. - [Explicit Local Repository Trust](../../../plans/explicit-local-repository-trust/plan.md) - [Local-only Merge and Rebase](../../../plans/local-only-merge-rebase/plan.md) +- [Desktop Repository Discovery Consent](../../../plans/desktop-repository-discovery-consent/plan.md) diff --git a/docs/specs/workspaces/system-design/local-repositories.md b/docs/specs/workspaces/system-design/local-repositories.md index 76622acd3b..c2410d3d0a 100644 --- a/docs/specs/workspaces/system-design/local-repositories.md +++ b/docs/specs/workspaces/system-design/local-repositories.md @@ -1,19 +1,306 @@ --- -status: current +status: draft system: workspaces +created: 2026-08-27 +owners: + - kandev requirements: - REQ-WORKSPACES-LOCAL-REPOSITORIES-001 + - REQ-WORKSPACES-LOCAL-REPOSITORIES-002 + - REQ-WORKSPACES-LOCAL-REPOSITORIES-003 + - REQ-WORKSPACES-LOCAL-REPOSITORIES-004 --- -# Local Workspace Repository Validation +# Local Repository Discovery System Design -## Boundary +## Overview -`internal/task/service` canonicalizes an explicit repository path before it is +The workspace system will separate server discovery from desktop discovery. +Server launches retain automatic home discovery. Desktop launches require a +user-selected root before discovery can read the home directory. + +This design extends the existing local-repository contract. It does not turn a +discovered repository into a saved repository grant. + +## Requirement mapping + +| Requirement | Design sections | +| --- | --- | +| REQ-WORKSPACES-LOCAL-REPOSITORIES-001 | API Surface, Permissions, Failure Modes, Persistence Guarantees | +| REQ-WORKSPACES-LOCAL-REPOSITORIES-002 | Runtime policy, Desktop folder selection, Home scan exclusions, Persistence and state, Upgrade behavior | +| REQ-WORKSPACES-LOCAL-REPOSITORIES-003 | Discovery flow, User interface, Persistence and state | +| REQ-WORKSPACES-LOCAL-REPOSITORIES-004 | Workspace polling, Diagnostics, Failure handling | + +## Goals + +- Preserve automatic home discovery for server and web deployments. +- Prevent automatic home scans from an unconfigured desktop application. +- Use a native folder picker for desktop folder selection. +- Return cached repository choices without a filesystem scan. +- Refresh stale data only while a repository-selection surface is visible. +- Explain denied filesystem access through structured diagnostics. +- Stop idle workspace polling after the active operation finishes. + +## Non-goals + +- Programmatically grant Full Disk Access. +- Guarantee macOS permission persistence for unsigned application updates. +- Give the SPA a generic Tauri filesystem plugin. +- Change provider repository discovery or clone placement. +- Move existing worktrees or scratch workspaces. + +## Runtime policy + +The Tauri shell sets a dedicated internal desktop marker when it starts the Go +runtime. This process marker selects discovery policy for all connected clients. + +The boot payload exposes a desktop picker capability. This client capability +selects the available folder-selection interface. It does not change backend +discovery policy. + +| Backend and client | Effective roots | Folder selection | +| --- | --- | --- | +| Server backend and any browser | Configured roots, otherwise server user Home | HTTP folder browser | +| Desktop backend and Tauri WebView | Configured roots plus selected desktop roots | Native folder picker | +| Desktop backend and ordinary browser | Configured roots plus selected desktop roots | HTTP folder browser | + +A desktop backend with no effective root does not scan Home. An ordinary +browser can select a root explicitly, but it does not restore the home fallback. + +The desktop marker is internal wiring. It does not become a supported operator +configuration key. If the marker is absent, the backend uses server policy. + +## Desktop folder selection + +The desktop bridge adds one origin-checked command. The command opens a native +directory panel at the current user's home and returns one selected canonical +directory or a cancellation result. + +The command does not list directories, read files, or accept a caller-provided +path. The Tauri WebView sends the returned path to the desktop root API. The +backend validates the directory before it saves the root. + +The same adapter supplies explicit folder selection for repository discovery +and repo-less task folders. The Tauri WebView never calls +`GET /api/v1/fs/list-dir`. + +The HTTP directory-listing endpoint remains available in desktop mode. An +ordinary browser can use it because the browser selects paths on the backend +host. Opening that browser is an explicit user action. + +Apple documents that a standard open panel grants access to a selected folder +and its descendants for a sandboxed application. Some files can remain +inaccessible for other policy reasons. The implementation must treat selection +as user intent, not as proof that every descendant is readable. + +The macOS bundle supplies usage descriptions for Desktop, Documents, and +Downloads access. These descriptions improve dialog text. They do not reduce +dialog frequency or create a stable code identity. + +## Home scan exclusions + +On macOS, a scan whose canonical root equals the current user Home skips these +direct children: + +- `Desktop` +- `Documents` +- `Downloads`. + +The walker applies this rule only to direct children of Home. If one protected +folder is an effective root, the walker scans that folder normally. + +This rule makes Home discovery useful without three default privacy dialogs. A +user with repositories in a protected folder can select that folder explicitly. + +## Persistence and state + +The backend stores desktop discovery roots in SQLite as install-wide records. +Each record contains: + +- canonical path +- display path relative to home when possible +- state: `connected` or `reconnect_required` +- last successful scan time +- last failure class and time + +Operator-configured roots remain in startup configuration. The effective root +set combines configured roots with desktop records. Workspace identifiers do +not change this root set. + +The backend seeds the effective set from configured roots on each launch. It +does not copy these roots into SQLite. + +The workspace-scoped discovery endpoint returns repositories for one workspace. +It does not give workspace scope to the desktop root records. + +The repository-discovery cache is keyed by the normalized root set and maximum +depth. It stores the last successful repositories, scan time, and root state. +One single-flight scan serves concurrent workspace requests for the same key. + +## Upgrade behavior + +On the first desktop launch after this change, configured roots enter the +effective root set immediately. This path requires no new user selection. + +If an existing installation depended on implicit Home, the backend records a +`home_confirmation_required` migration state. It does not create a Home root. + +The SQLite migration distinguishes an existing database from a new database. +For an existing database, it records this state only when configured and saved +roots are both empty. A new database starts in the normal unconfigured state. + +The UI then shows Continue Home Discovery. If the user selects this action, the +native picker opens at Home. Saved repositories remain available during this +migration. + +## Discovery flow + +1. A repository-selection surface requests the current discovery snapshot. +2. The backend returns cached repositories and freshness metadata without a scan. +3. The frontend renders saved and cached repositories immediately. +4. If the surface is active and the snapshot is 30 minutes old, it requests a refresh. +5. The backend shares an existing scan or starts one scan for the root set. +6. Success replaces the cache and broadcasts the new snapshot. +7. Failure preserves the cache and marks only the failed root for recovery. + +The shared discovery coordinator owns one activation count in each browser tab. +An open consumer acquires one activation lease. It releases the lease when the +surface closes or unmounts. + +The coordinator starts a stale refresh only when both conditions are true: + +- the activation count is more than zero +- `document.visibilityState` is `visible`. + +The coordinator does not cancel an active scan when the document becomes +hidden. It starts no new scan until both conditions are true again. + +No interval runs when the activation count is zero. A manual Refresh action +bypasses the freshness test but still shares an active scan. + +## User interface + +Create Task, Add Workspace Sources, Automations, Office project setup, and +Workspace Repositories consume one discovery-state hook. + +Desktop with no effective root shows saved repositories and one action named +Choose folders to discover repositories. The action explains that the user can +select Home or a narrower folder. + +If migration needs Home confirmation, the surface also shows Continue Home +Discovery. Cancellation leaves the current choices unchanged. + +An inaccessible saved root shows Reconnect and Remove actions. Reconnect opens +the native picker again. It does not retry the denied path in the background. + +An empty result and a filtered result show a visible Refresh action. This action +lets a user find a repository that arrived before the 30-minute limit. + +Phone web with a server backend keeps the server-host repository picker. A +browser on a desktop backend uses desktop policy and the HTTP folder browser. + +Temporary repository choices use the current phone-native picker or drawer +composition. No native Tauri control appears in a browser or mobile viewport. + +## Workspace polling + +Repository discovery and workspace monitoring remain separate functions. Normal +worktrees use `~/.kandev/tasks`, which macOS does not protect with folder TCC. + +Polling changes reduce CPU use for normal worktrees. They reduce privacy dialogs +only for a direct-local task whose repository is in a protected folder. + +Monitoring keeps fast mode for a focused client. It keeps slow mode for an +unfocused agent or terminal operation. + +The target modes are: + +| State | File monitor | Git status | +| --- | --- | --- | +| Focused client | 2 seconds | 3 seconds | +| Unfocused operation in flight | 30 seconds | 30 seconds | +| Turn completes without a focused client | One final scan | One final scan | +| No focused client and no operation in flight | Paused | Paused | +| No mode push during the 60-second startup grace | One final scan, then paused | One final scan, then paused | + +The lifecycle manager uses the activity lease as the operation signal. The +`releaseActivity(executionActivityKey(...))` seam already identifies turn +completion. + +When the last runtime activity ends, the manager requests one final workspace +refresh. Then it removes runtime interest and pushes `paused` unless UI interest +requires `fast` or `slow`. + +The aggregator must send a `paused` transition to agentctl. It must not suppress +that transition when it removes the final contribution. + +If no mode push arrives during the 60-second startup grace, agentctl performs +one final refresh and enters `paused`. It does not stay in `slow` forever. + +An access-denied result pauses the affected tracker. A visible task or explicit +retry action can reactivate it. This rule prevents a denied path from producing +repeated macOS access attempts. + +## Diagnostics + +Every filesystem operation carries an operation context with these fields: + +- operation name +- canonical target path +- trigger, such as `user_select`, `manual_refresh`, `stale_refresh`, or `poll` +- runtime mode +- workspace, task, and session identifiers when available +- poll mode when the operation came from a tracker. + +Expected scan starts and skips use `info`. Access denial uses `warn`. Identical +poll warnings use a bounded logger that records the suppressed count. The first +warning must contain enough data to reproduce the operation. + +Suggested event names are: + +- `repository.discovery.picker_opened` +- `repository.discovery.scan_started` +- `repository.discovery.scan_skipped` +- `filesystem.access_denied` +- `workspace.poll_paused_after_denial`. + +## Failure handling + +- Picker cancellation changes no grant and starts no scan. +- An invalid selected path is rejected without persistence. +- A partial scan returns the last successful cache and identifies failed roots. +- A denied root moves to `reconnect_required` and receives no automatic retry. +- An unsigned update can change macOS code identity. The UI offers Reconnect and + does not claim that one consent will survive every update. +- An unavailable desktop bridge shows a typed error and does not fall back to + the server folder browser inside the Tauri WebView. +- An ordinary browser on the desktop backend can use the HTTP folder browser. +- An old implicit-home installation receives a confirmation state. It does not + receive an automatic Home root. + +## Security and privacy + +The desktop bridge follows the owned-loopback-origin check used by external +links and native notifications. It returns only a path selected by the user. +No command accepts an arbitrary path or exposes directory contents. + +The Tauri permission surface names only the picker command. The implementation +adds `tauri-plugin-dialog` to the `desktop-runtime` feature. It updates +`capabilities/default.json`, generated command permissions, `tauri.conf.json`, +and the bundle usage descriptions. The picker module is exported through +`src/lib.rs` because the binary target has `test = false`. + +The HTTP directory-listing API remains part of the trusted local-user model. The +Tauri WebView cannot use it as a fallback. + +Diagnostic bundles can contain local paths. Public recovery documentation must +state this before a user exports a bundle. + +## Explicit local repository validation + +The workspace system canonicalizes an explicit repository path before it is saved or used. Automatic discovery roots do not constrain explicit selection. -## Metadata validation - Standalone `.git` directories remain valid when they do not redirect their common directory. A regular-file `.git` pointer is accepted through one of two independent reciprocal validators: linked worktrees require `gitdir`, @@ -27,12 +314,25 @@ sources. When neither validator succeeds, their errors are joined to retain diagnostics. -## Requirement mapping +The explicit repository validation contract is recorded in [Explicit submodule +repository trust](../../../decisions/2026-08-28-explicit-submodule-repository-trust.md). -| Requirement | Design section | -| --- | --- | -| REQ-WORKSPACES-LOCAL-REPOSITORIES-001 | Boundary and Metadata validation | +## Verification strategy -## Related ADRs +- Go tests cover runtime policy, canonical roots, cache freshness, single-flight + scans, reconnect state, partial failures, and structured log fields. +- Rust tests cover origin checks, cancellation, directory-only selection, and + the absence of generic filesystem commands. +- Frontend tests cover all repository-selection consumers through one shared hook. +- Web E2E uses a stubbed native-picker adapter for selection, cancellation, + cache, denial, reconnect, removal, and migration states. +- Browser E2E covers server Home discovery at desktop and phone widths. It also + covers an ordinary browser on a desktop backend. +- Rust tests cover the native command and origin rule through the library target. +- Manual macOS QA covers `NSOpenPanel` and real privacy dialogs. +- Lifecycle tests cover final refresh, paused delivery, startup fallback, focus, + and new operation activity. -- [Explicit submodule repository trust](../../../decisions/2026-08-28-explicit-submodule-repository-trust.md) +## Decisions + +- [Use Explicit Roots for Desktop Repository Discovery](../../../decisions/2026-08-27-explicit-desktop-repository-discovery-roots.md)