Story 37: Editor Command for Worktrees
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
47
frontend/src/api/settings.ts
Normal file
47
frontend/src/api/settings.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export interface EditorSettings {
|
||||||
|
editor_command: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_API_BASE = "/api";
|
||||||
|
|
||||||
|
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||||
|
return `${baseUrl}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
baseUrl = DEFAULT_API_BASE,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(buildApiUrl(path, baseUrl), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers ?? {}),
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text || `Request failed (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsApi = {
|
||||||
|
getEditorCommand(baseUrl?: string): Promise<EditorSettings> {
|
||||||
|
return requestJson<EditorSettings>("/settings/editor", {}, baseUrl);
|
||||||
|
},
|
||||||
|
|
||||||
|
setEditorCommand(command: string | null, baseUrl?: string): Promise<EditorSettings> {
|
||||||
|
return requestJson<EditorSettings>(
|
||||||
|
"/settings/editor",
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ editor_command: command }),
|
||||||
|
},
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
AgentStatusValue,
|
AgentStatusValue,
|
||||||
} from "../api/agents";
|
} from "../api/agents";
|
||||||
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
import { agentsApi, subscribeAgentStream } from "../api/agents";
|
||||||
|
import { settingsApi } from "../api/settings";
|
||||||
import type { UpcomingStory } from "../api/workflow";
|
import type { UpcomingStory } from "../api/workflow";
|
||||||
|
|
||||||
const { useCallback, useEffect, useRef, useState } = React;
|
const { useCallback, useEffect, useRef, useState } = React;
|
||||||
@@ -171,6 +172,72 @@ function DiffCommand({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EditorCommand({
|
||||||
|
worktreePath,
|
||||||
|
editorCommand,
|
||||||
|
}: {
|
||||||
|
worktreePath: string;
|
||||||
|
editorCommand: string;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const command = `${editorCommand} "${worktreePath}"`;
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(command);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Fallback: select text for manual copy
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: "0.7em",
|
||||||
|
color: "#8b949e",
|
||||||
|
background: "#0d1117",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #21262d",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{command}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
style={{
|
||||||
|
padding: "3px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #30363d",
|
||||||
|
background: copied ? "#238636" : "#21262d",
|
||||||
|
color: copied ? "#fff" : "#8b949e",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.7em",
|
||||||
|
fontWeight: 600,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? "Copied" : "Open"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentPanel({ stories }: AgentPanelProps) {
|
export function AgentPanel({ stories }: AgentPanelProps) {
|
||||||
const [agents, setAgents] = useState<Record<string, AgentState>>({});
|
const [agents, setAgents] = useState<Record<string, AgentState>>({});
|
||||||
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
|
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
|
||||||
@@ -178,10 +245,13 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||||
const [selectorStory, setSelectorStory] = useState<string | null>(null);
|
const [selectorStory, setSelectorStory] = useState<string | null>(null);
|
||||||
|
const [editorCommand, setEditorCommand] = useState<string | null>(null);
|
||||||
|
const [editorInput, setEditorInput] = useState<string>("");
|
||||||
|
const [editingEditor, setEditingEditor] = useState(false);
|
||||||
const cleanupRefs = useRef<Record<string, () => void>>({});
|
const cleanupRefs = useRef<Record<string, () => void>>({});
|
||||||
const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
|
||||||
// Load roster and existing agents on mount
|
// Load roster, existing agents, and editor preference on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
agentsApi
|
agentsApi
|
||||||
.getAgentConfig()
|
.getAgentConfig()
|
||||||
@@ -211,6 +281,14 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
})
|
})
|
||||||
.catch((err) => console.error("Failed to load agents:", err));
|
.catch((err) => console.error("Failed to load agents:", err));
|
||||||
|
|
||||||
|
settingsApi
|
||||||
|
.getEditorCommand()
|
||||||
|
.then((s) => {
|
||||||
|
setEditorCommand(s.editor_command);
|
||||||
|
setEditorInput(s.editor_command ?? "");
|
||||||
|
})
|
||||||
|
.catch((err) => console.error("Failed to load editor command:", err));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
for (const cleanup of Object.values(cleanupRefs.current)) {
|
for (const cleanup of Object.values(cleanupRefs.current)) {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -347,6 +425,19 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveEditor = async () => {
|
||||||
|
try {
|
||||||
|
const trimmed = editorInput.trim() || null;
|
||||||
|
const result = await settingsApi.setEditorCommand(trimmed);
|
||||||
|
setEditorCommand(result.editor_command);
|
||||||
|
setEditorInput(result.editor_command ?? "");
|
||||||
|
setEditingEditor(false);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setActionError(`Failed to save editor: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Get all active agent keys for a story. */
|
/** Get all active agent keys for a story. */
|
||||||
const getActiveKeysForStory = (storyId: string): string[] => {
|
const getActiveKeysForStory = (storyId: string): string[] => {
|
||||||
return Object.keys(agents).filter((key) => {
|
return Object.keys(agents).filter((key) => {
|
||||||
@@ -404,6 +495,81 @@ export function AgentPanel({ stories }: AgentPanelProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Editor preference */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
||||||
|
<span style={{ fontSize: "0.75em", color: "#666" }}>Editor:</span>
|
||||||
|
{editingEditor ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editorInput}
|
||||||
|
onChange={(e) => setEditorInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSaveEditor();
|
||||||
|
if (e.key === "Escape") setEditingEditor(false);
|
||||||
|
}}
|
||||||
|
placeholder="zed, code, cursor..."
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75em",
|
||||||
|
background: "#111",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: "#ccc",
|
||||||
|
padding: "2px 6px",
|
||||||
|
width: "120px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveEditor}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7em",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #238636",
|
||||||
|
background: "#238636",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingEditor(false)}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7em",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #444",
|
||||||
|
background: "none",
|
||||||
|
color: "#888",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingEditor(true)}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75em",
|
||||||
|
background: "none",
|
||||||
|
border: "1px solid #333",
|
||||||
|
borderRadius: "4px",
|
||||||
|
color: editorCommand ? "#aaa" : "#555",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "2px 8px",
|
||||||
|
fontFamily: editorCommand ? "monospace" : "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editorCommand ?? "Set editor..."}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Roster badges */}
|
{/* Roster badges */}
|
||||||
{roster.length > 0 && (
|
{roster.length > 0 && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::config::ProjectConfig;
|
use crate::config::ProjectConfig;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
|
use crate::http::settings::get_editor_command_from_store;
|
||||||
use crate::http::workflow::{create_story_file, load_upcoming_stories, validate_story_dirs};
|
use crate::http::workflow::{create_story_file, load_upcoming_stories, validate_story_dirs};
|
||||||
use crate::worktree;
|
use crate::worktree;
|
||||||
use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos};
|
use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos};
|
||||||
@@ -562,6 +563,20 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
},
|
},
|
||||||
"required": ["story_id"]
|
"required": ["story_id"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_editor_command",
|
||||||
|
"description": "Get the open-in-editor command for a worktree. Returns a ready-to-paste shell command like 'zed /path/to/worktree'. Requires the editor preference to be configured via PUT /api/settings/editor.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"worktree_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Absolute path to the worktree directory"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["worktree_path"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
@@ -601,6 +616,8 @@ async fn handle_tools_call(
|
|||||||
"create_worktree" => tool_create_worktree(&args, ctx).await,
|
"create_worktree" => tool_create_worktree(&args, ctx).await,
|
||||||
"list_worktrees" => tool_list_worktrees(ctx),
|
"list_worktrees" => tool_list_worktrees(ctx),
|
||||||
"remove_worktree" => tool_remove_worktree(&args, ctx).await,
|
"remove_worktree" => tool_remove_worktree(&args, ctx).await,
|
||||||
|
// Editor tools
|
||||||
|
"get_editor_command" => tool_get_editor_command(&args, ctx),
|
||||||
_ => Err(format!("Unknown tool: {tool_name}")),
|
_ => Err(format!("Unknown tool: {tool_name}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -945,6 +962,20 @@ async fn tool_remove_worktree(args: &Value, ctx: &AppContext) -> Result<String,
|
|||||||
Ok(format!("Worktree for story '{story_id}' removed."))
|
Ok(format!("Worktree for story '{story_id}' removed."))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Editor tool implementations ───────────────────────────────────
|
||||||
|
|
||||||
|
fn tool_get_editor_command(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||||
|
let worktree_path = args
|
||||||
|
.get("worktree_path")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or("Missing required argument: worktree_path")?;
|
||||||
|
|
||||||
|
let editor = get_editor_command_from_store(ctx)
|
||||||
|
.ok_or_else(|| "No editor configured. Set one via PUT /api/settings/editor.".to_string())?;
|
||||||
|
|
||||||
|
Ok(format!("{editor} {worktree_path}"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Run `git log <base>..HEAD --oneline` in the worktree and return the commit
|
/// Run `git log <base>..HEAD --oneline` in the worktree and return the commit
|
||||||
/// summaries, or `None` if git is unavailable or there are no new commits.
|
/// summaries, or `None` if git is unavailable or there are no new commits.
|
||||||
async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option<Vec<String>> {
|
async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option<Vec<String>> {
|
||||||
@@ -1093,7 +1124,8 @@ mod tests {
|
|||||||
assert!(names.contains(&"create_worktree"));
|
assert!(names.contains(&"create_worktree"));
|
||||||
assert!(names.contains(&"list_worktrees"));
|
assert!(names.contains(&"list_worktrees"));
|
||||||
assert!(names.contains(&"remove_worktree"));
|
assert!(names.contains(&"remove_worktree"));
|
||||||
assert_eq!(tools.len(), 16);
|
assert!(names.contains(&"get_editor_command"));
|
||||||
|
assert_eq!(tools.len(), 17);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1338,4 +1370,68 @@ mod tests {
|
|||||||
// commits key present (may be null since no real worktree)
|
// commits key present (may be null since no real worktree)
|
||||||
assert!(parsed.get("commits").is_some());
|
assert!(parsed.get("commits").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Editor command tool tests ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_get_editor_command_missing_worktree_path() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = test_ctx(tmp.path());
|
||||||
|
let result = tool_get_editor_command(&json!({}), &ctx);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("worktree_path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_get_editor_command_no_editor_configured() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = test_ctx(tmp.path());
|
||||||
|
let result = tool_get_editor_command(
|
||||||
|
&json!({"worktree_path": "/some/path"}),
|
||||||
|
&ctx,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("No editor configured"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_get_editor_command_formats_correctly() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = test_ctx(tmp.path());
|
||||||
|
ctx.store.set("editor_command", json!("zed"));
|
||||||
|
|
||||||
|
let result = tool_get_editor_command(
|
||||||
|
&json!({"worktree_path": "/home/user/worktrees/37_my_story"}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, "zed /home/user/worktrees/37_my_story");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_get_editor_command_works_with_vscode() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let ctx = test_ctx(tmp.path());
|
||||||
|
ctx.store.set("editor_command", json!("code"));
|
||||||
|
|
||||||
|
let result = tool_get_editor_command(
|
||||||
|
&json!({"worktree_path": "/path/to/worktree"}),
|
||||||
|
&ctx,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result, "code /path/to/worktree");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_editor_command_in_tools_list() {
|
||||||
|
let resp = handle_tools_list(Some(json!(1)));
|
||||||
|
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||||
|
let tool = tools.iter().find(|t| t["name"] == "get_editor_command");
|
||||||
|
assert!(tool.is_some(), "get_editor_command missing from tools list");
|
||||||
|
let t = tool.unwrap();
|
||||||
|
assert!(t["description"].is_string());
|
||||||
|
let required = t["inputSchema"]["required"].as_array().unwrap();
|
||||||
|
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
|
||||||
|
assert!(req_names.contains(&"worktree_path"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub mod health;
|
|||||||
pub mod io;
|
pub mod io;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
|
pub mod settings;
|
||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
|
||||||
pub mod project;
|
pub mod project;
|
||||||
@@ -23,6 +24,7 @@ use poem::EndpointExt;
|
|||||||
use poem::{Route, get, post};
|
use poem::{Route, get, post};
|
||||||
use poem_openapi::OpenApiService;
|
use poem_openapi::OpenApiService;
|
||||||
use project::ProjectApi;
|
use project::ProjectApi;
|
||||||
|
use settings::SettingsApi;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use workflow::WorkflowApi;
|
use workflow::WorkflowApi;
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ type ApiTuple = (
|
|||||||
ChatApi,
|
ChatApi,
|
||||||
WorkflowApi,
|
WorkflowApi,
|
||||||
AgentsApi,
|
AgentsApi,
|
||||||
|
SettingsApi,
|
||||||
);
|
);
|
||||||
|
|
||||||
type ApiService = OpenApiService<ApiTuple, ()>;
|
type ApiService = OpenApiService<ApiTuple, ()>;
|
||||||
@@ -72,6 +75,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
|||||||
ChatApi { ctx: ctx.clone() },
|
ChatApi { ctx: ctx.clone() },
|
||||||
WorkflowApi { ctx: ctx.clone() },
|
WorkflowApi { ctx: ctx.clone() },
|
||||||
AgentsApi { ctx: ctx.clone() },
|
AgentsApi { ctx: ctx.clone() },
|
||||||
|
SettingsApi { ctx: ctx.clone() },
|
||||||
);
|
);
|
||||||
|
|
||||||
let api_service =
|
let api_service =
|
||||||
@@ -84,7 +88,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
|||||||
IoApi { ctx: ctx.clone() },
|
IoApi { ctx: ctx.clone() },
|
||||||
ChatApi { ctx: ctx.clone() },
|
ChatApi { ctx: ctx.clone() },
|
||||||
WorkflowApi { ctx: ctx.clone() },
|
WorkflowApi { ctx: ctx.clone() },
|
||||||
AgentsApi { ctx },
|
AgentsApi { ctx: ctx.clone() },
|
||||||
|
SettingsApi { ctx },
|
||||||
);
|
);
|
||||||
|
|
||||||
let docs_service =
|
let docs_service =
|
||||||
|
|||||||
143
server/src/http/settings.rs
Normal file
143
server/src/http/settings.rs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||||
|
use crate::store::StoreOps;
|
||||||
|
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const EDITOR_COMMAND_KEY: &str = "editor_command";
|
||||||
|
|
||||||
|
#[derive(Tags)]
|
||||||
|
enum SettingsTags {
|
||||||
|
Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Object)]
|
||||||
|
struct EditorCommandPayload {
|
||||||
|
editor_command: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Object, Serialize)]
|
||||||
|
struct EditorCommandResponse {
|
||||||
|
editor_command: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SettingsApi {
|
||||||
|
pub ctx: Arc<AppContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OpenApi(tag = "SettingsTags::Settings")]
|
||||||
|
impl SettingsApi {
|
||||||
|
/// Get the configured editor command (e.g. "zed", "code", "cursor"), or null if not set.
|
||||||
|
#[oai(path = "/settings/editor", method = "get")]
|
||||||
|
async fn get_editor(&self) -> OpenApiResult<Json<EditorCommandResponse>> {
|
||||||
|
let editor_command = self
|
||||||
|
.ctx
|
||||||
|
.store
|
||||||
|
.get(EDITOR_COMMAND_KEY)
|
||||||
|
.and_then(|v| v.as_str().map(|s| s.to_string()));
|
||||||
|
Ok(Json(EditorCommandResponse { editor_command }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the preferred editor command (e.g. "zed", "code", "cursor").
|
||||||
|
/// Pass null or empty string to clear the preference.
|
||||||
|
#[oai(path = "/settings/editor", method = "put")]
|
||||||
|
async fn set_editor(
|
||||||
|
&self,
|
||||||
|
payload: Json<EditorCommandPayload>,
|
||||||
|
) -> OpenApiResult<Json<EditorCommandResponse>> {
|
||||||
|
let editor_command = payload.0.editor_command;
|
||||||
|
let trimmed = editor_command.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||||
|
match trimmed {
|
||||||
|
Some(cmd) => {
|
||||||
|
self.ctx.store.set(EDITOR_COMMAND_KEY, json!(cmd));
|
||||||
|
self.ctx.store.save().map_err(bad_request)?;
|
||||||
|
Ok(Json(EditorCommandResponse {
|
||||||
|
editor_command: Some(cmd.to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.ctx.store.delete(EDITOR_COMMAND_KEY);
|
||||||
|
self.ctx.store.save().map_err(bad_request)?;
|
||||||
|
Ok(Json(EditorCommandResponse {
|
||||||
|
editor_command: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_editor_command_from_store(ctx: &AppContext) -> Option<String> {
|
||||||
|
ctx.store
|
||||||
|
.get(EDITOR_COMMAND_KEY)
|
||||||
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::http::context::AppContext;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn test_ctx(dir: &TempDir) -> AppContext {
|
||||||
|
AppContext::new_test(dir.path().to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_command_defaults_to_null() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let ctx = test_ctx(&dir);
|
||||||
|
let result = get_editor_command_from_store(&ctx);
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_editor_command_persists_in_store() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let ctx = test_ctx(&dir);
|
||||||
|
|
||||||
|
ctx.store.set(EDITOR_COMMAND_KEY, json!("zed"));
|
||||||
|
ctx.store.save().unwrap();
|
||||||
|
|
||||||
|
let result = get_editor_command_from_store(&ctx);
|
||||||
|
assert_eq!(result, Some("zed".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_editor_command_from_store_returns_value() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let ctx = test_ctx(&dir);
|
||||||
|
|
||||||
|
ctx.store.set(EDITOR_COMMAND_KEY, json!("code"));
|
||||||
|
let result = get_editor_command_from_store(&ctx);
|
||||||
|
assert_eq!(result, Some("code".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_editor_command_returns_none() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let ctx = test_ctx(&dir);
|
||||||
|
|
||||||
|
ctx.store.set(EDITOR_COMMAND_KEY, json!("cursor"));
|
||||||
|
ctx.store.delete(EDITOR_COMMAND_KEY);
|
||||||
|
let result = get_editor_command_from_store(&ctx);
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_command_survives_reload() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let store_path = dir.path().join(".story_kit_store.json");
|
||||||
|
|
||||||
|
{
|
||||||
|
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||||
|
ctx.store.set(EDITOR_COMMAND_KEY, json!("zed"));
|
||||||
|
ctx.store.save().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload from disk
|
||||||
|
let store2 = crate::store::JsonFileStore::new(store_path).unwrap();
|
||||||
|
let val = store2.get(EDITOR_COMMAND_KEY);
|
||||||
|
assert_eq!(val, Some(json!("zed")));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user