feat: add multi-project support (#110)
* feat: Implement project management store with project path validation and synchronization - Added `useProjectsStore` for managing projects, including adding, removing, renaming, and validating project paths. - Implemented persistence for projects and active project ID using safe storage. - Introduced synchronization from desktop settings to keep project data consistent. - Enhanced session store to manage sessions by directory and added new methods for session management. - Updated todo store to fetch session todos based on the directory context. - Refactored server code to validate and resolve project directories for various API endpoints. - Added project entry validation and sanitization to ensure data integrity. * feat(settings): migrate legacy project settings and update settings loading logic * feat: enhance project management with directory-aware settings and improved agent/command source handling * feat: enhance session and project management with directory-aware settings and improved configuration refresh logic * feat: enhance project management with worktree manager integration and project directory resolution * feat: enhance agent groups store with project directory resolution and loading logic * feat: add heartbeat management and global wrapping for SSE blocks in agent and chat providers * feat: refactor command and project handling in useCommandsStore - Replaced useDirectoryStore with useProjectsStore to manage project paths. - Introduced getRequestDirectory function to determine the active project directory. - Updated command fetching to respect project-level scoping. - Enhanced error handling and logging for command configuration fetching. - Improved command configuration saving and updating to utilize project directory context. feat: enhance project path normalization in useProjectsStore - Added resolveTildePath function to expand paths starting with ~. - Updated normalizeProjectPath to utilize home directory for path expansion. fix: update permission handling in useSessionStore - Changed Permission type to PermissionRequest for clarity. - Updated respondToPermission method to use requestId instead of permissionId. refactor: improve permission utilities - Introduced types for PermissionAction and PermissionRule. - Enhanced getAgentDefinition and resolveConfigStore functions for better type safety. - Added resolvePermissionAction to streamline permission resolution logic. feat: add agent configuration retrieval endpoint - Implemented new API endpoint to fetch agent configuration based on project directory. - Enhanced getAgentPermissionSource to prioritize project-level permissions. chore: update SDK version in package.json files - Bumped @opencode-ai/sdk version to ^1.1.1 across all relevant package.json files. refactor: streamline bridge message handling - Updated handleBridgeMessage to accept directory parameter for agent and command requests. - Improved local API request handling to extract directory from query parameters and headers. feat: enhance project configuration management - Added functions to retrieve and merge project configuration paths. - Improved handling of existing project configuration files for agents and commands. * feat: enhance VSCode integration and session management - Added support for a sticky sidebar header background in light and dark themes. - Introduced functions to read VSCode workspace directory and check if running in VSCode. - Implemented detailed logging for session loading and creation processes. - Enhanced session filtering based on directory structure and canonical paths. - Added a new method to reorder projects and prevent modifications in VSCode workspace. - Improved error handling and logging for app initialization and markdown file parsing. - Updated API checks and health checks to ensure readiness before proceeding. - Refactored code for better readability and maintainability across various modules. * feat: improve agent and branch selection logic, enhance session management, and update multi-run creation response * feat: add worktree management actions in agent group detail and sidebar, including delete and keep only options * fix(ui): share IME guard and cover multi-run * fix(session): reduce maximum visible sessions in group from 7 to 5
This commit is contained in:
committed by
GitHub
parent
8aa379e313
commit
18c5b4c7b5
@@ -1,4 +1,4 @@
|
||||
use std::{collections::HashSet, time::Duration};
|
||||
use std::{collections::HashSet, path::PathBuf, time::Duration};
|
||||
|
||||
use anyhow::Result;
|
||||
use futures_util::TryStreamExt;
|
||||
@@ -11,6 +11,7 @@ use tauri_plugin_notification::NotificationExt;
|
||||
use tokio::{io::AsyncBufReadExt, sync::Mutex};
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
use crate::path_utils::expand_tilde_path;
|
||||
use crate::DesktopRuntime;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -21,6 +22,14 @@ struct EventEnvelope {
|
||||
properties: Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MultiplexedEventEnvelope {
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
directory: Option<String>,
|
||||
payload: EventEnvelope,
|
||||
}
|
||||
|
||||
pub fn spawn_assistant_notifications(
|
||||
app: AppHandle,
|
||||
runtime: DesktopRuntime,
|
||||
@@ -71,41 +80,8 @@ async fn run_once(
|
||||
};
|
||||
|
||||
let prefix = opencode.api_prefix();
|
||||
let mut url = format!("http://127.0.0.1:{port}{}/event", prefix);
|
||||
|
||||
if let Some(dir) = opencode
|
||||
.get_working_directory()
|
||||
.to_str()
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
let mut parsed = reqwest::Url::parse(&url)?;
|
||||
parsed.query_pairs_mut().append_pair("directory", &dir);
|
||||
url = parsed.to_string();
|
||||
}
|
||||
|
||||
debug!("[desktop:notify] Connecting SSE for notifications: {url}");
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
debug!(
|
||||
"[desktop:notify] SSE response status={} headers={:?}",
|
||||
response.status(),
|
||||
response.headers()
|
||||
);
|
||||
|
||||
if !response.status().is_success() {
|
||||
warn!(
|
||||
"[desktop:notify] SSE connect failed with status {}",
|
||||
response.status()
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
return Ok(());
|
||||
}
|
||||
let base = format!("http://127.0.0.1:{port}{prefix}");
|
||||
let response = connect_notifications_sse(runtime, client, &base).await?;
|
||||
|
||||
let stream = response
|
||||
.bytes_stream()
|
||||
@@ -142,7 +118,7 @@ async fn run_once(
|
||||
let raw = data_lines.join("\n");
|
||||
data_lines.clear();
|
||||
|
||||
match serde_json::from_str::<EventEnvelope>(&raw) {
|
||||
match parse_event_envelope(&raw) {
|
||||
Ok(event) => handle_event(app, event, notified_messages).await,
|
||||
Err(err) => {
|
||||
warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}");
|
||||
@@ -159,6 +135,111 @@ async fn run_once(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_event_envelope(raw: &str) -> Result<EventEnvelope> {
|
||||
if let Ok(event) = serde_json::from_str::<EventEnvelope>(raw) {
|
||||
return Ok(event);
|
||||
}
|
||||
|
||||
let multiplexed = serde_json::from_str::<MultiplexedEventEnvelope>(raw)?;
|
||||
Ok(multiplexed.payload)
|
||||
}
|
||||
|
||||
async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option<PathBuf> {
|
||||
let settings = runtime.settings().load().await.ok()?;
|
||||
|
||||
if let Some(active_id) = settings.get("activeProjectId").and_then(Value::as_str) {
|
||||
if let Some(projects) = settings.get("projects").and_then(Value::as_array) {
|
||||
if let Some(path) = projects.iter().find_map(|entry| {
|
||||
let id = entry.get("id").and_then(Value::as_str)?;
|
||||
if id != active_id {
|
||||
return None;
|
||||
}
|
||||
entry.get("path").and_then(Value::as_str)
|
||||
}) {
|
||||
return Some(expand_tilde_path(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settings
|
||||
.get("lastDirectory")
|
||||
.and_then(Value::as_str)
|
||||
.map(expand_tilde_path)
|
||||
}
|
||||
|
||||
async fn connect_notifications_sse(
|
||||
runtime: &DesktopRuntime,
|
||||
client: &Client,
|
||||
base: &str,
|
||||
) -> Result<reqwest::Response> {
|
||||
let global_url = format!("{base}/global/event");
|
||||
match try_connect_sse(client, &global_url, "[desktop:notify]").await {
|
||||
Ok(response) => {
|
||||
debug!("[desktop:notify] Using SSE endpoint: {global_url}");
|
||||
return Ok(response);
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"[desktop:notify] SSE endpoint unavailable: {global_url} ({err:?}); falling back"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let event_url = format!("{base}/event");
|
||||
match try_connect_sse(client, &event_url, "[desktop:notify]").await {
|
||||
Ok(response) => {
|
||||
debug!("[desktop:notify] Using SSE endpoint: {event_url}");
|
||||
return Ok(response);
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"[desktop:notify] SSE endpoint unavailable: {event_url} ({err:?}); falling back"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let Some(working_dir) = resolve_project_directory_from_settings(runtime).await else {
|
||||
anyhow::bail!("No project directory available for SSE fallback");
|
||||
};
|
||||
let directory = working_dir.to_string_lossy().to_string();
|
||||
let mut parsed = reqwest::Url::parse(&event_url)?;
|
||||
parsed
|
||||
.query_pairs_mut()
|
||||
.append_pair("directory", &directory);
|
||||
let directory_url = parsed.to_string();
|
||||
|
||||
let response = try_connect_sse(client, &directory_url, "[desktop:notify]").await?;
|
||||
debug!("[desktop:notify] Using directory-scoped SSE endpoint: {directory_url}");
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn try_connect_sse(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
log_prefix: &str,
|
||||
) -> Result<reqwest::Response> {
|
||||
debug!("{log_prefix} Connecting SSE: {url}");
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
debug!(
|
||||
"{log_prefix} SSE response status={} headers={:?}",
|
||||
response.status(),
|
||||
response.headers()
|
||||
);
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("SSE connect failed with status {}", response.status());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn handle_event(
|
||||
app: &AppHandle,
|
||||
event: EventEnvelope,
|
||||
|
||||
@@ -136,8 +136,8 @@ pub async fn list_directory(
|
||||
path: Option<String>,
|
||||
state: tauri::State<'_, DesktopRuntime>,
|
||||
) -> Result<DirectoryListResult, String> {
|
||||
let workspace_root = resolve_workspace_root(state.settings()).await;
|
||||
let resolved_path = resolve_sandboxed_path(path, workspace_root.as_ref())
|
||||
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
||||
let resolved_path = resolve_sandboxed_path(path, &workspace_roots, default_root.as_ref())
|
||||
.await
|
||||
.map_err(|err| err.to_list_message())?;
|
||||
|
||||
@@ -150,10 +150,12 @@ pub async fn list_directory(
|
||||
}
|
||||
|
||||
// Re-check boundary after canonicalization to guard against traversal
|
||||
if let Some(root) = &workspace_root {
|
||||
if !resolved_path.starts_with(root) {
|
||||
return Err(FsCommandError::OutsideWorkspace.to_list_message());
|
||||
}
|
||||
if !workspace_roots.is_empty()
|
||||
&& !workspace_roots
|
||||
.iter()
|
||||
.any(|root| resolved_path.starts_with(root))
|
||||
{
|
||||
return Err(FsCommandError::OutsideWorkspace.to_list_message());
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
@@ -223,8 +225,8 @@ pub async fn search_files(
|
||||
max_results: Option<usize>,
|
||||
state: tauri::State<'_, DesktopRuntime>,
|
||||
) -> Result<SearchFilesResponse, String> {
|
||||
let workspace_root = resolve_workspace_root(state.settings()).await;
|
||||
let resolved_root = resolve_sandboxed_path(directory, workspace_root.as_ref())
|
||||
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
||||
let resolved_root = resolve_sandboxed_path(directory, &workspace_roots, default_root.as_ref())
|
||||
.await
|
||||
.map_err(|err| err.to_search_message())?;
|
||||
|
||||
@@ -352,8 +354,8 @@ pub async fn create_directory(
|
||||
return Err("Path is required".to_string());
|
||||
}
|
||||
|
||||
let workspace_root = resolve_workspace_root(state.settings()).await;
|
||||
let resolved_path = resolve_creatable_path(trimmed, workspace_root.as_ref())
|
||||
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
||||
let resolved_path = resolve_creatable_path(trimmed, &workspace_roots, default_root.as_ref())
|
||||
.await
|
||||
.map_err(|err| err.to_create_message())?;
|
||||
|
||||
@@ -369,35 +371,40 @@ pub async fn create_directory(
|
||||
|
||||
async fn resolve_sandboxed_path(
|
||||
path: Option<String>,
|
||||
workspace_root: Option<&PathBuf>,
|
||||
workspace_roots: &[PathBuf],
|
||||
default_root: Option<&PathBuf>,
|
||||
) -> Result<PathBuf, FsCommandError> {
|
||||
let candidate_input = path
|
||||
.as_ref()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let candidate_path = match (candidate_input, workspace_root) {
|
||||
(Some(value), _) => expand_tilde_path(value),
|
||||
(None, Some(root)) => root.clone(),
|
||||
(None, None) => default_home_directory(),
|
||||
let fallback_root = default_root
|
||||
.or_else(|| workspace_roots.first())
|
||||
.cloned()
|
||||
.unwrap_or_else(default_home_directory);
|
||||
|
||||
let candidate_path = match candidate_input {
|
||||
Some(value) => expand_tilde_path(value),
|
||||
None => fallback_root.clone(),
|
||||
};
|
||||
|
||||
let resolved = if candidate_path.is_absolute() {
|
||||
candidate_path
|
||||
} else if let Some(root) = workspace_root {
|
||||
root.join(candidate_path)
|
||||
} else {
|
||||
default_home_directory().join(candidate_path)
|
||||
fallback_root.join(candidate_path)
|
||||
};
|
||||
|
||||
let canonicalized = fs::canonicalize(&resolved)
|
||||
.await
|
||||
.map_err(FsCommandError::from)?;
|
||||
|
||||
if let Some(root) = workspace_root {
|
||||
if !canonicalized.starts_with(root) {
|
||||
return Err(FsCommandError::OutsideWorkspace);
|
||||
}
|
||||
if !workspace_roots.is_empty()
|
||||
&& !workspace_roots
|
||||
.iter()
|
||||
.any(|root| canonicalized.starts_with(root))
|
||||
{
|
||||
return Err(FsCommandError::OutsideWorkspace);
|
||||
}
|
||||
|
||||
Ok(canonicalized)
|
||||
@@ -405,19 +412,23 @@ async fn resolve_sandboxed_path(
|
||||
|
||||
async fn resolve_creatable_path(
|
||||
path: &str,
|
||||
workspace_root: Option<&PathBuf>,
|
||||
workspace_roots: &[PathBuf],
|
||||
default_root: Option<&PathBuf>,
|
||||
) -> Result<PathBuf, FsCommandError> {
|
||||
let candidate = expand_tilde_path(path);
|
||||
if candidate.as_os_str().is_empty() {
|
||||
return Err(FsCommandError::Other("Path is required".to_string()));
|
||||
}
|
||||
|
||||
let fallback_root = default_root
|
||||
.or_else(|| workspace_roots.first())
|
||||
.cloned()
|
||||
.unwrap_or_else(default_home_directory);
|
||||
|
||||
let absolute = if candidate.is_absolute() {
|
||||
candidate
|
||||
} else if let Some(root) = workspace_root {
|
||||
root.join(candidate)
|
||||
} else {
|
||||
default_home_directory().join(candidate)
|
||||
fallback_root.join(candidate)
|
||||
};
|
||||
|
||||
let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?;
|
||||
@@ -426,22 +437,79 @@ async fn resolve_creatable_path(
|
||||
.await
|
||||
.map_err(FsCommandError::from)?;
|
||||
|
||||
if let Some(root) = workspace_root {
|
||||
if !canonical_parent.starts_with(root) {
|
||||
return Err(FsCommandError::OutsideWorkspace);
|
||||
}
|
||||
if !workspace_roots.is_empty()
|
||||
&& !workspace_roots
|
||||
.iter()
|
||||
.any(|root| canonical_parent.starts_with(root))
|
||||
{
|
||||
return Err(FsCommandError::OutsideWorkspace);
|
||||
}
|
||||
|
||||
Ok(absolute)
|
||||
}
|
||||
|
||||
async fn resolve_workspace_root(settings: &SettingsStore) -> Option<PathBuf> {
|
||||
if let Ok(Some(last_dir)) = settings.last_directory().await {
|
||||
if let Ok(canonicalized) = fs::canonicalize(&last_dir).await {
|
||||
return Some(canonicalized);
|
||||
async fn resolve_workspace_roots(settings: &SettingsStore) -> (Vec<PathBuf>, Option<PathBuf>) {
|
||||
let mut roots: Vec<PathBuf> = Vec::new();
|
||||
let mut default_root: Option<PathBuf> = None;
|
||||
|
||||
let settings_value = settings.load().await.ok();
|
||||
|
||||
if let Some(value) = settings_value.as_ref() {
|
||||
if let Some(active_id) = value.get("activeProjectId").and_then(|v| v.as_str()) {
|
||||
if let Some(projects) = value.get("projects").and_then(|v| v.as_array()) {
|
||||
if let Some(active_path) = projects.iter().find_map(|entry| {
|
||||
let id = entry.get("id").and_then(|v| v.as_str())?;
|
||||
if id != active_id {
|
||||
return None;
|
||||
}
|
||||
entry.get("path").and_then(|v| v.as_str())
|
||||
}) {
|
||||
if let Ok(canonicalized) =
|
||||
fs::canonicalize(expand_tilde_path(active_path)).await
|
||||
{
|
||||
default_root = Some(canonicalized.clone());
|
||||
roots.push(canonicalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(projects) = value.get("projects").and_then(|v| v.as_array()) {
|
||||
for entry in projects {
|
||||
if let Some(path) = entry.get("path").and_then(|v| v.as_str()) {
|
||||
if let Ok(canonicalized) = fs::canonicalize(expand_tilde_path(path)).await {
|
||||
roots.push(canonicalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(last_dir) = value.get("lastDirectory").and_then(|v| v.as_str()) {
|
||||
if let Ok(canonicalized) = fs::canonicalize(expand_tilde_path(last_dir)).await {
|
||||
if default_root.is_none() {
|
||||
default_root = Some(canonicalized.clone());
|
||||
}
|
||||
roots.push(canonicalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
if default_root.is_none() {
|
||||
if let Ok(Some(last_dir)) = settings.last_directory().await {
|
||||
if let Ok(canonicalized) = fs::canonicalize(last_dir).await {
|
||||
default_root = Some(canonicalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut deduped: Vec<PathBuf> = Vec::new();
|
||||
for root in roots {
|
||||
if !deduped.iter().any(|existing| existing == &root) {
|
||||
deduped.push(root);
|
||||
}
|
||||
}
|
||||
|
||||
(deduped, default_root)
|
||||
}
|
||||
|
||||
fn default_home_directory() -> PathBuf {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use chrono::Utc;
|
||||
use log::{info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tauri::AppHandle;
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::path_utils::expand_tilde_path;
|
||||
use crate::DesktopRuntime;
|
||||
@@ -17,6 +20,8 @@ pub struct DirectoryPermissionRequest {
|
||||
pub struct DirectoryPermissionResult {
|
||||
success: bool,
|
||||
path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
project_id: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
@@ -27,9 +32,8 @@ pub struct StartAccessingResult {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Process directory selection from frontend
|
||||
/// Updates settings with lastDirectory
|
||||
/// OpenCode restart is triggered separately via /api/opencode/directory endpoint
|
||||
/// Process directory selection from frontend.
|
||||
/// Updates settings (projects, activeProjectId, lastDirectory).
|
||||
#[tauri::command]
|
||||
pub async fn process_directory_selection(
|
||||
path: String,
|
||||
@@ -45,6 +49,7 @@ pub async fn process_directory_selection(
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
project_id: None,
|
||||
error: Some("Directory does not exist".to_string()),
|
||||
});
|
||||
}
|
||||
@@ -53,38 +58,94 @@ pub async fn process_directory_selection(
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
project_id: None,
|
||||
error: Some("Path is not a directory".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Update settings with lastDirectory
|
||||
let mut settings = state
|
||||
.settings()
|
||||
.load()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load settings: {}", e))?;
|
||||
// Update settings with projects + activeProjectId + lastDirectory
|
||||
let now = Utc::now().timestamp_millis();
|
||||
let normalized_path_for_update = normalized_path.clone();
|
||||
|
||||
if let Some(obj) = settings.as_object_mut() {
|
||||
obj.insert(
|
||||
"lastDirectory".to_string(),
|
||||
serde_json::Value::String(normalized_path.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
state
|
||||
let (_, project_id) = state
|
||||
.settings()
|
||||
.save(settings)
|
||||
.update_with(|mut settings| {
|
||||
if !settings.is_object() {
|
||||
settings = json!({});
|
||||
}
|
||||
|
||||
let project_id = {
|
||||
let obj = settings.as_object_mut().unwrap();
|
||||
|
||||
let projects_value = obj.entry("projects").or_insert_with(|| json!([]));
|
||||
if !projects_value.is_array() {
|
||||
*projects_value = json!([]);
|
||||
}
|
||||
|
||||
let projects = projects_value.as_array_mut().unwrap();
|
||||
|
||||
let existing_index = projects.iter().position(|entry| {
|
||||
entry
|
||||
.get("path")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value == normalized_path_for_update)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if let Some(index) = existing_index {
|
||||
let entry = projects
|
||||
.get_mut(index)
|
||||
.and_then(|value| value.as_object_mut());
|
||||
if let Some(entry) = entry {
|
||||
entry.insert("lastOpenedAt".to_string(), json!(now));
|
||||
if let Some(id) = entry.get("id").and_then(|value| value.as_str()) {
|
||||
id.to_string()
|
||||
} else {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
entry.insert("id".to_string(), json!(id));
|
||||
id
|
||||
}
|
||||
} else {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
projects[index] = json!({
|
||||
"id": id,
|
||||
"path": normalized_path_for_update,
|
||||
"addedAt": now,
|
||||
"lastOpenedAt": now
|
||||
});
|
||||
id
|
||||
}
|
||||
} else {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
projects.push(json!({
|
||||
"id": id,
|
||||
"path": normalized_path_for_update,
|
||||
"addedAt": now,
|
||||
"lastOpenedAt": now
|
||||
}));
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(obj) = settings.as_object_mut() {
|
||||
obj.insert("activeProjectId".to_string(), json!(project_id.clone()));
|
||||
obj.insert("lastDirectory".to_string(), json!(normalized_path_for_update));
|
||||
}
|
||||
|
||||
(settings, project_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save updated settings: {}", e))?;
|
||||
|
||||
info!(
|
||||
"[permissions] Updated settings with lastDirectory: {}",
|
||||
normalized_path
|
||||
"[permissions] Updated settings with active project {}: {}",
|
||||
project_id, normalized_path
|
||||
);
|
||||
|
||||
Ok(DirectoryPermissionResult {
|
||||
success: true,
|
||||
path: Some(normalized_path),
|
||||
project_id: Some(project_id),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
@@ -98,6 +159,7 @@ pub async fn pick_directory(
|
||||
Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
project_id: None,
|
||||
error: Some(
|
||||
"Use requestDirectoryAccess instead - it handles native dialog properly".to_string(),
|
||||
),
|
||||
@@ -122,6 +184,7 @@ pub async fn request_directory_access(
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
project_id: None,
|
||||
error: Some("Directory does not exist".to_string()),
|
||||
});
|
||||
}
|
||||
@@ -130,6 +193,7 @@ pub async fn request_directory_access(
|
||||
return Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
project_id: None,
|
||||
error: Some("Path is not a directory".to_string()),
|
||||
});
|
||||
}
|
||||
@@ -139,11 +203,13 @@ pub async fn request_directory_access(
|
||||
Ok(_) => Ok(DirectoryPermissionResult {
|
||||
success: true,
|
||||
path: Some(normalized_path),
|
||||
project_id: None,
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(DirectoryPermissionResult {
|
||||
success: false,
|
||||
path: None,
|
||||
project_id: None,
|
||||
error: Some(format!("Cannot access directory: {}", e)),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::Utc;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::path_utils::expand_tilde_path;
|
||||
use crate::DesktopRuntime;
|
||||
@@ -19,52 +21,47 @@ pub struct RestartResult {
|
||||
restarted: bool,
|
||||
}
|
||||
|
||||
/// Load settings from disk (matches Express handler behavior)
|
||||
/// Load settings from disk.
|
||||
#[tauri::command]
|
||||
pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsLoadResult, String> {
|
||||
let settings = state
|
||||
let (settings, _) = state
|
||||
.settings()
|
||||
.load()
|
||||
.update_with(|mut settings| {
|
||||
migrate_legacy_project_settings(&mut settings);
|
||||
normalize_project_selection(&mut settings);
|
||||
(settings, ())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load settings: {}", e))?;
|
||||
|
||||
Ok(SettingsLoadResult {
|
||||
settings,
|
||||
settings: format_settings_response(&settings),
|
||||
source: "desktop".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Save settings to disk with merge logic matching Express implementation
|
||||
/// Save settings to disk with merge logic.
|
||||
#[tauri::command]
|
||||
pub async fn save_settings(
|
||||
changes: Value,
|
||||
state: State<'_, DesktopRuntime>,
|
||||
) -> Result<Value, String> {
|
||||
// Load current settings
|
||||
let current = state
|
||||
.settings()
|
||||
.load()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load current settings: {}", e))?;
|
||||
|
||||
// Sanitize incoming changes
|
||||
let sanitized_changes = sanitize_settings_update(&changes);
|
||||
|
||||
// Merge changes into current settings
|
||||
let merged = merge_persisted_settings(¤t, &sanitized_changes);
|
||||
|
||||
// Save merged settings
|
||||
state
|
||||
let (merged, _) = state
|
||||
.settings()
|
||||
.save(merged.clone())
|
||||
.update_with(|current| {
|
||||
let mut merged = merge_persisted_settings(¤t, &sanitized_changes);
|
||||
normalize_project_selection(&mut merged);
|
||||
(merged, ())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||
|
||||
// Format response
|
||||
Ok(format_settings_response(&merged))
|
||||
}
|
||||
|
||||
/// Restart OpenCode CLI (matches Express /api/config/reload)
|
||||
/// Restart the backend process (config reload).
|
||||
#[tauri::command]
|
||||
pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<RestartResult, String> {
|
||||
state
|
||||
@@ -76,6 +73,82 @@ pub async fn restart_opencode(state: State<'_, DesktopRuntime>) -> Result<Restar
|
||||
Ok(RestartResult { restarted: true })
|
||||
}
|
||||
|
||||
fn sanitize_projects(value: &Value) -> Option<Value> {
|
||||
let arr = value.as_array()?;
|
||||
let mut seen_ids = HashSet::new();
|
||||
let mut seen_paths = HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
|
||||
for entry in arr {
|
||||
let Some(obj) = entry.as_object() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let id = obj.get("id").and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
let raw_path = obj
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if id.is_empty() || raw_path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let expanded = expand_tilde_path(raw_path).to_string_lossy().to_string();
|
||||
let normalized = if expanded == "/" {
|
||||
expanded
|
||||
} else {
|
||||
expanded.trim_end_matches('/').replace('\\', "/")
|
||||
};
|
||||
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if seen_ids.contains(id) || seen_paths.contains(&normalized) {
|
||||
continue;
|
||||
}
|
||||
seen_ids.insert(id.to_string());
|
||||
seen_paths.insert(normalized.clone());
|
||||
|
||||
let mut project = serde_json::Map::new();
|
||||
project.insert("id".to_string(), json!(id));
|
||||
project.insert("path".to_string(), json!(normalized));
|
||||
|
||||
if let Some(Value::String(label)) = obj.get("label") {
|
||||
if !label.trim().is_empty() {
|
||||
project.insert("label".to_string(), json!(label.trim()));
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(num)) = obj.get("addedAt") {
|
||||
if let Some(value) = num.as_i64() {
|
||||
if value >= 0 {
|
||||
project.insert("addedAt".to_string(), json!(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(Value::Number(num)) = obj.get("lastOpenedAt") {
|
||||
if let Some(value) = num.as_i64() {
|
||||
if value >= 0 {
|
||||
project.insert("lastOpenedAt".to_string(), json!(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(Value::Object(project));
|
||||
}
|
||||
|
||||
if arr.is_empty() {
|
||||
return Some(Value::Array(vec![]));
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Array(result))
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize settings update payload (port of Express sanitizeSettingsUpdate)
|
||||
fn sanitize_settings_update(payload: &Value) -> Value {
|
||||
let mut result = json!({});
|
||||
@@ -116,6 +189,14 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
||||
result_obj.insert("homeDirectory".to_string(), json!(expanded));
|
||||
}
|
||||
}
|
||||
if let Some(projects) = obj.get("projects").and_then(sanitize_projects) {
|
||||
result_obj.insert("projects".to_string(), projects);
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("activeProjectId") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("activeProjectId".to_string(), json!(s));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = obj.get("uiFont") {
|
||||
if !s.is_empty() {
|
||||
result_obj.insert("uiFont".to_string(), json!(s));
|
||||
@@ -259,6 +340,135 @@ fn sanitize_settings_update(payload: &Value) -> Value {
|
||||
result
|
||||
}
|
||||
|
||||
fn migrate_legacy_project_settings(settings: &mut Value) {
|
||||
if !settings.is_object() {
|
||||
*settings = json!({});
|
||||
}
|
||||
|
||||
let now = Utc::now().timestamp_millis();
|
||||
let obj = settings.as_object_mut().unwrap();
|
||||
|
||||
let has_projects = obj
|
||||
.get("projects")
|
||||
.and_then(|value| value.as_array())
|
||||
.map(|arr| !arr.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_projects {
|
||||
return;
|
||||
}
|
||||
|
||||
let last_directory = obj
|
||||
.get("lastDirectory")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(expand_tilde_path);
|
||||
|
||||
let Some(mut last_directory) = last_directory else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(canonicalized) = std::fs::canonicalize(&last_directory) {
|
||||
last_directory = canonicalized;
|
||||
}
|
||||
|
||||
let Ok(stats) = std::fs::metadata(&last_directory) else {
|
||||
return;
|
||||
};
|
||||
if !stats.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
let normalized_path = last_directory.to_string_lossy().to_string();
|
||||
if normalized_path.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let project_id = Uuid::new_v4().to_string();
|
||||
let active_project_id = project_id.clone();
|
||||
let project_path = normalized_path.clone();
|
||||
|
||||
let projects_value = obj.entry("projects").or_insert_with(|| json!([]));
|
||||
*projects_value = json!([
|
||||
{
|
||||
"id": project_id,
|
||||
"path": project_path,
|
||||
"addedAt": now,
|
||||
"lastOpenedAt": now
|
||||
}
|
||||
]);
|
||||
|
||||
obj.insert("activeProjectId".to_string(), json!(active_project_id));
|
||||
|
||||
// Ensure approvedDirectories includes the migrated project root.
|
||||
let approved_value = obj
|
||||
.entry("approvedDirectories")
|
||||
.or_insert_with(|| json!([]));
|
||||
if !approved_value.is_array() {
|
||||
*approved_value = json!([]);
|
||||
}
|
||||
|
||||
if let Some(array) = approved_value.as_array_mut() {
|
||||
array.push(json!(normalized_path.clone()));
|
||||
array.retain(|entry| entry.as_str().is_some_and(|value| !value.trim().is_empty()));
|
||||
let mut seen = HashSet::new();
|
||||
array.retain(|entry| {
|
||||
let Some(value) = entry.as_str() else {
|
||||
return false;
|
||||
};
|
||||
if seen.contains(value) {
|
||||
return false;
|
||||
}
|
||||
seen.insert(value.to_string());
|
||||
true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_project_selection(settings: &mut Value) {
|
||||
let Some(obj) = settings.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(projects) = obj.get("projects").and_then(|value| value.as_array()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if projects.is_empty() {
|
||||
obj.remove("activeProjectId");
|
||||
return;
|
||||
}
|
||||
|
||||
let current_active = obj
|
||||
.get("activeProjectId")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let has_active = projects.iter().any(|entry| {
|
||||
entry
|
||||
.get("id")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|id| id == current_active)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if has_active {
|
||||
return;
|
||||
}
|
||||
|
||||
let first_id = projects
|
||||
.first()
|
||||
.and_then(|entry| entry.get("id"))
|
||||
.and_then(|value| value.as_str());
|
||||
|
||||
if let Some(id) = first_id {
|
||||
obj.insert("activeProjectId".to_string(), json!(id));
|
||||
} else {
|
||||
obj.remove("activeProjectId");
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge persisted settings (port of Express mergePersistedSettings)
|
||||
fn merge_persisted_settings(current: &Value, changes: &Value) -> Value {
|
||||
let mut result = current.clone();
|
||||
@@ -290,6 +500,22 @@ fn merge_persisted_settings(current: &Value, changes: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
let project_source = if let Some(Value::Array(arr)) = changes_obj.get("projects") {
|
||||
Some(arr)
|
||||
} else {
|
||||
current.get("projects").and_then(|v| v.as_array())
|
||||
};
|
||||
|
||||
if let Some(entries) = project_source {
|
||||
for entry in entries {
|
||||
if let Some(path) = entry.get("path").and_then(|v| v.as_str()) {
|
||||
if !path.trim().is_empty() {
|
||||
additional_approved.push(path.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut approved_set: HashSet<String> = base_approved.into_iter().collect();
|
||||
for item in additional_approved {
|
||||
approved_set.insert(item);
|
||||
|
||||
@@ -22,9 +22,9 @@ use anyhow::{anyhow, Result};
|
||||
use assistant_notifications::spawn_assistant_notifications;
|
||||
use axum::{
|
||||
body::{to_bytes, Body},
|
||||
extract::{OriginalUri, State},
|
||||
http::{Method, Request, Response, StatusCode},
|
||||
response::IntoResponse,
|
||||
extract::{Request, State},
|
||||
http::{Method, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{any, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
@@ -39,6 +39,7 @@ use commands::git::{
|
||||
update_git_identity,
|
||||
};
|
||||
use commands::logs::fetch_desktop_logs;
|
||||
|
||||
use commands::notifications::desktop_notify;
|
||||
use commands::permissions::{
|
||||
pick_directory, process_directory_selection, request_directory_access,
|
||||
@@ -157,10 +158,7 @@ pub(crate) struct DesktopRuntime {
|
||||
impl DesktopRuntime {
|
||||
fn initialize_sync() -> Result<Self> {
|
||||
let settings = Arc::new(SettingsStore::new()?);
|
||||
let initial_dir = tauri::async_runtime::block_on(settings.last_directory())
|
||||
.ok()
|
||||
.flatten();
|
||||
let opencode = Arc::new(OpenCodeManager::new_with_directory(initial_dir.clone()));
|
||||
let opencode = Arc::new(OpenCodeManager::new_with_directory(None));
|
||||
|
||||
let client = Client::builder().build()?;
|
||||
|
||||
@@ -170,6 +168,7 @@ impl DesktopRuntime {
|
||||
let server_state = ServerState {
|
||||
client,
|
||||
opencode: opencode.clone(),
|
||||
settings: settings.clone(),
|
||||
server_port,
|
||||
directory_change_lock: Arc::new(Mutex::new(())),
|
||||
models_metadata_cache: Arc::new(Mutex::new(ModelsMetadataCache::default())),
|
||||
@@ -217,6 +216,7 @@ impl DesktopRuntime {
|
||||
struct ServerState {
|
||||
client: Client,
|
||||
opencode: Arc<OpenCodeManager>,
|
||||
settings: Arc<SettingsStore>,
|
||||
server_port: u16,
|
||||
directory_change_lock: Arc<Mutex<()>>,
|
||||
models_metadata_cache: Arc<Mutex<ModelsMetadataCache>>,
|
||||
@@ -420,21 +420,11 @@ fn build_macos_menu<R: tauri::Runtime>(
|
||||
)?;
|
||||
|
||||
// View menu items
|
||||
let open_git_tab = MenuItem::with_id(
|
||||
app,
|
||||
MENU_ITEM_OPEN_GIT_TAB_ID,
|
||||
"Git",
|
||||
true,
|
||||
Some("Cmd+G"),
|
||||
)?;
|
||||
let open_git_tab =
|
||||
MenuItem::with_id(app, MENU_ITEM_OPEN_GIT_TAB_ID, "Git", true, Some("Cmd+G"))?;
|
||||
|
||||
let open_diff_tab = MenuItem::with_id(
|
||||
app,
|
||||
MENU_ITEM_OPEN_DIFF_TAB_ID,
|
||||
"Diff",
|
||||
true,
|
||||
Some("Cmd+E"),
|
||||
)?;
|
||||
let open_diff_tab =
|
||||
MenuItem::with_id(app, MENU_ITEM_OPEN_DIFF_TAB_ID, "Diff", true, Some("Cmd+E"))?;
|
||||
|
||||
let open_terminal_tab = MenuItem::with_id(
|
||||
app,
|
||||
@@ -718,18 +708,8 @@ fn main() {
|
||||
|
||||
let app_handle = app.app_handle().clone();
|
||||
let runtime_clone = runtime.clone();
|
||||
let has_initial_dir =
|
||||
tauri::async_runtime::block_on(runtime.settings().last_directory())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Only start opencode if we have a saved directory, otherwise frontend will prompt
|
||||
if has_initial_dir {
|
||||
runtime_clone.start_opencode().await;
|
||||
} else {
|
||||
info!("[desktop] No saved directory - waiting for user to select one");
|
||||
}
|
||||
runtime_clone.start_opencode().await;
|
||||
|
||||
if let Err(e) =
|
||||
restore_bookmarks_on_startup(app_handle.state::<DesktopRuntime>().clone()).await
|
||||
@@ -1190,11 +1170,11 @@ struct DirectoryChangeResponse {
|
||||
path: String,
|
||||
}
|
||||
|
||||
fn json_response<T: Serialize>(status: StatusCode, payload: T) -> Response<Body> {
|
||||
fn json_response<T: Serialize>(status: StatusCode, payload: T) -> Response {
|
||||
(status, Json(payload)).into_response()
|
||||
}
|
||||
|
||||
fn config_error_response(status: StatusCode, message: impl Into<String>) -> Response<Body> {
|
||||
fn config_error_response(status: StatusCode, message: impl Into<String>) -> Response {
|
||||
json_response(
|
||||
status,
|
||||
ConfigErrorResponse {
|
||||
@@ -1203,10 +1183,8 @@ fn config_error_response(status: StatusCode, message: impl Into<String>) -> Resp
|
||||
)
|
||||
}
|
||||
|
||||
async fn parse_request_payload(
|
||||
req: Request<Body>,
|
||||
) -> Result<HashMap<String, Value>, Response<Body>> {
|
||||
let (_, body) = req.into_parts();
|
||||
async fn parse_request_payload(req: &mut Request) -> Result<HashMap<String, Value>, Response> {
|
||||
let body = std::mem::take(req.body_mut());
|
||||
let body_bytes = to_bytes(body, PROXY_BODY_LIMIT)
|
||||
.await
|
||||
.map_err(|_| config_error_response(StatusCode::BAD_REQUEST, "Invalid request body"))?;
|
||||
@@ -1222,7 +1200,7 @@ async fn parse_request_payload(
|
||||
async fn refresh_opencode_after_config_change(
|
||||
state: &ServerState,
|
||||
reason: &str,
|
||||
) -> Result<(), Response<Body>> {
|
||||
) -> Result<(), Response> {
|
||||
info!("[desktop:config] Restarting OpenCode after {}", reason);
|
||||
state.opencode.restart().await.map_err(|err| {
|
||||
config_error_response(
|
||||
@@ -1233,21 +1211,146 @@ async fn refresh_opencode_after_config_change(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_directory_from_request(req: &Request) -> Option<String> {
|
||||
if let Some(value) = req.headers().get("x-opencode-directory") {
|
||||
if let Ok(text) = value.to_str() {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let query = req.uri().query()?;
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
let key = parts.next()?;
|
||||
if key != "directory" {
|
||||
continue;
|
||||
}
|
||||
let value = parts.next().unwrap_or("");
|
||||
if let Ok(decoded) = urlencoding::decode(value) {
|
||||
let trimmed = decoded.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn resolve_directory_candidate(candidate: &str) -> Result<PathBuf, Response> {
|
||||
let mut resolved = expand_tilde_path(candidate);
|
||||
if !resolved.is_absolute() {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
|
||||
resolved = home.join(resolved);
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&resolved)
|
||||
.await
|
||||
.map_err(|_| config_error_response(StatusCode::BAD_REQUEST, "Directory not found"))?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(config_error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Specified path is not a directory",
|
||||
));
|
||||
}
|
||||
|
||||
if let Ok(canonicalized) = fs::canonicalize(&resolved).await {
|
||||
resolved = canonicalized;
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
async fn resolve_project_directory_from_settings(
|
||||
settings: &SettingsStore,
|
||||
) -> Result<Option<PathBuf>, Response> {
|
||||
let raw = settings.load().await.map_err(|_| {
|
||||
config_error_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to load settings")
|
||||
})?;
|
||||
|
||||
let active_id = raw
|
||||
.get("activeProjectId")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let projects = raw.get("projects").and_then(|value| value.as_array());
|
||||
if let Some(projects) = projects {
|
||||
if let Some(entry) = projects.iter().find(|entry| {
|
||||
entry
|
||||
.get("id")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.trim() == active_id)
|
||||
.unwrap_or(false)
|
||||
}) {
|
||||
if let Some(path) = entry.get("path").and_then(|value| value.as_str()) {
|
||||
return resolve_directory_candidate(path).await.map(Some);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(entry) = projects.first() {
|
||||
if let Some(path) = entry.get("path").and_then(|value| value.as_str()) {
|
||||
return resolve_directory_candidate(path).await.map(Some);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let legacy = raw
|
||||
.get("lastDirectory")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if !legacy.is_empty() {
|
||||
return resolve_directory_candidate(legacy).await.map(Some);
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn resolve_project_directory(
|
||||
state: &ServerState,
|
||||
directory: Option<String>,
|
||||
) -> Result<PathBuf, Response> {
|
||||
if let Some(directory) = directory {
|
||||
return resolve_directory_candidate(&directory).await;
|
||||
}
|
||||
|
||||
match resolve_project_directory_from_settings(state.settings.as_ref()).await? {
|
||||
Some(path) => Ok(path),
|
||||
None => Err(config_error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Directory parameter or active project is required",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_agent_route(
|
||||
state: &ServerState,
|
||||
method: Method,
|
||||
req: Request<Body>,
|
||||
mut req: Request,
|
||||
name: String,
|
||||
) -> Result<Response<Body>, StatusCode> {
|
||||
) -> Result<Response, StatusCode> {
|
||||
// Get working directory for project-level agent detection
|
||||
let working_directory = state.opencode.get_working_directory();
|
||||
|
||||
let working_directory =
|
||||
match resolve_project_directory(state, extract_directory_from_request(&req)).await {
|
||||
Ok(directory) => directory,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
match method {
|
||||
Method::GET => {
|
||||
match opencode_config::get_agent_sources(&name, Some(&working_directory)).await {
|
||||
Ok(sources) => {
|
||||
let scope = sources.md.scope.clone().map(|s| match s {
|
||||
let resolved_scope = if sources.md.exists {
|
||||
sources.md.scope.clone()
|
||||
} else {
|
||||
sources.json.scope.clone()
|
||||
};
|
||||
let scope = resolved_scope.map(|s| match s {
|
||||
opencode_config::Scope::User => opencode_config::CommandScope::User,
|
||||
opencode_config::Scope::Project => opencode_config::CommandScope::Project,
|
||||
});
|
||||
@@ -1271,12 +1374,11 @@ async fn handle_agent_route(
|
||||
}
|
||||
}
|
||||
Method::POST => {
|
||||
let payload = match parse_request_payload(req).await {
|
||||
let payload = match parse_request_payload(&mut req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
|
||||
|
||||
// Extract scope from payload if present
|
||||
let scope = payload
|
||||
.get("scope")
|
||||
@@ -1320,7 +1422,7 @@ async fn handle_agent_route(
|
||||
}
|
||||
}
|
||||
Method::PATCH => {
|
||||
let payload = match parse_request_payload(req).await {
|
||||
let payload = match parse_request_payload(&mut req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
@@ -1421,11 +1523,17 @@ struct SkillFileResponse {
|
||||
content: String,
|
||||
}
|
||||
|
||||
async fn handle_skill_list_route(state: &ServerState) -> Result<Response<Body>, StatusCode> {
|
||||
let working_directory = state.opencode.get_working_directory();
|
||||
async fn handle_skill_list_route(
|
||||
state: &ServerState,
|
||||
req: Request,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let working_directory =
|
||||
match resolve_project_directory(state, extract_directory_from_request(&req)).await {
|
||||
Ok(directory) => directory,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
let discovered = opencode_config::discover_skills(Some(&working_directory));
|
||||
|
||||
|
||||
let mut skills = Vec::new();
|
||||
for skill in discovered {
|
||||
match opencode_config::get_skill_sources(&skill.name, Some(&working_directory)).await {
|
||||
@@ -1447,18 +1555,24 @@ async fn handle_skill_list_route(state: &ServerState) -> Result<Response<Body>,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json_response(StatusCode::OK, serde_json::json!({ "skills": skills })))
|
||||
Ok(json_response(
|
||||
StatusCode::OK,
|
||||
serde_json::json!({ "skills": skills }),
|
||||
))
|
||||
}
|
||||
|
||||
async fn handle_skill_route(
|
||||
state: &ServerState,
|
||||
method: Method,
|
||||
req: Request<Body>,
|
||||
mut req: Request,
|
||||
name: String,
|
||||
file_path: Option<String>,
|
||||
) -> Result<Response<Body>, StatusCode> {
|
||||
let working_directory = state.opencode.get_working_directory();
|
||||
|
||||
) -> Result<Response, StatusCode> {
|
||||
let working_directory =
|
||||
match resolve_project_directory(state, extract_directory_from_request(&req)).await {
|
||||
Ok(directory) => directory,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
// Handle file operations: /api/config/skills/:name/files/*
|
||||
if let Some(ref fp) = file_path {
|
||||
@@ -1504,11 +1618,14 @@ async fn handle_skill_route(
|
||||
}
|
||||
Method::PUT => {
|
||||
// Write supporting file
|
||||
let payload = match parse_request_payload(req).await {
|
||||
let payload = match parse_request_payload(&mut req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let content = payload.get("content").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let content = payload
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
match opencode_config::get_skill_sources(&name, Some(&working_directory)).await {
|
||||
Ok(sources) => {
|
||||
@@ -1616,12 +1733,13 @@ async fn handle_skill_route(
|
||||
}
|
||||
}
|
||||
Method::POST => {
|
||||
let payload = match parse_request_payload(req).await {
|
||||
let payload = match parse_request_payload(&mut req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
|
||||
let scope = payload.get("scope")
|
||||
let scope = payload
|
||||
.get("scope")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| match s {
|
||||
"project" => Some(opencode_config::SkillScope::Project),
|
||||
@@ -1667,7 +1785,7 @@ async fn handle_skill_route(
|
||||
}
|
||||
}
|
||||
Method::PATCH => {
|
||||
let payload = match parse_request_payload(req).await {
|
||||
let payload = match parse_request_payload(&mut req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
@@ -1744,18 +1862,26 @@ async fn handle_skill_route(
|
||||
async fn handle_command_route(
|
||||
state: &ServerState,
|
||||
method: Method,
|
||||
req: Request<Body>,
|
||||
mut req: Request,
|
||||
name: String,
|
||||
) -> Result<Response<Body>, StatusCode> {
|
||||
) -> Result<Response, StatusCode> {
|
||||
// Get working directory for project-level command detection
|
||||
let working_directory = state.opencode.get_working_directory();
|
||||
|
||||
let working_directory =
|
||||
match resolve_project_directory(state, extract_directory_from_request(&req)).await {
|
||||
Ok(directory) => directory,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
match method {
|
||||
Method::GET => {
|
||||
match opencode_config::get_command_sources(&name, Some(&working_directory)).await {
|
||||
Ok(sources) => {
|
||||
let scope = sources.md.scope.clone().map(|s| match s {
|
||||
let resolved_scope = if sources.md.exists {
|
||||
sources.md.scope.clone()
|
||||
} else {
|
||||
sources.json.scope.clone()
|
||||
};
|
||||
let scope = resolved_scope.map(|s| match s {
|
||||
opencode_config::Scope::User => opencode_config::CommandScope::User,
|
||||
opencode_config::Scope::Project => opencode_config::CommandScope::Project,
|
||||
});
|
||||
@@ -1779,12 +1905,11 @@ async fn handle_command_route(
|
||||
}
|
||||
}
|
||||
Method::POST => {
|
||||
let payload = match parse_request_payload(req).await {
|
||||
let payload = match parse_request_payload(&mut req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
|
||||
|
||||
// Extract scope from payload if present
|
||||
let scope = payload
|
||||
.get("scope")
|
||||
@@ -1831,7 +1956,7 @@ async fn handle_command_route(
|
||||
}
|
||||
}
|
||||
Method::PATCH => {
|
||||
let payload = match parse_request_payload(req).await {
|
||||
let payload = match parse_request_payload(&mut req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
@@ -1913,8 +2038,8 @@ async fn handle_config_routes(
|
||||
state: ServerState,
|
||||
path: &str,
|
||||
method: Method,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response<Body>, StatusCode> {
|
||||
mut req: Request,
|
||||
) -> Result<Response, StatusCode> {
|
||||
if let Some(name) = path.strip_prefix("/api/config/agents/") {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -1945,13 +2070,17 @@ async fn handle_config_routes(
|
||||
.map(|q| q.contains("refresh=true"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let working_directory = state.opencode.get_working_directory();
|
||||
let working_directory =
|
||||
match resolve_project_directory(&state, extract_directory_from_request(&req)).await {
|
||||
Ok(directory) => directory,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
let payload = skills_catalog::get_catalog(&working_directory, refresh).await;
|
||||
return Ok(json_response(StatusCode::OK, payload));
|
||||
}
|
||||
|
||||
if path == "/api/config/skills/scan" && method == Method::POST {
|
||||
let payload_map = match parse_request_payload(req).await {
|
||||
let payload_map = match parse_request_payload(&mut req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
@@ -1991,7 +2120,7 @@ async fn handle_config_routes(
|
||||
}
|
||||
|
||||
if path == "/api/config/skills/install" && method == Method::POST {
|
||||
let payload_map = match parse_request_payload(req).await {
|
||||
let payload_map = match parse_request_payload(&mut req).await {
|
||||
Ok(data) => data,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
@@ -2019,15 +2148,22 @@ async fn handle_config_routes(
|
||||
}
|
||||
};
|
||||
|
||||
let working_directory = state.opencode.get_working_directory();
|
||||
let response = skills_catalog::install_skills(&working_directory, install_request).await;
|
||||
let working_directory = if install_request.scope == "project" {
|
||||
match resolve_project_directory(&state, extract_directory_from_request(&req)).await {
|
||||
Ok(directory) => directory,
|
||||
Err(response) => return Ok(response),
|
||||
}
|
||||
} else {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||
};
|
||||
|
||||
let response = skills_catalog::install_skills(&working_directory, install_request).await;
|
||||
let status = if response.ok {
|
||||
StatusCode::OK
|
||||
} else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("conflicts") {
|
||||
StatusCode::CONFLICT
|
||||
} else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("authRequired") {
|
||||
StatusCode::UNAUTHORIZED
|
||||
} else if response.error.as_ref().map(|e| e.kind.as_str()) == Some("conflicts") {
|
||||
StatusCode::CONFLICT
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
@@ -2037,7 +2173,7 @@ async fn handle_config_routes(
|
||||
|
||||
// Handle skill routes: /api/config/skills and /api/config/skills/:name
|
||||
if path == "/api/config/skills" && method == Method::GET {
|
||||
return handle_skill_list_route(&state).await;
|
||||
return handle_skill_list_route(&state, req).await;
|
||||
}
|
||||
|
||||
if let Some(rest) = path.strip_prefix("/api/config/skills/") {
|
||||
@@ -2059,7 +2195,6 @@ async fn handle_config_routes(
|
||||
.await;
|
||||
}
|
||||
|
||||
|
||||
let trimmed = rest.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(config_error_response(
|
||||
@@ -2158,7 +2293,8 @@ async fn change_directory_handler(
|
||||
|
||||
let mut resolved_path = expand_tilde_path(requested_path);
|
||||
if !resolved_path.is_absolute() {
|
||||
resolved_path = state.opencode.get_working_directory().join(resolved_path);
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
|
||||
resolved_path = home.join(resolved_path);
|
||||
}
|
||||
|
||||
// Validate directory exists and is accessible
|
||||
@@ -2185,51 +2321,72 @@ async fn change_directory_handler(
|
||||
resolved_path = canonicalized;
|
||||
}
|
||||
|
||||
let current_dir = state.opencode.get_working_directory();
|
||||
let is_running = state.opencode.current_port().is_some();
|
||||
let path_value = resolved_path.to_string_lossy().to_string();
|
||||
|
||||
// If already on this directory and OpenCode is running, no restart needed
|
||||
if current_dir == resolved_path && is_running {
|
||||
return Ok(Json(DirectoryChangeResponse {
|
||||
success: true,
|
||||
restarted: false,
|
||||
path: resolved_path.to_string_lossy().to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
info!("[desktop:http] Changing directory to {:?}", resolved_path);
|
||||
|
||||
// Update working directory and restart OpenCode
|
||||
state
|
||||
.opencode
|
||||
.set_working_directory(resolved_path.clone())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(
|
||||
"[desktop:http] ERROR: Failed to set working directory: {}",
|
||||
e
|
||||
);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
.settings
|
||||
.update(|mut settings| {
|
||||
if !settings.is_object() {
|
||||
settings = Value::Object(Default::default());
|
||||
}
|
||||
|
||||
state.opencode.restart().await.map_err(|e| {
|
||||
error!("[desktop:http] ERROR: Failed to restart OpenCode: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
let mut projects = settings
|
||||
.get("projects")
|
||||
.and_then(|value| value.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let existing_index = projects.iter().position(|entry| {
|
||||
entry
|
||||
.get("path")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value == path_value)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
let active_project_id = if let Some(index) = existing_index {
|
||||
projects[index]
|
||||
.get("id")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
} else {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let project = serde_json::json!({
|
||||
"id": id,
|
||||
"path": path_value,
|
||||
"addedAt": chrono::Utc::now().timestamp_millis(),
|
||||
"lastOpenedAt": chrono::Utc::now().timestamp_millis(),
|
||||
});
|
||||
projects.push(project);
|
||||
id
|
||||
};
|
||||
|
||||
let map = settings.as_object_mut().unwrap();
|
||||
map.insert("projects".to_string(), Value::Array(projects));
|
||||
map.insert(
|
||||
"activeProjectId".to_string(),
|
||||
Value::String(active_project_id.clone()),
|
||||
);
|
||||
map.insert("lastDirectory".to_string(), Value::String(path_value.clone()));
|
||||
|
||||
settings
|
||||
})
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(DirectoryChangeResponse {
|
||||
success: true,
|
||||
restarted: true,
|
||||
path: resolved_path.to_string_lossy().to_string(),
|
||||
restarted: false,
|
||||
path: path_value,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn proxy_to_opencode(
|
||||
State(state): State<ServerState>,
|
||||
original: OriginalUri,
|
||||
req: Request<Body>,
|
||||
) -> Result<Response<Body>, StatusCode> {
|
||||
let origin_path = original.0.path().to_string();
|
||||
req: Request,
|
||||
) -> Result<Response, axum::http::StatusCode> {
|
||||
let origin_path = req.uri().path().to_string();
|
||||
let method = req.method().clone();
|
||||
|
||||
// Check if this is a provider auth deletion request (DELETE /api/provider/:id/auth)
|
||||
@@ -2253,7 +2410,7 @@ async fn proxy_to_opencode(
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
})?;
|
||||
|
||||
let query = original.0.query();
|
||||
let query = req.uri().query();
|
||||
let rewritten_path = state.opencode.rewrite_path(&origin_path);
|
||||
let mut target = format!("http://127.0.0.1:{port}{rewritten_path}");
|
||||
if let Some(q) = query {
|
||||
@@ -2351,14 +2508,41 @@ impl SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn save(&self, payload: Value) -> Result<()> {
|
||||
|
||||
pub(crate) async fn update_with<R, F>(&self, f: F) -> Result<(Value, R)>
|
||||
where
|
||||
F: FnOnce(Value) -> (Value, R),
|
||||
{
|
||||
let _lock = self.guard.lock().await;
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent).await.ok();
|
||||
|
||||
let current = match fs::read(&self.path).await {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or(Value::Object(Default::default())),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
Value::Object(Default::default())
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
let current_snapshot = current.clone();
|
||||
let (next, result) = f(current);
|
||||
|
||||
if next != current_snapshot {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent).await.ok();
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&next)?;
|
||||
fs::write(&self.path, bytes).await?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&payload)?;
|
||||
fs::write(&self.path, bytes).await?;
|
||||
Ok(())
|
||||
|
||||
Ok((next, result))
|
||||
}
|
||||
|
||||
pub(crate) async fn update<F>(&self, f: F) -> Result<Value>
|
||||
where
|
||||
F: FnOnce(Value) -> Value,
|
||||
{
|
||||
let (next, _) = self.update_with(|current| (f(current), ())).await?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub(crate) async fn last_directory(&self) -> Result<Option<PathBuf>> {
|
||||
|
||||
@@ -105,9 +105,30 @@ fn get_config_file() -> PathBuf {
|
||||
get_config_dir().join("opencode.json")
|
||||
}
|
||||
|
||||
/// Get project config file path
|
||||
/// Get all possible project config paths in priority order
|
||||
/// Priority: root > .opencode/, json > jsonc
|
||||
fn get_project_config_candidates(working_directory: &Path) -> Vec<PathBuf> {
|
||||
vec![
|
||||
working_directory.join("opencode.json"),
|
||||
working_directory.join("opencode.jsonc"),
|
||||
working_directory.join(".opencode").join("opencode.json"),
|
||||
working_directory.join(".opencode").join("opencode.jsonc"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Find existing project config file or return default path for new config
|
||||
fn get_project_config_file(working_directory: &Path) -> PathBuf {
|
||||
working_directory.join("opencode.json")
|
||||
let candidates = get_project_config_candidates(working_directory);
|
||||
|
||||
// Return first existing config file
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
return candidate.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// Default to root opencode.json for new configs
|
||||
candidates.into_iter().next().unwrap_or_else(|| working_directory.join("opencode.json"))
|
||||
}
|
||||
|
||||
/// Get custom config file path from OPENCODE_CONFIG env var
|
||||
@@ -165,7 +186,9 @@ async fn read_config_file(path: &Path) -> Result<Value> {
|
||||
return Ok(Value::Object(serde_json::Map::new()));
|
||||
}
|
||||
|
||||
serde_json::from_str(&normalized).map_err(|e| anyhow!("Failed to parse config: {}", e))
|
||||
serde_json::from_str(&normalized)
|
||||
.or_else(|_| json5::from_str::<serde_json::Value>(&normalized))
|
||||
.map_err(|e| anyhow!("Failed to parse config: {}", e))
|
||||
}
|
||||
|
||||
async fn read_config_layers(working_directory: Option<&Path>) -> Result<ConfigLayers> {
|
||||
|
||||
@@ -57,7 +57,7 @@ fn normalize_api_prefix(prefix: &str) -> String {
|
||||
}
|
||||
|
||||
impl OpenCodeManager {
|
||||
pub fn new_with_directory(initial_dir: Option<PathBuf>) -> Self {
|
||||
pub fn new_with_directory(_initial_dir: Option<PathBuf>) -> Self {
|
||||
let desired_port = std::env::var("OPENCHAMBER_OPENCODE_PORT")
|
||||
.ok()
|
||||
.and_then(|raw| raw.parse::<u16>().ok())
|
||||
@@ -88,7 +88,7 @@ impl OpenCodeManager {
|
||||
}
|
||||
|
||||
let env = build_augmented_env();
|
||||
let working_dir = initial_dir
|
||||
let working_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
|
||||
info!(
|
||||
@@ -177,11 +177,13 @@ impl OpenCodeManager {
|
||||
self.graceful_stop().await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn set_working_directory(&self, new_dir: PathBuf) -> Result<()> {
|
||||
*self.working_dir.write() = new_dir;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_working_directory(&self) -> PathBuf {
|
||||
self.working_dir.read().clone()
|
||||
}
|
||||
@@ -191,7 +193,7 @@ impl OpenCodeManager {
|
||||
return Err(anyhow!("Cannot detect API prefix without port"));
|
||||
};
|
||||
|
||||
// Try empty prefix first (OpenCode default), then /api (some installations)
|
||||
// Try no prefix first, then /api (compatibility).
|
||||
let candidates = ["", "/api"];
|
||||
for candidate in candidates {
|
||||
let base = if candidate.is_empty() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::Result;
|
||||
use futures_util::TryStreamExt;
|
||||
@@ -10,6 +10,7 @@ use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
use crate::path_utils::expand_tilde_path;
|
||||
use crate::DesktopRuntime;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -126,13 +127,16 @@ async fn run_once(
|
||||
// No data received recently; if we are connected to a directory-scoped stream and the working directory
|
||||
// has changed, reconnect so activity tracking follows the new directory.
|
||||
if let SseScope::Directory(connected_dir) = &scope {
|
||||
let current_dir = opencode.get_working_directory();
|
||||
if current_dir != *connected_dir {
|
||||
debug!(
|
||||
"[desktop:activity] Working directory changed; reconnecting activity SSE (from {:?} to {:?})",
|
||||
connected_dir, current_dir
|
||||
);
|
||||
return Ok(());
|
||||
if let Some(current_dir) =
|
||||
resolve_project_directory_from_settings(runtime).await
|
||||
{
|
||||
if current_dir != *connected_dir {
|
||||
debug!(
|
||||
"[desktop:activity] Project directory changed; reconnecting activity SSE (from {:?} to {:?})",
|
||||
connected_dir, current_dir
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -183,13 +187,34 @@ fn parse_event_envelope(raw: &str) -> Result<(EventEnvelope, Option<String>)> {
|
||||
Ok((multiplexed.payload, multiplexed.directory))
|
||||
}
|
||||
|
||||
async fn resolve_project_directory_from_settings(runtime: &DesktopRuntime) -> Option<PathBuf> {
|
||||
let settings = runtime.settings().load().await.ok()?;
|
||||
|
||||
if let Some(active_id) = settings.get("activeProjectId").and_then(Value::as_str) {
|
||||
if let Some(projects) = settings.get("projects").and_then(Value::as_array) {
|
||||
if let Some(path) = projects.iter().find_map(|entry| {
|
||||
let id = entry.get("id").and_then(Value::as_str)?;
|
||||
if id != active_id {
|
||||
return None;
|
||||
}
|
||||
entry.get("path").and_then(Value::as_str)
|
||||
}) {
|
||||
return Some(expand_tilde_path(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settings
|
||||
.get("lastDirectory")
|
||||
.and_then(Value::as_str)
|
||||
.map(expand_tilde_path)
|
||||
}
|
||||
|
||||
async fn connect_activity_sse(
|
||||
runtime: &DesktopRuntime,
|
||||
client: &Client,
|
||||
base: &str,
|
||||
) -> Result<(reqwest::Response, SseScope)> {
|
||||
let opencode = runtime.opencode_manager();
|
||||
|
||||
let global_url = format!("{base}/global/event");
|
||||
match try_connect_sse(client, &global_url, "[desktop:activity]").await {
|
||||
Ok(response) => {
|
||||
@@ -216,7 +241,9 @@ async fn connect_activity_sse(
|
||||
}
|
||||
}
|
||||
|
||||
let working_dir = opencode.get_working_directory();
|
||||
let Some(working_dir) = resolve_project_directory_from_settings(runtime).await else {
|
||||
anyhow::bail!("No project directory available for SSE fallback");
|
||||
};
|
||||
let directory = working_dir.to_string_lossy().to_string();
|
||||
let mut parsed = reqwest::Url::parse(&event_url)?;
|
||||
parsed
|
||||
|
||||
Reference in New Issue
Block a user