diff --git a/cmd/wl/cmd_config.go b/cmd/wl/cmd_config.go index 3b17388..375ddaf 100644 --- a/cmd/wl/cmd_config.go +++ b/cmd/wl/cmd_config.go @@ -19,7 +19,7 @@ Use 'wl config get ' to read a setting. Use 'wl config set ' to change a setting. Supported keys: - mode Workflow mode: wild-west (default) or pr + mode Workflow mode: pr (default) or wild-west signing Enable GPG-signed Dolt commits: true or false provider-type Upstream provider type (read-only, set during 'wl join') github-repo (deprecated) Upstream GitHub repo for PR shells`, diff --git a/cmd/wl/cmd_config_test.go b/cmd/wl/cmd_config_test.go index 889a4ee..7ea08e2 100644 --- a/cmd/wl/cmd_config_test.go +++ b/cmd/wl/cmd_config_test.go @@ -108,8 +108,8 @@ func TestRunConfigGet_ModeDefault(t *testing.T) { if err != nil { t.Fatalf("runConfigGet(mode) error: %v", err) } - if got := strings.TrimSpace(stdout.String()); got != "wild-west" { - t.Errorf("runConfigGet(mode default) = %q, want %q", got, "wild-west") + if got := strings.TrimSpace(stdout.String()); got != "pr" { + t.Errorf("runConfigGet(mode default) = %q, want %q", got, "pr") } } diff --git a/cmd/wl/cmd_serve.go b/cmd/wl/cmd_serve.go index a113fb7..646c509 100644 --- a/cmd/wl/cmd_serve.go +++ b/cmd/wl/cmd_serve.go @@ -310,6 +310,7 @@ func runServeHosted(cmd *cobra.Command, stdout, _ io.Writer) error { defer pendingCache.Stop() anonClient := sdk.New(sdk.ClientConfig{ DB: publicDB, + Mode: federation.ModePR, ListPendingItems: pendingCache.Get, }) apiServer.SetPublicClient(anonClient) diff --git a/internal/federation/federation.go b/internal/federation/federation.go index f977279..0321fc5 100644 --- a/internal/federation/federation.go +++ b/internal/federation/federation.go @@ -63,7 +63,7 @@ type Config struct { // JoinedAt is when the rig joined the wasteland. JoinedAt time.Time `json:"joined_at"` - // Mode is the workflow mode: "" or "wild-west" (default) or "pr". + // Mode is the workflow mode: "" or "pr" (default) or "wild-west". Mode string `json:"mode,omitempty"` // Backend is the database backend: "remote" (DoltHub API, default) or "local" (dolt CLI). @@ -81,10 +81,10 @@ type Config struct { GitHubRepo string `json:"github_repo,omitempty"` } -// ResolveMode returns the effective mode, defaulting to wild-west. +// ResolveMode returns the effective mode, defaulting to PR mode. func (c *Config) ResolveMode() string { - if c.Mode == "" || c.Mode == ModeWildWest { - return ModeWildWest + if c.Mode == "" || c.Mode == ModePR { + return ModePR } return c.Mode } @@ -318,6 +318,7 @@ func (s *Service) Join(upstream, forkOrg, handle, displayName, ownerEmail, versi ForkDB: upstreamDB, LocalDir: localDir, Backend: BackendLocal, + Mode: ModePR, RigHandle: handle, HopURI: hopURI, JoinedAt: time.Now(), @@ -412,6 +413,7 @@ func (s *Service) Create(opts CreateOptions) (*CreateResult, error) { ForkDB: db, LocalDir: localDir, Backend: BackendLocal, + Mode: ModePR, RigHandle: opts.Handle, HopURI: hopURI, JoinedAt: time.Now(), diff --git a/internal/federation/federation_service_test.go b/internal/federation/federation_service_test.go index abfcdd0..050ae96 100644 --- a/internal/federation/federation_service_test.go +++ b/internal/federation/federation_service_test.go @@ -30,6 +30,9 @@ func TestJoin_Success(t *testing.T) { if cfg.RigHandle != "alice-rig" { t.Errorf("RigHandle = %q, want %q", cfg.RigHandle, "alice-rig") } + if cfg.Mode != ModePR { + t.Errorf("Mode = %q, want %q", cfg.Mode, ModePR) + } if !provider.Forked["steveyegge/wl-commons->alice-dev"] { t.Error("expected fork to be created") @@ -322,6 +325,9 @@ func TestCreate_Success(t *testing.T) { if cfg.ProviderType != "fake" { t.Errorf("ProviderType = %q, want %q", cfg.ProviderType, "fake") } + if cfg.Mode != ModePR { + t.Errorf("Mode = %q, want %q", cfg.Mode, ModePR) + } // Verify call ordering: Init → SQLExec → StageAndCommit → RegisterRig → AddRemote → Push expectedOrder := []string{"Init", "SQLExec", "StageAndCommit", "RegisterRig", "AddRemote", "Push"} diff --git a/internal/federation/federation_test.go b/internal/federation/federation_test.go index 56c431c..b452730 100644 --- a/internal/federation/federation_test.go +++ b/internal/federation/federation_test.go @@ -312,6 +312,27 @@ func TestResolveProviderType(t *testing.T) { } } +func TestResolveMode(t *testing.T) { + tests := []struct { + name string + mode string + want string + }{ + {"empty defaults to pr", "", ModePR}, + {"explicit pr", ModePR, ModePR}, + {"explicit wild-west", ModeWildWest, ModeWildWest}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := &Config{Mode: tc.mode} + got := cfg.ResolveMode() + if got != tc.want { + t.Errorf("ResolveMode() = %q, want %q", got, tc.want) + } + }) + } +} + func TestResolveBackend(t *testing.T) { tests := []struct { name string diff --git a/test/integration/offline/lifecycle_test.go b/test/integration/offline/lifecycle_test.go index 7df2adb..26f168f 100644 --- a/test/integration/offline/lifecycle_test.go +++ b/test/integration/offline/lifecycle_test.go @@ -27,6 +27,11 @@ func joinedEnv(t *testing.T, backend backendKind) *testEnv { return env } +func joinedLifecycleEnv(t *testing.T, backend backendKind) *testEnv { + t.Helper() + return joinedEnvInMode(t, backend, "wild-west") +} + // forkCloneDir returns the fork clone dir from the wasteland config. func forkCloneDir(t *testing.T, env *testEnv) string { t.Helper() @@ -146,7 +151,7 @@ func TestSchemaInit(t *testing.T) { func TestPostWanted(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) dbDir := forkCloneDir(t, env) stdout, stderr, err := runWL(t, env, "post", @@ -200,7 +205,7 @@ func TestPostWanted(t *testing.T) { func TestClaimWanted(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) dbDir := forkCloneDir(t, env) // Post an item first. @@ -235,7 +240,7 @@ func TestClaimWanted(t *testing.T) { func TestClaimAlreadyClaimed(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) dbDir := forkCloneDir(t, env) // Post and claim. @@ -269,7 +274,7 @@ func TestClaimAlreadyClaimed(t *testing.T) { func TestDoneFullLifecycle(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) dbDir := forkCloneDir(t, env) // Post. @@ -317,7 +322,7 @@ func TestDoneFullLifecycle(t *testing.T) { func TestDoneWrongClaimer(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) dbDir := forkCloneDir(t, env) // Post and claim as the default rig (forkOrg handle). @@ -354,7 +359,7 @@ func TestDoneWrongClaimer(t *testing.T) { func TestDoneUnclaimed(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) dbDir := forkCloneDir(t, env) // Post but don't claim. @@ -387,7 +392,7 @@ func TestDoneUnclaimed(t *testing.T) { func TestPostOutput(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) stdout, _, err := runWL(t, env, "post", "--title", "Output format test", "--type", "docs", "--no-push") if err != nil { @@ -410,7 +415,7 @@ func TestPostOutput(t *testing.T) { func TestAcceptFullLifecycle(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) dbDir := forkCloneDir(t, env) // Post as forkOrg (poster). @@ -487,7 +492,7 @@ func TestAcceptFullLifecycle(t *testing.T) { func TestAcceptSelfReject(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) // Post → claim → done as forkOrg. stdout, _, err := runWL(t, env, "post", "--title", "Self accept test", "--type", "bug", "--no-push") @@ -518,7 +523,7 @@ func TestAcceptSelfReject(t *testing.T) { func TestRejectFullLifecycle(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) dbDir := forkCloneDir(t, env) // Post as forkOrg (poster). @@ -602,7 +607,7 @@ func TestRejectFullLifecycle(t *testing.T) { func TestUpdateWanted(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) dbDir := forkCloneDir(t, env) // Post. @@ -643,7 +648,7 @@ func TestUpdateWanted(t *testing.T) { func TestUpdateClaimedFails(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) // Post and claim. stdout, _, err := runWL(t, env, "post", "--title", "Claimed update test", "--type", "bug", "--no-push") @@ -669,7 +674,7 @@ func TestUpdateClaimedFails(t *testing.T) { func TestUnclaimWanted(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) dbDir := forkCloneDir(t, env) // Post an item. @@ -713,7 +718,7 @@ func TestUnclaimWanted(t *testing.T) { func TestUnclaimByPoster(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) dbDir := forkCloneDir(t, env) // Post as forkOrg (poster). @@ -761,7 +766,7 @@ func TestUnclaimByPoster(t *testing.T) { func TestDeleteWanted(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) dbDir := forkCloneDir(t, env) // Post. @@ -793,7 +798,7 @@ func TestDeleteWanted(t *testing.T) { func TestDeleteClaimedFails(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedLifecycleEnv(t, backend) // Post and claim. stdout, _, err := runWL(t, env, "post", "--title", "Claimed delete test", "--type", "feature", "--no-push") diff --git a/test/integration/offline/offline_test.go b/test/integration/offline/offline_test.go index d32e2b6..91dc501 100644 --- a/test/integration/offline/offline_test.go +++ b/test/integration/offline/offline_test.go @@ -248,6 +248,14 @@ func (e *testEnv) remoteArgs() []string { } } +// joinedEnvInMode creates a joined env and then explicitly sets the workflow mode. +func joinedEnvInMode(t *testing.T, backend backendKind, mode string) *testEnv { + t.Helper() + env := joinedEnv(t, backend) + setMode(t, env, upstream, mode) + return env +} + // joinWasteland runs "wl join" with the appropriate remote provider as the front door. func (e *testEnv) joinWasteland(t *testing.T, upstream, forkOrg string) { t.Helper() @@ -295,6 +303,24 @@ func runWL(t *testing.T, env *testEnv, args ...string) (string, string, error) { return stdout.String(), stderr.String(), err } +// setMode updates the wasteland config to the given mode. +func setMode(t *testing.T, env *testEnv, upstreamPath, mode string) { + t.Helper() + cfg := env.loadConfig(t, upstreamPath) + cfg["mode"] = mode + + parts := strings.SplitN(upstreamPath, "/", 2) + configPath := filepath.Join(env.ConfigDir, "wastelands", parts[0], parts[1]+".json") + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("marshaling config: %v", err) + } + if err := os.WriteFile(configPath, append(data, '\n'), 0o644); err != nil { + t.Fatalf("writing config: %v", err) + } +} + // doltSQL runs a dolt SQL query against a database directory and returns CSV output. func doltSQL(t *testing.T, dbDir, query string) string { t.Helper() diff --git a/test/integration/offline/pr_mode_test.go b/test/integration/offline/pr_mode_test.go index 0b8d0b5..16151b7 100644 --- a/test/integration/offline/pr_mode_test.go +++ b/test/integration/offline/pr_mode_test.go @@ -4,8 +4,6 @@ package offline import ( "encoding/json" - "os" - "path/filepath" "strings" "testing" ) @@ -102,24 +100,6 @@ func TestReviewJSON(t *testing.T) { } } -// setMode updates the wasteland config to the given mode. -func setMode(t *testing.T, env *testEnv, upstreamPath, mode string) { - t.Helper() - cfg := env.loadConfig(t, upstreamPath) - cfg["mode"] = mode - - parts := strings.SplitN(upstreamPath, "/", 2) - configPath := filepath.Join(env.ConfigDir, "wastelands", parts[0], parts[1]+".json") - - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - t.Fatalf("marshaling config: %v", err) - } - if err := os.WriteFile(configPath, append(data, '\n'), 0o644); err != nil { - t.Fatalf("writing config: %v", err) - } -} - func TestPRModePost(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { @@ -159,7 +139,7 @@ func TestPRModePost(t *testing.T) { func TestPRModeClaim(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedEnvInMode(t, backend, "wild-west") dbDir := forkCloneDir(t, env) // Post in wild-west mode. @@ -234,10 +214,10 @@ func TestPRModeReturnToMain(t *testing.T) { func TestWildWestModeUnchanged(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedEnvInMode(t, backend, "wild-west") dbDir := forkCloneDir(t, env) - // Post in default (wild-west) mode. + // Post in explicit wild-west mode. stdout, _, err := runWL(t, env, "post", "--title", "Wild-west unchanged test", "--type", "feature", @@ -491,19 +471,19 @@ func TestConfigSetGetMode(t *testing.T) { t.Run(string(backend), func(t *testing.T) { env := joinedEnv(t, backend) - // Default mode should be wild-west. + // Default mode should be PR. stdout, stderr, err := runWL(t, env, "config", "get", "mode") if err != nil { t.Fatalf("wl config get mode failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) } - if strings.TrimSpace(stdout) != "wild-west" { - t.Errorf("default mode = %q, want %q", strings.TrimSpace(stdout), "wild-west") + if strings.TrimSpace(stdout) != "pr" { + t.Errorf("default mode = %q, want %q", strings.TrimSpace(stdout), "pr") } - // Set to PR mode. - stdout, stderr, err = runWL(t, env, "config", "set", "mode", "pr") + // Set to wild-west. + stdout, stderr, err = runWL(t, env, "config", "set", "mode", "wild-west") if err != nil { - t.Fatalf("wl config set mode pr failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + t.Fatalf("wl config set mode wild-west failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) } // Verify it's set. @@ -511,14 +491,14 @@ func TestConfigSetGetMode(t *testing.T) { if err != nil { t.Fatalf("wl config get mode failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) } - if strings.TrimSpace(stdout) != "pr" { - t.Errorf("mode = %q, want %q", strings.TrimSpace(stdout), "pr") + if strings.TrimSpace(stdout) != "wild-west" { + t.Errorf("mode = %q, want %q", strings.TrimSpace(stdout), "wild-west") } - // Set back to wild-west. - stdout, stderr, err = runWL(t, env, "config", "set", "mode", "wild-west") + // Set back to PR. + stdout, stderr, err = runWL(t, env, "config", "set", "mode", "pr") if err != nil { - t.Fatalf("wl config set mode wild-west failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + t.Fatalf("wl config set mode pr failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) } // Verify. @@ -526,8 +506,8 @@ func TestConfigSetGetMode(t *testing.T) { if err != nil { t.Fatalf("wl config get mode failed: %v", err) } - if strings.TrimSpace(stdout) != "wild-west" { - t.Errorf("mode = %q, want %q", strings.TrimSpace(stdout), "wild-west") + if strings.TrimSpace(stdout) != "pr" { + t.Errorf("mode = %q, want %q", strings.TrimSpace(stdout), "pr") } }) } diff --git a/test/integration/offline/status_test.go b/test/integration/offline/status_test.go index 8204cea..7dfb076 100644 --- a/test/integration/offline/status_test.go +++ b/test/integration/offline/status_test.go @@ -10,7 +10,7 @@ import ( func TestStatusFullLifecycle(t *testing.T) { for _, backend := range backends { t.Run(string(backend), func(t *testing.T) { - env := joinedEnv(t, backend) + env := joinedEnvInMode(t, backend, "wild-west") // 1. Post as forkOrg → status shows "open" + title. stdout, _, err := runWL(t, env, "post", diff --git a/web/src/components/BrowseList.tsx b/web/src/components/BrowseList.tsx index 9a577c1..9d73c20 100644 --- a/web/src/components/BrowseList.tsx +++ b/web/src/components/BrowseList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { startTransition, useCallback, useEffect, useRef, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { browse } from "../api/client"; @@ -23,9 +23,15 @@ export function BrowseList() { const [showForm, setShowForm] = useState(false); const [showInferForm, setShowInferForm] = useState(false); const [selectedIndex, setSelectedIndex] = useState(-1); + const selectedIndexRef = useRef(-1); const searchRef = useRef(null); const hasLoadedRef = useRef(false); + const setSelection = useCallback((next: number) => { + selectedIndexRef.current = next; + setSelectedIndex(next); + }, []); + const load = useCallback(async () => { if (!hasLoadedRef.current) setLoading(true); setError(""); @@ -36,7 +42,7 @@ export function BrowseList() { const resp = (prefetched && (await prefetched)) || (await browse(filter)); setItems(resp.items); if (resp.warning) setWarning(resp.warning); - setSelectedIndex(-1); + setSelection(-1); hasLoadedRef.current = true; } catch (e) { const msg = e instanceof Error ? e.message : "Failed to load"; @@ -45,7 +51,7 @@ export function BrowseList() { } finally { setLoading(false); } - }, [filter]); + }, [filter, setSelection]); useEffect(() => { load(); @@ -59,11 +65,12 @@ export function BrowseList() { browse(filter) .then((resp) => { setItems(resp.items); + setSelection(-1); }) .catch(() => {}); }, 30_000); return () => clearInterval(id); - }, [filter]); + }, [filter, setSelection]); useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -72,19 +79,25 @@ export function BrowseList() { if (inInput) return; switch (e.key) { - case "j": + case "j": { e.preventDefault(); - setSelectedIndex((i) => Math.min(i + 1, items.length - 1)); + if (items.length === 0) break; + setSelection(Math.min(selectedIndexRef.current + 1, items.length - 1)); break; - case "k": + } + case "k": { e.preventDefault(); - setSelectedIndex((i) => Math.max(i - 1, 0)); + if (items.length === 0) break; + setSelection(Math.max(selectedIndexRef.current - 1, 0)); break; - case "Enter": - if (selectedIndex >= 0 && selectedIndex < items.length) { - navigate(`/wanted/${items[selectedIndex].id}`); + } + case "Enter": { + const index = selectedIndexRef.current; + if (index >= 0 && index < items.length) { + startTransition(() => navigate(`/wanted/${items[index].id}`)); } break; + } case "c": setShowForm(true); break; @@ -100,7 +113,7 @@ export function BrowseList() { window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [items, selectedIndex, navigate]); + }, [items, navigate, setSelection]); return (
diff --git a/web/src/components/Settings.tsx b/web/src/components/Settings.tsx index 2e3de35..f8c0e86 100644 --- a/web/src/components/Settings.tsx +++ b/web/src/components/Settings.tsx @@ -8,7 +8,7 @@ import styles from "./Settings.module.css"; export function Settings() { const navigate = useNavigate(); const { wastelands, active } = useWasteland(); - const [mode, setMode] = useState("wild-west"); + const [mode, setMode] = useState("pr"); const [signing, setSigning] = useState(false); const [rigHandle, setRigHandle] = useState(""); const [loading, setLoading] = useState(true); @@ -21,7 +21,7 @@ export function Settings() { try { const cfg = await config(); setRigHandle(cfg.rig_handle); - setMode(cfg.mode || "wild-west"); + setMode(cfg.mode || "pr"); setHosted(!!cfg.hosted); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to load config");