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:
Bohdan Triapitsyn
2026-01-06 21:31:04 +02:00
committed by GitHub
parent 8aa379e313
commit 18c5b4c7b5
84 changed files with 8399 additions and 2854 deletions
@@ -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,
+103 -35
View File
@@ -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(&current, &sanitized_changes);
// Save merged settings
state
let (merged, _) = state
.settings()
.save(merged.clone())
.update_with(|current| {
let mut merged = merge_persisted_settings(&current, &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);
+305 -121
View File
@@ -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