2026-02-20 14:42:41 +00:00
|
|
|
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
|
|
|
|
use crate::store::StoreOps;
|
2026-02-26 12:34:57 +00:00
|
|
|
use poem_openapi::{Object, OpenApi, Tags, param::Query, payload::Json};
|
2026-02-20 14:42:41 +00:00
|
|
|
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>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 12:34:57 +00:00
|
|
|
#[derive(Debug, Object, Serialize)]
|
|
|
|
|
struct OpenFileResponse {
|
|
|
|
|
success: bool,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 14:42:41 +00:00
|
|
|
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 }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 12:34:57 +00:00
|
|
|
/// Open a file in the configured editor at the given line number.
|
|
|
|
|
///
|
|
|
|
|
/// Invokes the stored editor CLI (e.g. "zed", "code") with `path:line` as the argument.
|
|
|
|
|
/// Returns an error if no editor is configured or if the process fails to spawn.
|
|
|
|
|
#[oai(path = "/settings/open-file", method = "post")]
|
|
|
|
|
async fn open_file(
|
|
|
|
|
&self,
|
|
|
|
|
path: Query<String>,
|
|
|
|
|
line: Query<Option<u32>>,
|
|
|
|
|
) -> OpenApiResult<Json<OpenFileResponse>> {
|
|
|
|
|
let editor_command = get_editor_command_from_store(&self.ctx)
|
|
|
|
|
.ok_or_else(|| bad_request("No editor configured".to_string()))?;
|
|
|
|
|
|
|
|
|
|
let file_ref = match line.0 {
|
|
|
|
|
Some(l) => format!("{}:{}", path.0, l),
|
|
|
|
|
None => path.0.clone(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
std::process::Command::new(&editor_command)
|
|
|
|
|
.arg(&file_ref)
|
|
|
|
|
.spawn()
|
|
|
|
|
.map_err(|e| bad_request(format!("Failed to open editor: {e}")))?;
|
|
|
|
|
|
|
|
|
|
Ok(Json(OpenFileResponse { success: true }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 14:42:41 +00:00
|
|
|
/// 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;
|
2026-02-23 22:21:37 +00:00
|
|
|
use std::sync::Arc;
|
2026-02-20 14:42:41 +00:00
|
|
|
use tempfile::TempDir;
|
|
|
|
|
|
|
|
|
|
fn test_ctx(dir: &TempDir) -> AppContext {
|
|
|
|
|
AppContext::new_test(dir.path().to_path_buf())
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 22:21:37 +00:00
|
|
|
fn make_api(dir: &TempDir) -> SettingsApi {
|
|
|
|
|
SettingsApi {
|
|
|
|
|
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn get_editor_returns_none_when_unset() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let api = make_api(&dir);
|
|
|
|
|
let result = api.get_editor().await.unwrap();
|
|
|
|
|
assert!(result.0.editor_command.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn set_editor_stores_command() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let api = make_api(&dir);
|
|
|
|
|
let payload = Json(EditorCommandPayload {
|
|
|
|
|
editor_command: Some("zed".to_string()),
|
|
|
|
|
});
|
|
|
|
|
let result = api.set_editor(payload).await.unwrap();
|
|
|
|
|
assert_eq!(result.0.editor_command, Some("zed".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn set_editor_clears_command_on_null() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let api = make_api(&dir);
|
|
|
|
|
api.set_editor(Json(EditorCommandPayload {
|
|
|
|
|
editor_command: Some("zed".to_string()),
|
|
|
|
|
}))
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
let result = api
|
|
|
|
|
.set_editor(Json(EditorCommandPayload {
|
|
|
|
|
editor_command: None,
|
|
|
|
|
}))
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(result.0.editor_command.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn set_editor_clears_command_on_empty_string() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let api = make_api(&dir);
|
|
|
|
|
let result = api
|
|
|
|
|
.set_editor(Json(EditorCommandPayload {
|
|
|
|
|
editor_command: Some(String::new()),
|
|
|
|
|
}))
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(result.0.editor_command.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn set_editor_trims_whitespace_only() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let api = make_api(&dir);
|
|
|
|
|
let result = api
|
|
|
|
|
.set_editor(Json(EditorCommandPayload {
|
|
|
|
|
editor_command: Some(" ".to_string()),
|
|
|
|
|
}))
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(result.0.editor_command.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn get_editor_returns_value_after_set() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let api = make_api(&dir);
|
|
|
|
|
api.set_editor(Json(EditorCommandPayload {
|
|
|
|
|
editor_command: Some("cursor".to_string()),
|
|
|
|
|
}))
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
let result = api.get_editor().await.unwrap();
|
|
|
|
|
assert_eq!(result.0.editor_command, Some("cursor".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 14:42:41 +00:00
|
|
|
#[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")));
|
|
|
|
|
}
|
2026-02-23 22:00:33 +00:00
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn get_editor_http_handler_returns_null_when_not_set() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let ctx = test_ctx(&dir);
|
|
|
|
|
let api = SettingsApi {
|
|
|
|
|
ctx: Arc::new(ctx),
|
|
|
|
|
};
|
|
|
|
|
let result = api.get_editor().await.unwrap().0;
|
|
|
|
|
assert!(result.editor_command.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn set_editor_http_handler_stores_value() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let ctx = test_ctx(&dir);
|
|
|
|
|
let api = SettingsApi {
|
|
|
|
|
ctx: Arc::new(ctx),
|
|
|
|
|
};
|
|
|
|
|
let result = api
|
|
|
|
|
.set_editor(Json(EditorCommandPayload {
|
|
|
|
|
editor_command: Some("zed".to_string()),
|
|
|
|
|
}))
|
|
|
|
|
.await
|
|
|
|
|
.unwrap()
|
|
|
|
|
.0;
|
|
|
|
|
assert_eq!(result.editor_command, Some("zed".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn set_editor_http_handler_clears_value_when_null() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let ctx = test_ctx(&dir);
|
|
|
|
|
let api = SettingsApi {
|
|
|
|
|
ctx: Arc::new(ctx),
|
|
|
|
|
};
|
|
|
|
|
// First set a value
|
|
|
|
|
api.set_editor(Json(EditorCommandPayload {
|
|
|
|
|
editor_command: Some("code".to_string()),
|
|
|
|
|
}))
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
// Now clear it
|
|
|
|
|
let result = api
|
|
|
|
|
.set_editor(Json(EditorCommandPayload {
|
|
|
|
|
editor_command: None,
|
|
|
|
|
}))
|
|
|
|
|
.await
|
|
|
|
|
.unwrap()
|
|
|
|
|
.0;
|
|
|
|
|
assert!(result.editor_command.is_none());
|
|
|
|
|
}
|
2026-02-26 12:34:57 +00:00
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn open_file_returns_error_when_no_editor_configured() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let api = make_api(&dir);
|
|
|
|
|
let result = api
|
|
|
|
|
.open_file(Query("src/main.rs".to_string()), Query(Some(42)))
|
|
|
|
|
.await;
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
let err = result.unwrap_err();
|
|
|
|
|
assert_eq!(err.status(), poem::http::StatusCode::BAD_REQUEST);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn open_file_spawns_editor_with_path_and_line() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let api = make_api(&dir);
|
|
|
|
|
// Configure the editor to "echo" which is a safe no-op command
|
|
|
|
|
api.set_editor(Json(EditorCommandPayload {
|
|
|
|
|
editor_command: Some("echo".to_string()),
|
|
|
|
|
}))
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
let result = api
|
|
|
|
|
.open_file(Query("src/main.rs".to_string()), Query(Some(42)))
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(result.0.success);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn open_file_spawns_editor_with_path_only_when_no_line() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let api = make_api(&dir);
|
|
|
|
|
api.set_editor(Json(EditorCommandPayload {
|
|
|
|
|
editor_command: Some("echo".to_string()),
|
|
|
|
|
}))
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
let result = api
|
|
|
|
|
.open_file(Query("src/lib.rs".to_string()), Query(None))
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(result.0.success);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn open_file_returns_error_for_nonexistent_editor() {
|
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
|
let api = make_api(&dir);
|
|
|
|
|
api.set_editor(Json(EditorCommandPayload {
|
|
|
|
|
editor_command: Some("this_editor_does_not_exist_xyz_abc".to_string()),
|
|
|
|
|
}))
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
let result = api
|
|
|
|
|
.open_file(Query("src/main.rs".to_string()), Query(Some(1)))
|
|
|
|
|
.await;
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
}
|
2026-02-20 14:42:41 +00:00
|
|
|
}
|