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
+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>> {