From a282bbdae43e989bca89dc477683eec07f45c973 Mon Sep 17 00:00:00 2001 From: Solomon Date: Wed, 17 Dec 2025 18:25:34 +0000 Subject: [PATCH] Real-time sync for Projects (#1512) * Real-time sync for Projects * Do not create project in a transaction Update hooks trigger before the transaction is commited, which causes insert events to be dismissed because the row is isn't found --- ...28f9be72d02217ef43f0b79494b63380ea9a8.json | 56 +++++++++++++ crates/db/src/models/project.rs | 18 ++++ crates/server/src/routes/projects.rs | 52 +++++++++++- crates/services/src/services/events.rs | 37 ++++++++- .../services/src/services/events/patches.rs | 45 +++++++++- .../services/src/services/events/streams.rs | 82 ++++++++++++++++++- crates/services/src/services/events/types.rs | 10 ++- crates/services/src/services/project.rs | 11 +-- .../src/components/projects/ProjectCard.tsx | 13 +-- .../src/components/projects/ProjectDetail.tsx | 50 ++++------- .../src/components/projects/ProjectList.tsx | 46 ++++------- frontend/src/contexts/ProjectContext.tsx | 27 +++--- frontend/src/hooks/useProjects.ts | 53 ++++++++++-- frontend/src/i18n/locales/en/projects.json | 4 +- frontend/src/i18n/locales/es/projects.json | 4 +- frontend/src/i18n/locales/ja/projects.json | 4 +- frontend/src/i18n/locales/ko/projects.json | 4 +- .../src/i18n/locales/zh-Hans/projects.json | 4 +- frontend/src/lib/api.ts | 10 --- .../pages/settings/OrganizationSettings.tsx | 3 +- .../src/pages/settings/ProjectSettings.tsx | 2 +- 21 files changed, 402 insertions(+), 133 deletions(-) create mode 100644 crates/db/.sqlx/query-d6218ce758d0ef58edc775de68a28f9be72d02217ef43f0b79494b63380ea9a8.json diff --git a/crates/db/.sqlx/query-d6218ce758d0ef58edc775de68a28f9be72d02217ef43f0b79494b63380ea9a8.json b/crates/db/.sqlx/query-d6218ce758d0ef58edc775de68a28f9be72d02217ef43f0b79494b63380ea9a8.json new file mode 100644 index 00000000..c499d46e --- /dev/null +++ b/crates/db/.sqlx/query-d6218ce758d0ef58edc775de68a28f9be72d02217ef43f0b79494b63380ea9a8.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id as \"id!: Uuid\",\n name,\n dev_script,\n dev_script_working_dir,\n remote_project_id as \"remote_project_id: Uuid\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM projects\n WHERE rowid = $1", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "dev_script", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "dev_script_working_dir", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "remote_project_id: Uuid", + "ordinal": 4, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "d6218ce758d0ef58edc775de68a28f9be72d02217ef43f0b79494b63380ea9a8" +} diff --git a/crates/db/src/models/project.rs b/crates/db/src/models/project.rs index d27e978c..64b9fc10 100644 --- a/crates/db/src/models/project.rs +++ b/crates/db/src/models/project.rs @@ -122,6 +122,24 @@ impl Project { .await } + pub async fn find_by_rowid(pool: &SqlitePool, rowid: i64) -> Result, sqlx::Error> { + sqlx::query_as!( + Project, + r#"SELECT id as "id!: Uuid", + name, + dev_script, + dev_script_working_dir, + remote_project_id as "remote_project_id: Uuid", + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM projects + WHERE rowid = $1"#, + rowid + ) + .fetch_optional(pool) + .await + } + pub async fn find_by_remote_project_id( pool: &SqlitePool, remote_project_id: Uuid, diff --git a/crates/server/src/routes/projects.rs b/crates/server/src/routes/projects.rs index 925f641a..ca13cd2d 100644 --- a/crates/server/src/routes/projects.rs +++ b/crates/server/src/routes/projects.rs @@ -1,11 +1,15 @@ use std::path::PathBuf; +use anyhow; use axum::{ Extension, Json, Router, - extract::{Path, Query, State}, + extract::{ + Path, Query, State, + ws::{WebSocket, WebSocketUpgrade}, + }, http::StatusCode, middleware::from_fn_with_state, - response::Json as ResponseJson, + response::{IntoResponse, Json as ResponseJson}, routing::{get, post}, }; use db::models::{ @@ -14,6 +18,7 @@ use db::models::{ repo::Repo, }; use deployment::Deployment; +use futures_util::{SinkExt, StreamExt, TryStreamExt}; use serde::Deserialize; use services::services::{ file_search_cache::SearchQuery, project::ProjectServiceError, @@ -46,6 +51,48 @@ pub async fn get_projects( Ok(ResponseJson(ApiResponse::success(projects))) } +pub async fn stream_projects_ws( + ws: WebSocketUpgrade, + State(deployment): State, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| async move { + if let Err(e) = handle_projects_ws(socket, deployment).await { + tracing::warn!("projects WS closed: {}", e); + } + }) +} + +async fn handle_projects_ws(socket: WebSocket, deployment: DeploymentImpl) -> anyhow::Result<()> { + let mut stream = deployment + .events() + .stream_projects_raw() + .await? + .map_ok(|msg| msg.to_ws_message_unchecked()); + + // Split socket into sender and receiver + let (mut sender, mut receiver) = socket.split(); + + // Drain (and ignore) any client->server messages so pings/pongs work + tokio::spawn(async move { while let Some(Ok(_)) = receiver.next().await {} }); + + // Forward server messages + while let Some(item) = stream.next().await { + match item { + Ok(msg) => { + if sender.send(msg).await.is_err() { + break; // client disconnected + } + } + Err(e) => { + tracing::error!("stream error: {}", e); + break; + } + } + } + + Ok(()) +} + pub async fn get_project( Extension(project): Extension, ) -> Result>, ApiError> { @@ -566,6 +613,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router { .put(update_project_repository) .delete(delete_project_repository), ) + .route("/stream/ws", get(stream_projects_ws)) .nest("/{id}", project_id_router); Router::new().nest("/projects", projects_router).route( diff --git a/crates/services/src/services/events.rs b/crates/services/src/services/events.rs index f643876c..5e6565d2 100644 --- a/crates/services/src/services/events.rs +++ b/crates/services/src/services/events.rs @@ -3,7 +3,7 @@ use std::{str::FromStr, sync::Arc}; use db::{ DBService, models::{ - execution_process::ExecutionProcess, scratch::Scratch, task::Task, + execution_process::ExecutionProcess, project::Project, scratch::Scratch, task::Task, task_attempt::TaskAttempt, }, }; @@ -20,7 +20,9 @@ mod streams; #[path = "events/types.rs"] pub mod types; -pub use patches::{execution_process_patch, scratch_patch, task_attempt_patch, task_patch}; +pub use patches::{ + execution_process_patch, project_patch, scratch_patch, task_attempt_patch, task_patch, +}; pub use types::{EventError, EventPatch, EventPatchInner, HookTables, RecordTypes}; #[derive(Clone)] @@ -107,6 +109,14 @@ impl EventService { msg_store_for_preupdate.push_patch(patch); } } + "projects" => { + if let Ok(value) = preupdate.get_old_column_value(0) + && let Ok(project_id) = >::decode(value) + { + let patch = project_patch::remove(project_id); + msg_store_for_preupdate.push_patch(patch); + } + } "task_attempts" => { if let Ok(value) = preupdate.get_old_column_value(0) && let Ok(attempt_id) = >::decode(value) @@ -151,6 +161,7 @@ impl EventService { runtime_handle.spawn(async move { let record_type: RecordTypes = match (table, hook.operation.clone()) { (HookTables::Tasks, SqliteOperation::Delete) + | (HookTables::Projects, SqliteOperation::Delete) | (HookTables::TaskAttempts, SqliteOperation::Delete) | (HookTables::ExecutionProcesses, SqliteOperation::Delete) | (HookTables::Scratch, SqliteOperation::Delete) => { @@ -171,6 +182,19 @@ impl EventService { } } } + (HookTables::Projects, _) => { + match Project::find_by_rowid(&db.pool, rowid).await { + Ok(Some(project)) => RecordTypes::Project(project), + Ok(None) => RecordTypes::DeletedProject { + rowid, + project_id: None, + }, + Err(e) => { + tracing::error!("Failed to fetch project: {:?}", e); + return; + } + } + } (HookTables::TaskAttempts, _) => { match TaskAttempt::find_by_rowid(&db.pool, rowid).await { Ok(Some(attempt)) => RecordTypes::TaskAttempt(attempt), @@ -261,6 +285,15 @@ impl EventService { msg_store_for_hook.push_patch(patch); return; } + RecordTypes::Project(project) => { + let patch = match hook.operation { + SqliteOperation::Insert => project_patch::add(project), + SqliteOperation::Update => project_patch::replace(project), + _ => project_patch::replace(project), + }; + msg_store_for_hook.push_patch(patch); + return; + } RecordTypes::Scratch(scratch) => { let patch = match hook.operation { SqliteOperation::Insert => scratch_patch::add(scratch), diff --git a/crates/services/src/services/events/patches.rs b/crates/services/src/services/events/patches.rs index 4db4db61..5e9f1f30 100644 --- a/crates/services/src/services/events/patches.rs +++ b/crates/services/src/services/events/patches.rs @@ -1,6 +1,6 @@ use db::models::{ - execution_process::ExecutionProcess, scratch::Scratch, task::TaskWithAttemptStatus, - task_attempt::TaskAttempt, + execution_process::ExecutionProcess, project::Project, scratch::Scratch, + task::TaskWithAttemptStatus, task_attempt::TaskAttempt, }; use json_patch::{AddOperation, Patch, PatchOperation, RemoveOperation, ReplaceOperation}; use uuid::Uuid; @@ -48,6 +48,47 @@ pub mod task_patch { } } +/// Helper functions for creating project-specific patches +pub mod project_patch { + use super::*; + + fn project_path(project_id: Uuid) -> String { + format!( + "/projects/{}", + escape_pointer_segment(&project_id.to_string()) + ) + } + + /// Create patch for adding a new project + pub fn add(project: &Project) -> Patch { + Patch(vec![PatchOperation::Add(AddOperation { + path: project_path(project.id) + .try_into() + .expect("Project path should be valid"), + value: serde_json::to_value(project).expect("Project serialization should not fail"), + })]) + } + + /// Create patch for updating an existing project + pub fn replace(project: &Project) -> Patch { + Patch(vec![PatchOperation::Replace(ReplaceOperation { + path: project_path(project.id) + .try_into() + .expect("Project path should be valid"), + value: serde_json::to_value(project).expect("Project serialization should not fail"), + })]) + } + + /// Create patch for removing a project + pub fn remove(project_id: Uuid) -> Patch { + Patch(vec![PatchOperation::Remove(RemoveOperation { + path: project_path(project_id) + .try_into() + .expect("Project path should be valid"), + })]) + } +} + /// Helper functions for creating execution process-specific patches pub mod execution_process_patch { use super::*; diff --git a/crates/services/src/services/events/streams.rs b/crates/services/src/services/events/streams.rs index 09ce64bd..1182c9ce 100644 --- a/crates/services/src/services/events/streams.rs +++ b/crates/services/src/services/events/streams.rs @@ -1,11 +1,12 @@ use db::models::{ execution_process::ExecutionProcess, + project::Project, scratch::Scratch, task::{Task, TaskWithAttemptStatus}, }; use futures::StreamExt; use serde_json::json; -use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; use utils::log_msg::LogMsg; use uuid::Uuid; @@ -145,6 +146,85 @@ impl EventService { Ok(combined_stream) } + /// Stream raw project messages with initial snapshot + pub async fn stream_projects_raw( + &self, + ) -> Result>, EventError> + { + fn build_projects_snapshot(projects: Vec) -> LogMsg { + // Convert projects array to object keyed by project ID + let projects_map: serde_json::Map = projects + .into_iter() + .map(|project| { + ( + project.id.to_string(), + serde_json::to_value(project).unwrap(), + ) + }) + .collect(); + + let patch = json!([ + { + "op": "replace", + "path": "/projects", + "value": projects_map + } + ]); + + LogMsg::JsonPatch(serde_json::from_value(patch).unwrap()) + } + + // Get initial snapshot of projects + let projects = Project::find_all(&self.db.pool).await?; + let initial_msg = build_projects_snapshot(projects); + + let db_pool = self.db.pool.clone(); + + // Get filtered event stream (projects only) + let filtered_stream = + BroadcastStream::new(self.msg_store.get_receiver()).filter_map(move |msg_result| { + let db_pool = db_pool.clone(); + async move { + match msg_result { + Ok(LogMsg::JsonPatch(patch)) => { + if let Some(patch_op) = patch.0.first() + && patch_op.path().starts_with("/projects") + { + return Some(Ok(LogMsg::JsonPatch(patch))); + } + None + } + Ok(other) => Some(Ok(other)), // Pass through non-patch messages + Err(BroadcastStreamRecvError::Lagged(skipped)) => { + tracing::warn!( + skipped = skipped, + "projects stream lagged; resyncing snapshot" + ); + + match Project::find_all(&db_pool).await { + Ok(projects) => Some(Ok(build_projects_snapshot(projects))), + Err(err) => { + tracing::error!( + error = %err, + "failed to resync projects after lag" + ); + Some(Err(std::io::Error::other(format!( + "failed to resync projects after lag: {err}" + )))) + } + } + } + } + } + }); + + // Start with initial snapshot, then live updates + let initial_stream = futures::stream::once(async move { Ok(initial_msg) }); + let combined_stream = initial_stream.chain(filtered_stream).boxed(); + + Ok(combined_stream) + } + /// Stream execution processes for a specific task attempt with initial snapshot (raw LogMsg format for WebSocket) pub async fn stream_execution_processes_for_attempt_raw( &self, diff --git a/crates/services/src/services/events/types.rs b/crates/services/src/services/events/types.rs index c6636e4c..9737cf5c 100644 --- a/crates/services/src/services/events/types.rs +++ b/crates/services/src/services/events/types.rs @@ -1,6 +1,7 @@ use anyhow::Error as AnyhowError; use db::models::{ - execution_process::ExecutionProcess, scratch::Scratch, task::Task, task_attempt::TaskAttempt, + execution_process::ExecutionProcess, project::Project, scratch::Scratch, task::Task, + task_attempt::TaskAttempt, }; use serde::{Deserialize, Serialize}; use sqlx::Error as SqlxError; @@ -29,6 +30,8 @@ pub enum HookTables { ExecutionProcesses, #[strum(to_string = "scratch")] Scratch, + #[strum(to_string = "projects")] + Projects, } #[derive(Serialize, Deserialize, TS)] @@ -38,6 +41,7 @@ pub enum RecordTypes { TaskAttempt(TaskAttempt), ExecutionProcess(ExecutionProcess), Scratch(Scratch), + Project(Project), DeletedTask { rowid: i64, project_id: Option, @@ -57,6 +61,10 @@ pub enum RecordTypes { scratch_id: Option, scratch_type: Option, }, + DeletedProject { + rowid: i64, + project_id: Option, + }, } #[derive(Serialize, Deserialize, TS)] diff --git a/crates/services/src/services/project.rs b/crates/services/src/services/project.rs index de77ad63..98f632ef 100644 --- a/crates/services/src/services/project.rs +++ b/crates/services/src/services/project.rs @@ -106,22 +106,17 @@ impl ProjectService { let id = Uuid::new_v4(); - // Start transaction - let mut tx = pool.begin().await?; - - let project = Project::create(&mut *tx, &payload, id) + let project = Project::create(pool, &payload, id) .await .map_err(|e| ProjectServiceError::Project(ProjectError::CreateFailed(e.to_string())))?; for repo in normalized_repos { let repo_entity = - Repo::find_or_create(&mut *tx, Path::new(&repo.git_repo_path), &repo.display_name) + Repo::find_or_create(pool, Path::new(&repo.git_repo_path), &repo.display_name) .await?; - ProjectRepo::create(&mut *tx, project.id, repo_entity.id).await?; + ProjectRepo::create(pool, project.id, repo_entity.id).await?; } - tx.commit().await?; - Ok(project) } diff --git a/frontend/src/components/projects/ProjectCard.tsx b/frontend/src/components/projects/ProjectCard.tsx index 492b7b1f..4d24b0cd 100644 --- a/frontend/src/components/projects/ProjectCard.tsx +++ b/frontend/src/components/projects/ProjectCard.tsx @@ -33,18 +33,11 @@ import { useProjectMutations } from '@/hooks/useProjectMutations'; type Props = { project: Project; isFocused: boolean; - fetchProjects: () => void; setError: (error: string) => void; onEdit: (project: Project) => void; }; -function ProjectCard({ - project, - isFocused, - fetchProjects, - setError, - onEdit, -}: Props) { +function ProjectCard({ project, isFocused, setError, onEdit }: Props) { const navigate = useNavigateWithSearch(); const ref = useRef(null); const handleOpenInEditor = useOpenProjectInEditor(project); @@ -54,9 +47,6 @@ function ProjectCard({ const isSingleRepoProject = repos?.length === 1; const { unlinkProject } = useProjectMutations({ - onUnlinkSuccess: () => { - fetchProjects(); - }, onUnlinkError: (error) => { console.error('Failed to unlink project:', error); setError('Failed to unlink project'); @@ -80,7 +70,6 @@ function ProjectCard({ try { await projectsApi.delete(id); - fetchProjects(); } catch (error) { console.error('Failed to delete project:', error); setError('Failed to delete project'); diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx index ba7828dd..ec99f455 100644 --- a/frontend/src/components/projects/ProjectDetail.tsx +++ b/frontend/src/components/projects/ProjectDetail.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { useNavigateWithSearch } from '@/hooks'; import { @@ -10,8 +11,8 @@ import { } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Project } from 'shared/types'; import { projectsApi } from '@/lib/api'; +import { useProjects } from '@/hooks/useProjects'; import { AlertCircle, ArrowLeft, @@ -29,26 +30,12 @@ interface ProjectDetailProps { } export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { + const { t } = useTranslation('projects'); const navigate = useNavigateWithSearch(); - const [project, setProject] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); + const { projectsById, isLoading, error: projectsError } = useProjects(); + const [deleteError, setDeleteError] = useState(''); - const fetchProject = useCallback(async () => { - setLoading(true); - setError(''); - - try { - const result = await projectsApi.getById(projectId); - setProject(result); - } catch (error) { - console.error('Failed to fetch project:', error); - // @ts-expect-error it is type ApiError - setError(error.message || 'Failed to load project'); - } - - setLoading(false); - }, [projectId]); + const project = projectsById[projectId] || null; const handleDelete = async () => { if (!project) return; @@ -65,7 +52,8 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { } catch (error) { console.error('Failed to delete project:', error); // @ts-expect-error it is type ApiError - setError(error.message || 'Failed to delete project'); + setDeleteError(error.message || t('errors.deleteFailed')); + setTimeout(() => setDeleteError(''), 5000); } }; @@ -73,11 +61,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { navigate(`/settings/projects?projectId=${projectId}`); }; - useEffect(() => { - fetchProject(); - }, [fetchProject]); - - if (loading) { + if (isLoading) { return (
@@ -86,7 +70,10 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { ); } - if (error || !project) { + if ((!project && !isLoading) || projectsError) { + const errorMsg = projectsError + ? projectsError.message + : t('projectNotFound'); return (

Project not found

-

- {error || - "The project you're looking for doesn't exist or has been deleted."} -

+

{errorMsg}

@@ -149,10 +133,10 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
- {error && ( + {deleteError && ( - {error} + {deleteError} )} diff --git a/frontend/src/components/projects/ProjectList.tsx b/frontend/src/components/projects/ProjectList.tsx index 7cb4e8a6..594427b4 100644 --- a/frontend/src/components/projects/ProjectList.tsx +++ b/frontend/src/components/projects/ProjectList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -7,40 +7,22 @@ import { Card, CardContent } from '@/components/ui/card'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Project } from 'shared/types'; import { ProjectFormDialog } from '@/components/dialogs/projects/ProjectFormDialog'; -import { projectsApi } from '@/lib/api'; import { AlertCircle, Loader2, Plus } from 'lucide-react'; import ProjectCard from '@/components/projects/ProjectCard.tsx'; import { useKeyCreate, Scope } from '@/keyboard'; +import { useProjects } from '@/hooks/useProjects'; export function ProjectList() { const navigate = useNavigate(); const { t } = useTranslation('projects'); - const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(false); + const { projects, isLoading, error: projectsError } = useProjects(); const [error, setError] = useState(''); const [focusedProjectId, setFocusedProjectId] = useState(null); - const fetchProjects = useCallback(async () => { - setLoading(true); - setError(''); - - try { - const result = await projectsApi.getAll(); - setProjects(result); - } catch (error) { - console.error('Failed to fetch projects:', error); - setError(t('errors.fetchFailed')); - } finally { - setLoading(false); - } - }, [t]); - const handleCreateProject = async () => { try { const result = await ProjectFormDialog.show({}); - if (result === 'saved') { - fetchProjects(); - } + if (result === 'saved') return; } catch (error) { // User cancelled - do nothing } @@ -55,15 +37,16 @@ export function ProjectList() { // Set initial focus when projects are loaded useEffect(() => { - if (projects.length > 0 && !focusedProjectId) { + if (projects.length === 0) { + setFocusedProjectId(null); + return; + } + + if (!focusedProjectId || !projects.some((p) => p.id === focusedProjectId)) { setFocusedProjectId(projects[0].id); } }, [projects, focusedProjectId]); - useEffect(() => { - fetchProjects(); - }, [fetchProjects]); - return (
@@ -77,14 +60,16 @@ export function ProjectList() {
- {error && ( + {(error || projectsError) && ( - {error} + + {error || projectsError?.message || t('errors.fetchFailed')} + )} - {loading ? ( + {isLoading ? (
{t('loading')} @@ -114,7 +99,6 @@ export function ProjectList() { isFocused={focusedProjectId === project.id} setError={setError} onEdit={handleEditProject} - fetchProjects={fetchProjects} /> ))}
diff --git a/frontend/src/contexts/ProjectContext.tsx b/frontend/src/contexts/ProjectContext.tsx index 7ef2a214..6dd13fc6 100644 --- a/frontend/src/contexts/ProjectContext.tsx +++ b/frontend/src/contexts/ProjectContext.tsx @@ -6,9 +6,8 @@ import { useEffect, } from 'react'; import { useLocation } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; -import { projectsApi } from '@/lib/api'; import type { Project } from 'shared/types'; +import { useProjects } from '@/hooks/useProjects'; interface ProjectContextValue { projectId: string | undefined; @@ -33,32 +32,28 @@ export function ProjectProvider({ children }: ProjectProviderProps) { return match ? match[1] : undefined; }, [location.pathname]); - const query = useQuery({ - queryKey: ['project', projectId], - queryFn: () => projectsApi.getById(projectId!), - enabled: !!projectId, - staleTime: 5 * 60 * 1000, // 5 minutes - }); + const { projectsById, isLoading, error } = useProjects(); + const project = projectId ? projectsById[projectId] : undefined; const value = useMemo( () => ({ projectId, - project: query.data, - isLoading: query.isLoading, - error: query.error, - isError: query.isError, + project, + isLoading, + error, + isError: !!error, }), - [projectId, query.data, query.isLoading, query.error, query.isError] + [projectId, project, isLoading, error] ); // Centralized page title management useEffect(() => { - if (query.data) { - document.title = `${query.data.name} | vibe-kanban`; + if (project) { + document.title = `${project.name} | vibe-kanban`; } else { document.title = 'vibe-kanban'; } - }, [query.data]); + }, [project]); return ( {children} diff --git a/frontend/src/hooks/useProjects.ts b/frontend/src/hooks/useProjects.ts index dd1542a4..3bcff1cb 100644 --- a/frontend/src/hooks/useProjects.ts +++ b/frontend/src/hooks/useProjects.ts @@ -1,11 +1,48 @@ -import { useQuery } from '@tanstack/react-query'; -import { projectsApi } from '@/lib/api'; +import { useCallback, useMemo } from 'react'; +import { useJsonPatchWsStream } from './useJsonPatchWsStream'; import type { Project } from 'shared/types'; -export function useProjects() { - return useQuery({ - queryKey: ['projects'], - queryFn: () => projectsApi.getAll(), - staleTime: 30000, // Consider data fresh for 30 seconds - }); +type ProjectsState = { + projects: Record; +}; + +export interface UseProjectsResult { + projects: Project[]; + projectsById: Record; + isLoading: boolean; + isConnected: boolean; + error: Error | null; +} + +export function useProjects(): UseProjectsResult { + const endpoint = '/api/projects/stream/ws'; + + const initialData = useCallback((): ProjectsState => ({ projects: {} }), []); + + const { data, isConnected, error } = useJsonPatchWsStream( + endpoint, + true, + initialData + ); + + const projectsById = useMemo(() => data?.projects ?? {}, [data]); + + const projects = useMemo(() => { + return Object.values(projectsById).sort( + (a, b) => + new Date(b.created_at as unknown as string).getTime() - + new Date(a.created_at as unknown as string).getTime() + ); + }, [projectsById]); + + const projectsData = data ? projects : undefined; + const errorObj = useMemo(() => (error ? new Error(error) : null), [error]); + + return { + projects: projectsData ?? [], + projectsById, + isLoading: !data && !error, + isConnected, + error: errorObj, + }; } diff --git a/frontend/src/i18n/locales/en/projects.json b/frontend/src/i18n/locales/en/projects.json index 6da0b958..e8179e45 100644 --- a/frontend/src/i18n/locales/en/projects.json +++ b/frontend/src/i18n/locales/en/projects.json @@ -5,7 +5,8 @@ "linkToOrganization": "Link to Remote Project", "loading": "Loading projects...", "errors": { - "fetchFailed": "Failed to fetch projects" + "fetchFailed": "Failed to fetch projects", + "deleteFailed": "Failed to delete project" }, "empty": { "title": "No projects yet", @@ -44,6 +45,7 @@ } }, "unlinkFromOrganization": "Unlink from Remote Project", + "projectNotFound": "The project you're looking for doesn't exist or has been deleted.", "viewProject": "View Project", "openInIDE": "Open in IDE", "createdDate": "Created {{date}}", diff --git a/frontend/src/i18n/locales/es/projects.json b/frontend/src/i18n/locales/es/projects.json index ca795dc4..7a8431af 100644 --- a/frontend/src/i18n/locales/es/projects.json +++ b/frontend/src/i18n/locales/es/projects.json @@ -5,7 +5,8 @@ "linkToOrganization": "Vincular a Proyecto Remoto", "loading": "Cargando proyectos...", "errors": { - "fetchFailed": "Error al cargar proyectos" + "fetchFailed": "Error al cargar proyectos", + "deleteFailed": "Error al eliminar el proyecto" }, "empty": { "title": "Aún no hay proyectos", @@ -44,6 +45,7 @@ } }, "unlinkFromOrganization": "Desvincular de Proyecto Remoto", + "projectNotFound": "El proyecto que buscas no existe o ha sido eliminado.", "viewProject": "Ver Proyecto", "openInIDE": "Abrir en IDE", "createdDate": "Creado {{date}}", diff --git a/frontend/src/i18n/locales/ja/projects.json b/frontend/src/i18n/locales/ja/projects.json index a8c5f169..fb548905 100644 --- a/frontend/src/i18n/locales/ja/projects.json +++ b/frontend/src/i18n/locales/ja/projects.json @@ -5,7 +5,8 @@ "linkToOrganization": "リモートプロジェクトにリンク", "loading": "プロジェクトを読み込み中...", "errors": { - "fetchFailed": "プロジェクトの取得に失敗しました" + "fetchFailed": "プロジェクトの取得に失敗しました", + "deleteFailed": "プロジェクトの削除に失敗しました" }, "empty": { "title": "プロジェクトがありません", @@ -44,6 +45,7 @@ } }, "unlinkFromOrganization": "リモートプロジェクトからリンク解除", + "projectNotFound": "お探しのプロジェクトは存在しないか、削除されました。", "viewProject": "プロジェクトを表示", "openInIDE": "IDEで開く", "createdDate": "作成日 {{date}}", diff --git a/frontend/src/i18n/locales/ko/projects.json b/frontend/src/i18n/locales/ko/projects.json index 5452b5a6..a2c52ce9 100644 --- a/frontend/src/i18n/locales/ko/projects.json +++ b/frontend/src/i18n/locales/ko/projects.json @@ -5,7 +5,8 @@ "linkToOrganization": "원격 프로젝트에 연결", "loading": "프로젝트 로딩 중...", "errors": { - "fetchFailed": "프로젝트를 불러오지 못했습니다" + "fetchFailed": "프로젝트를 불러오지 못했습니다", + "deleteFailed": "프로젝트 삭제에 실패했습니다" }, "empty": { "title": "아직 프로젝트가 없습니다", @@ -44,6 +45,7 @@ } }, "unlinkFromOrganization": "원격 프로젝트에서 연결 해제", + "projectNotFound": "찾으시는 프로젝트가 존재하지 않거나 삭제되었습니다.", "viewProject": "프로젝트 보기", "openInIDE": "IDE에서 열기", "createdDate": "생성일 {{date}}", diff --git a/frontend/src/i18n/locales/zh-Hans/projects.json b/frontend/src/i18n/locales/zh-Hans/projects.json index 4b195373..64f78825 100644 --- a/frontend/src/i18n/locales/zh-Hans/projects.json +++ b/frontend/src/i18n/locales/zh-Hans/projects.json @@ -5,7 +5,8 @@ "linkToOrganization": "链接到远程项目", "loading": "加载项目中...", "errors": { - "fetchFailed": "获取项目失败" + "fetchFailed": "获取项目失败", + "deleteFailed": "删除项目失败" }, "empty": { "title": "还没有项目", @@ -44,6 +45,7 @@ } }, "unlinkFromOrganization": "取消链接远程项目", + "projectNotFound": "您查找的项目不存在或已被删除。", "viewProject": "查看项目", "openInIDE": "在 IDE 中打开", "createdDate": "创建于 {{date}}", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f462775c..3b860e1d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -233,16 +233,6 @@ export const handleApiResponse = async ( // Project Management APIs export const projectsApi = { - getAll: async (): Promise => { - const response = await makeRequest('/api/projects'); - return handleApiResponse(response); - }, - - getById: async (id: string): Promise => { - const response = await makeRequest(`/api/projects/${id}`); - return handleApiResponse(response); - }, - create: async (data: CreateProject): Promise => { const response = await makeRequest('/api/projects', { method: 'POST', diff --git a/frontend/src/pages/settings/OrganizationSettings.tsx b/frontend/src/pages/settings/OrganizationSettings.tsx index fb74d65b..4918c18c 100644 --- a/frontend/src/pages/settings/OrganizationSettings.tsx +++ b/frontend/src/pages/settings/OrganizationSettings.tsx @@ -137,7 +137,8 @@ export function OrganizationSettings() { }); // Fetch all local projects - const { data: allProjects = [], isLoading: loadingProjects } = useProjects(); + const { projects: allProjects = [], isLoading: loadingProjects } = + useProjects(); // Fetch remote projects for the selected organization const { data: remoteProjects = [], isLoading: loadingRemoteProjects } = diff --git a/frontend/src/pages/settings/ProjectSettings.tsx b/frontend/src/pages/settings/ProjectSettings.tsx index 7b1bb0f1..60a7a4d2 100644 --- a/frontend/src/pages/settings/ProjectSettings.tsx +++ b/frontend/src/pages/settings/ProjectSettings.tsx @@ -73,7 +73,7 @@ export function ProjectSettings() { // Fetch all projects const { - data: projects, + projects, isLoading: projectsLoading, error: projectsError, } = useProjects();