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
This commit is contained in:
Solomon
2025-12-17 18:25:34 +00:00
committed by GitHub
parent 4b4fdb9a60
commit a282bbdae4
21 changed files with 402 additions and 133 deletions

View File

@@ -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<Utc>\",\n updated_at as \"updated_at!: DateTime<Utc>\"\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<Utc>",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
true,
true,
true,
false,
false
]
},
"hash": "d6218ce758d0ef58edc775de68a28f9be72d02217ef43f0b79494b63380ea9a8"
}

View File

@@ -122,6 +122,24 @@ impl Project {
.await .await
} }
pub async fn find_by_rowid(pool: &SqlitePool, rowid: i64) -> Result<Option<Self>, 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<Utc>",
updated_at as "updated_at!: DateTime<Utc>"
FROM projects
WHERE rowid = $1"#,
rowid
)
.fetch_optional(pool)
.await
}
pub async fn find_by_remote_project_id( pub async fn find_by_remote_project_id(
pool: &SqlitePool, pool: &SqlitePool,
remote_project_id: Uuid, remote_project_id: Uuid,

View File

@@ -1,11 +1,15 @@
use std::path::PathBuf; use std::path::PathBuf;
use anyhow;
use axum::{ use axum::{
Extension, Json, Router, Extension, Json, Router,
extract::{Path, Query, State}, extract::{
Path, Query, State,
ws::{WebSocket, WebSocketUpgrade},
},
http::StatusCode, http::StatusCode,
middleware::from_fn_with_state, middleware::from_fn_with_state,
response::Json as ResponseJson, response::{IntoResponse, Json as ResponseJson},
routing::{get, post}, routing::{get, post},
}; };
use db::models::{ use db::models::{
@@ -14,6 +18,7 @@ use db::models::{
repo::Repo, repo::Repo,
}; };
use deployment::Deployment; use deployment::Deployment;
use futures_util::{SinkExt, StreamExt, TryStreamExt};
use serde::Deserialize; use serde::Deserialize;
use services::services::{ use services::services::{
file_search_cache::SearchQuery, project::ProjectServiceError, file_search_cache::SearchQuery, project::ProjectServiceError,
@@ -46,6 +51,48 @@ pub async fn get_projects(
Ok(ResponseJson(ApiResponse::success(projects))) Ok(ResponseJson(ApiResponse::success(projects)))
} }
pub async fn stream_projects_ws(
ws: WebSocketUpgrade,
State(deployment): State<DeploymentImpl>,
) -> 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( pub async fn get_project(
Extension(project): Extension<Project>, Extension(project): Extension<Project>,
) -> Result<ResponseJson<ApiResponse<Project>>, ApiError> { ) -> Result<ResponseJson<ApiResponse<Project>>, ApiError> {
@@ -566,6 +613,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
.put(update_project_repository) .put(update_project_repository)
.delete(delete_project_repository), .delete(delete_project_repository),
) )
.route("/stream/ws", get(stream_projects_ws))
.nest("/{id}", project_id_router); .nest("/{id}", project_id_router);
Router::new().nest("/projects", projects_router).route( Router::new().nest("/projects", projects_router).route(

View File

@@ -3,7 +3,7 @@ use std::{str::FromStr, sync::Arc};
use db::{ use db::{
DBService, DBService,
models::{ models::{
execution_process::ExecutionProcess, scratch::Scratch, task::Task, execution_process::ExecutionProcess, project::Project, scratch::Scratch, task::Task,
task_attempt::TaskAttempt, task_attempt::TaskAttempt,
}, },
}; };
@@ -20,7 +20,9 @@ mod streams;
#[path = "events/types.rs"] #[path = "events/types.rs"]
pub mod types; 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}; pub use types::{EventError, EventPatch, EventPatchInner, HookTables, RecordTypes};
#[derive(Clone)] #[derive(Clone)]
@@ -107,6 +109,14 @@ impl EventService {
msg_store_for_preupdate.push_patch(patch); msg_store_for_preupdate.push_patch(patch);
} }
} }
"projects" => {
if let Ok(value) = preupdate.get_old_column_value(0)
&& let Ok(project_id) = <Uuid as Decode<Sqlite>>::decode(value)
{
let patch = project_patch::remove(project_id);
msg_store_for_preupdate.push_patch(patch);
}
}
"task_attempts" => { "task_attempts" => {
if let Ok(value) = preupdate.get_old_column_value(0) if let Ok(value) = preupdate.get_old_column_value(0)
&& let Ok(attempt_id) = <Uuid as Decode<Sqlite>>::decode(value) && let Ok(attempt_id) = <Uuid as Decode<Sqlite>>::decode(value)
@@ -151,6 +161,7 @@ impl EventService {
runtime_handle.spawn(async move { runtime_handle.spawn(async move {
let record_type: RecordTypes = match (table, hook.operation.clone()) { let record_type: RecordTypes = match (table, hook.operation.clone()) {
(HookTables::Tasks, SqliteOperation::Delete) (HookTables::Tasks, SqliteOperation::Delete)
| (HookTables::Projects, SqliteOperation::Delete)
| (HookTables::TaskAttempts, SqliteOperation::Delete) | (HookTables::TaskAttempts, SqliteOperation::Delete)
| (HookTables::ExecutionProcesses, SqliteOperation::Delete) | (HookTables::ExecutionProcesses, SqliteOperation::Delete)
| (HookTables::Scratch, 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, _) => { (HookTables::TaskAttempts, _) => {
match TaskAttempt::find_by_rowid(&db.pool, rowid).await { match TaskAttempt::find_by_rowid(&db.pool, rowid).await {
Ok(Some(attempt)) => RecordTypes::TaskAttempt(attempt), Ok(Some(attempt)) => RecordTypes::TaskAttempt(attempt),
@@ -261,6 +285,15 @@ impl EventService {
msg_store_for_hook.push_patch(patch); msg_store_for_hook.push_patch(patch);
return; 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) => { RecordTypes::Scratch(scratch) => {
let patch = match hook.operation { let patch = match hook.operation {
SqliteOperation::Insert => scratch_patch::add(scratch), SqliteOperation::Insert => scratch_patch::add(scratch),

View File

@@ -1,6 +1,6 @@
use db::models::{ use db::models::{
execution_process::ExecutionProcess, scratch::Scratch, task::TaskWithAttemptStatus, execution_process::ExecutionProcess, project::Project, scratch::Scratch,
task_attempt::TaskAttempt, task::TaskWithAttemptStatus, task_attempt::TaskAttempt,
}; };
use json_patch::{AddOperation, Patch, PatchOperation, RemoveOperation, ReplaceOperation}; use json_patch::{AddOperation, Patch, PatchOperation, RemoveOperation, ReplaceOperation};
use uuid::Uuid; 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 /// Helper functions for creating execution process-specific patches
pub mod execution_process_patch { pub mod execution_process_patch {
use super::*; use super::*;

View File

@@ -1,11 +1,12 @@
use db::models::{ use db::models::{
execution_process::ExecutionProcess, execution_process::ExecutionProcess,
project::Project,
scratch::Scratch, scratch::Scratch,
task::{Task, TaskWithAttemptStatus}, task::{Task, TaskWithAttemptStatus},
}; };
use futures::StreamExt; use futures::StreamExt;
use serde_json::json; use serde_json::json;
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError};
use utils::log_msg::LogMsg; use utils::log_msg::LogMsg;
use uuid::Uuid; use uuid::Uuid;
@@ -145,6 +146,85 @@ impl EventService {
Ok(combined_stream) Ok(combined_stream)
} }
/// Stream raw project messages with initial snapshot
pub async fn stream_projects_raw(
&self,
) -> Result<futures::stream::BoxStream<'static, Result<LogMsg, std::io::Error>>, EventError>
{
fn build_projects_snapshot(projects: Vec<Project>) -> LogMsg {
// Convert projects array to object keyed by project ID
let projects_map: serde_json::Map<String, serde_json::Value> = 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) /// 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( pub async fn stream_execution_processes_for_attempt_raw(
&self, &self,

View File

@@ -1,6 +1,7 @@
use anyhow::Error as AnyhowError; use anyhow::Error as AnyhowError;
use db::models::{ 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 serde::{Deserialize, Serialize};
use sqlx::Error as SqlxError; use sqlx::Error as SqlxError;
@@ -29,6 +30,8 @@ pub enum HookTables {
ExecutionProcesses, ExecutionProcesses,
#[strum(to_string = "scratch")] #[strum(to_string = "scratch")]
Scratch, Scratch,
#[strum(to_string = "projects")]
Projects,
} }
#[derive(Serialize, Deserialize, TS)] #[derive(Serialize, Deserialize, TS)]
@@ -38,6 +41,7 @@ pub enum RecordTypes {
TaskAttempt(TaskAttempt), TaskAttempt(TaskAttempt),
ExecutionProcess(ExecutionProcess), ExecutionProcess(ExecutionProcess),
Scratch(Scratch), Scratch(Scratch),
Project(Project),
DeletedTask { DeletedTask {
rowid: i64, rowid: i64,
project_id: Option<Uuid>, project_id: Option<Uuid>,
@@ -57,6 +61,10 @@ pub enum RecordTypes {
scratch_id: Option<Uuid>, scratch_id: Option<Uuid>,
scratch_type: Option<String>, scratch_type: Option<String>,
}, },
DeletedProject {
rowid: i64,
project_id: Option<Uuid>,
},
} }
#[derive(Serialize, Deserialize, TS)] #[derive(Serialize, Deserialize, TS)]

View File

@@ -106,22 +106,17 @@ impl ProjectService {
let id = Uuid::new_v4(); let id = Uuid::new_v4();
// Start transaction let project = Project::create(pool, &payload, id)
let mut tx = pool.begin().await?;
let project = Project::create(&mut *tx, &payload, id)
.await .await
.map_err(|e| ProjectServiceError::Project(ProjectError::CreateFailed(e.to_string())))?; .map_err(|e| ProjectServiceError::Project(ProjectError::CreateFailed(e.to_string())))?;
for repo in normalized_repos { for repo in normalized_repos {
let repo_entity = 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?; .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) Ok(project)
} }

View File

@@ -33,18 +33,11 @@ import { useProjectMutations } from '@/hooks/useProjectMutations';
type Props = { type Props = {
project: Project; project: Project;
isFocused: boolean; isFocused: boolean;
fetchProjects: () => void;
setError: (error: string) => void; setError: (error: string) => void;
onEdit: (project: Project) => void; onEdit: (project: Project) => void;
}; };
function ProjectCard({ function ProjectCard({ project, isFocused, setError, onEdit }: Props) {
project,
isFocused,
fetchProjects,
setError,
onEdit,
}: Props) {
const navigate = useNavigateWithSearch(); const navigate = useNavigateWithSearch();
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const handleOpenInEditor = useOpenProjectInEditor(project); const handleOpenInEditor = useOpenProjectInEditor(project);
@@ -54,9 +47,6 @@ function ProjectCard({
const isSingleRepoProject = repos?.length === 1; const isSingleRepoProject = repos?.length === 1;
const { unlinkProject } = useProjectMutations({ const { unlinkProject } = useProjectMutations({
onUnlinkSuccess: () => {
fetchProjects();
},
onUnlinkError: (error) => { onUnlinkError: (error) => {
console.error('Failed to unlink project:', error); console.error('Failed to unlink project:', error);
setError('Failed to unlink project'); setError('Failed to unlink project');
@@ -80,7 +70,6 @@ function ProjectCard({
try { try {
await projectsApi.delete(id); await projectsApi.delete(id);
fetchProjects();
} catch (error) { } catch (error) {
console.error('Failed to delete project:', error); console.error('Failed to delete project:', error);
setError('Failed to delete project'); setError('Failed to delete project');

View File

@@ -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 { Button } from '@/components/ui/button';
import { useNavigateWithSearch } from '@/hooks'; import { useNavigateWithSearch } from '@/hooks';
import { import {
@@ -10,8 +11,8 @@ import {
} from '@/components/ui/card'; } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Project } from 'shared/types';
import { projectsApi } from '@/lib/api'; import { projectsApi } from '@/lib/api';
import { useProjects } from '@/hooks/useProjects';
import { import {
AlertCircle, AlertCircle,
ArrowLeft, ArrowLeft,
@@ -29,26 +30,12 @@ interface ProjectDetailProps {
} }
export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) { export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const { t } = useTranslation('projects');
const navigate = useNavigateWithSearch(); const navigate = useNavigateWithSearch();
const [project, setProject] = useState<Project | null>(null); const { projectsById, isLoading, error: projectsError } = useProjects();
const [loading, setLoading] = useState(false); const [deleteError, setDeleteError] = useState('');
const [error, setError] = useState('');
const fetchProject = useCallback(async () => { const project = projectsById[projectId] || null;
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 handleDelete = async () => { const handleDelete = async () => {
if (!project) return; if (!project) return;
@@ -65,7 +52,8 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
} catch (error) { } catch (error) {
console.error('Failed to delete project:', error); console.error('Failed to delete project:', error);
// @ts-expect-error it is type ApiError // @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}`); navigate(`/settings/projects?projectId=${projectId}`);
}; };
useEffect(() => { if (isLoading) {
fetchProject();
}, [fetchProject]);
if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -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 ( return (
<div className="space-y-4 py-12 px-4"> <div className="space-y-4 py-12 px-4">
<Button variant="outline" onClick={onBack}> <Button variant="outline" onClick={onBack}>
@@ -99,10 +86,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<AlertCircle className="h-6 w-6 text-muted-foreground" /> <AlertCircle className="h-6 w-6 text-muted-foreground" />
</div> </div>
<h3 className="mt-4 text-lg font-semibold">Project not found</h3> <h3 className="mt-4 text-lg font-semibold">Project not found</h3>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">{errorMsg}</p>
{error ||
"The project you're looking for doesn't exist or has been deleted."}
</p>
<Button className="mt-4" onClick={onBack}> <Button className="mt-4" onClick={onBack}>
Back to Projects Back to Projects
</Button> </Button>
@@ -149,10 +133,10 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
</div> </div>
</div> </div>
{error && ( {deleteError && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription> <AlertDescription>{deleteError}</AlertDescription>
</Alert> </Alert>
)} )}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -7,40 +7,22 @@ import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Project } from 'shared/types'; import { Project } from 'shared/types';
import { ProjectFormDialog } from '@/components/dialogs/projects/ProjectFormDialog'; import { ProjectFormDialog } from '@/components/dialogs/projects/ProjectFormDialog';
import { projectsApi } from '@/lib/api';
import { AlertCircle, Loader2, Plus } from 'lucide-react'; import { AlertCircle, Loader2, Plus } from 'lucide-react';
import ProjectCard from '@/components/projects/ProjectCard.tsx'; import ProjectCard from '@/components/projects/ProjectCard.tsx';
import { useKeyCreate, Scope } from '@/keyboard'; import { useKeyCreate, Scope } from '@/keyboard';
import { useProjects } from '@/hooks/useProjects';
export function ProjectList() { export function ProjectList() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('projects'); const { t } = useTranslation('projects');
const [projects, setProjects] = useState<Project[]>([]); const { projects, isLoading, error: projectsError } = useProjects();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [focusedProjectId, setFocusedProjectId] = useState<string | null>(null); const [focusedProjectId, setFocusedProjectId] = useState<string | null>(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 () => { const handleCreateProject = async () => {
try { try {
const result = await ProjectFormDialog.show({}); const result = await ProjectFormDialog.show({});
if (result === 'saved') { if (result === 'saved') return;
fetchProjects();
}
} catch (error) { } catch (error) {
// User cancelled - do nothing // User cancelled - do nothing
} }
@@ -55,15 +37,16 @@ export function ProjectList() {
// Set initial focus when projects are loaded // Set initial focus when projects are loaded
useEffect(() => { 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); setFocusedProjectId(projects[0].id);
} }
}, [projects, focusedProjectId]); }, [projects, focusedProjectId]);
useEffect(() => {
fetchProjects();
}, [fetchProjects]);
return ( return (
<div className="space-y-6 p-8 pb-16 md:pb-8 h-full overflow-auto"> <div className="space-y-6 p-8 pb-16 md:pb-8 h-full overflow-auto">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@@ -77,14 +60,16 @@ export function ProjectList() {
</Button> </Button>
</div> </div>
{error && ( {(error || projectsError) && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription> <AlertDescription>
{error || projectsError?.message || t('errors.fetchFailed')}
</AlertDescription>
</Alert> </Alert>
)} )}
{loading ? ( {isLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('loading')} {t('loading')}
@@ -114,7 +99,6 @@ export function ProjectList() {
isFocused={focusedProjectId === project.id} isFocused={focusedProjectId === project.id}
setError={setError} setError={setError}
onEdit={handleEditProject} onEdit={handleEditProject}
fetchProjects={fetchProjects}
/> />
))} ))}
</div> </div>

View File

@@ -6,9 +6,8 @@ import {
useEffect, useEffect,
} from 'react'; } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { projectsApi } from '@/lib/api';
import type { Project } from 'shared/types'; import type { Project } from 'shared/types';
import { useProjects } from '@/hooks/useProjects';
interface ProjectContextValue { interface ProjectContextValue {
projectId: string | undefined; projectId: string | undefined;
@@ -33,32 +32,28 @@ export function ProjectProvider({ children }: ProjectProviderProps) {
return match ? match[1] : undefined; return match ? match[1] : undefined;
}, [location.pathname]); }, [location.pathname]);
const query = useQuery({ const { projectsById, isLoading, error } = useProjects();
queryKey: ['project', projectId], const project = projectId ? projectsById[projectId] : undefined;
queryFn: () => projectsApi.getById(projectId!),
enabled: !!projectId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
const value = useMemo( const value = useMemo(
() => ({ () => ({
projectId, projectId,
project: query.data, project,
isLoading: query.isLoading, isLoading,
error: query.error, error,
isError: query.isError, isError: !!error,
}), }),
[projectId, query.data, query.isLoading, query.error, query.isError] [projectId, project, isLoading, error]
); );
// Centralized page title management // Centralized page title management
useEffect(() => { useEffect(() => {
if (query.data) { if (project) {
document.title = `${query.data.name} | vibe-kanban`; document.title = `${project.name} | vibe-kanban`;
} else { } else {
document.title = 'vibe-kanban'; document.title = 'vibe-kanban';
} }
}, [query.data]); }, [project]);
return ( return (
<ProjectContext.Provider value={value}>{children}</ProjectContext.Provider> <ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>

View File

@@ -1,11 +1,48 @@
import { useQuery } from '@tanstack/react-query'; import { useCallback, useMemo } from 'react';
import { projectsApi } from '@/lib/api'; import { useJsonPatchWsStream } from './useJsonPatchWsStream';
import type { Project } from 'shared/types'; import type { Project } from 'shared/types';
export function useProjects() { type ProjectsState = {
return useQuery<Project[]>({ projects: Record<string, Project>;
queryKey: ['projects'], };
queryFn: () => projectsApi.getAll(),
staleTime: 30000, // Consider data fresh for 30 seconds export interface UseProjectsResult {
}); projects: Project[];
projectsById: Record<string, Project>;
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<ProjectsState>(
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,
};
} }

View File

@@ -5,7 +5,8 @@
"linkToOrganization": "Link to Remote Project", "linkToOrganization": "Link to Remote Project",
"loading": "Loading projects...", "loading": "Loading projects...",
"errors": { "errors": {
"fetchFailed": "Failed to fetch projects" "fetchFailed": "Failed to fetch projects",
"deleteFailed": "Failed to delete project"
}, },
"empty": { "empty": {
"title": "No projects yet", "title": "No projects yet",
@@ -44,6 +45,7 @@
} }
}, },
"unlinkFromOrganization": "Unlink from Remote Project", "unlinkFromOrganization": "Unlink from Remote Project",
"projectNotFound": "The project you're looking for doesn't exist or has been deleted.",
"viewProject": "View Project", "viewProject": "View Project",
"openInIDE": "Open in IDE", "openInIDE": "Open in IDE",
"createdDate": "Created {{date}}", "createdDate": "Created {{date}}",

View File

@@ -5,7 +5,8 @@
"linkToOrganization": "Vincular a Proyecto Remoto", "linkToOrganization": "Vincular a Proyecto Remoto",
"loading": "Cargando proyectos...", "loading": "Cargando proyectos...",
"errors": { "errors": {
"fetchFailed": "Error al cargar proyectos" "fetchFailed": "Error al cargar proyectos",
"deleteFailed": "Error al eliminar el proyecto"
}, },
"empty": { "empty": {
"title": "Aún no hay proyectos", "title": "Aún no hay proyectos",
@@ -44,6 +45,7 @@
} }
}, },
"unlinkFromOrganization": "Desvincular de Proyecto Remoto", "unlinkFromOrganization": "Desvincular de Proyecto Remoto",
"projectNotFound": "El proyecto que buscas no existe o ha sido eliminado.",
"viewProject": "Ver Proyecto", "viewProject": "Ver Proyecto",
"openInIDE": "Abrir en IDE", "openInIDE": "Abrir en IDE",
"createdDate": "Creado {{date}}", "createdDate": "Creado {{date}}",

View File

@@ -5,7 +5,8 @@
"linkToOrganization": "リモートプロジェクトにリンク", "linkToOrganization": "リモートプロジェクトにリンク",
"loading": "プロジェクトを読み込み中...", "loading": "プロジェクトを読み込み中...",
"errors": { "errors": {
"fetchFailed": "プロジェクトの取得に失敗しました" "fetchFailed": "プロジェクトの取得に失敗しました",
"deleteFailed": "プロジェクトの削除に失敗しました"
}, },
"empty": { "empty": {
"title": "プロジェクトがありません", "title": "プロジェクトがありません",
@@ -44,6 +45,7 @@
} }
}, },
"unlinkFromOrganization": "リモートプロジェクトからリンク解除", "unlinkFromOrganization": "リモートプロジェクトからリンク解除",
"projectNotFound": "お探しのプロジェクトは存在しないか、削除されました。",
"viewProject": "プロジェクトを表示", "viewProject": "プロジェクトを表示",
"openInIDE": "IDEで開く", "openInIDE": "IDEで開く",
"createdDate": "作成日 {{date}}", "createdDate": "作成日 {{date}}",

View File

@@ -5,7 +5,8 @@
"linkToOrganization": "원격 프로젝트에 연결", "linkToOrganization": "원격 프로젝트에 연결",
"loading": "프로젝트 로딩 중...", "loading": "프로젝트 로딩 중...",
"errors": { "errors": {
"fetchFailed": "프로젝트를 불러오지 못했습니다" "fetchFailed": "프로젝트를 불러오지 못했습니다",
"deleteFailed": "프로젝트 삭제에 실패했습니다"
}, },
"empty": { "empty": {
"title": "아직 프로젝트가 없습니다", "title": "아직 프로젝트가 없습니다",
@@ -44,6 +45,7 @@
} }
}, },
"unlinkFromOrganization": "원격 프로젝트에서 연결 해제", "unlinkFromOrganization": "원격 프로젝트에서 연결 해제",
"projectNotFound": "찾으시는 프로젝트가 존재하지 않거나 삭제되었습니다.",
"viewProject": "프로젝트 보기", "viewProject": "프로젝트 보기",
"openInIDE": "IDE에서 열기", "openInIDE": "IDE에서 열기",
"createdDate": "생성일 {{date}}", "createdDate": "생성일 {{date}}",

View File

@@ -5,7 +5,8 @@
"linkToOrganization": "链接到远程项目", "linkToOrganization": "链接到远程项目",
"loading": "加载项目中...", "loading": "加载项目中...",
"errors": { "errors": {
"fetchFailed": "获取项目失败" "fetchFailed": "获取项目失败",
"deleteFailed": "删除项目失败"
}, },
"empty": { "empty": {
"title": "还没有项目", "title": "还没有项目",
@@ -44,6 +45,7 @@
} }
}, },
"unlinkFromOrganization": "取消链接远程项目", "unlinkFromOrganization": "取消链接远程项目",
"projectNotFound": "您查找的项目不存在或已被删除。",
"viewProject": "查看项目", "viewProject": "查看项目",
"openInIDE": "在 IDE 中打开", "openInIDE": "在 IDE 中打开",
"createdDate": "创建于 {{date}}", "createdDate": "创建于 {{date}}",

View File

@@ -233,16 +233,6 @@ export const handleApiResponse = async <T, E = T>(
// Project Management APIs // Project Management APIs
export const projectsApi = { export const projectsApi = {
getAll: async (): Promise<Project[]> => {
const response = await makeRequest('/api/projects');
return handleApiResponse<Project[]>(response);
},
getById: async (id: string): Promise<Project> => {
const response = await makeRequest(`/api/projects/${id}`);
return handleApiResponse<Project>(response);
},
create: async (data: CreateProject): Promise<Project> => { create: async (data: CreateProject): Promise<Project> => {
const response = await makeRequest('/api/projects', { const response = await makeRequest('/api/projects', {
method: 'POST', method: 'POST',

View File

@@ -137,7 +137,8 @@ export function OrganizationSettings() {
}); });
// Fetch all local projects // Fetch all local projects
const { data: allProjects = [], isLoading: loadingProjects } = useProjects(); const { projects: allProjects = [], isLoading: loadingProjects } =
useProjects();
// Fetch remote projects for the selected organization // Fetch remote projects for the selected organization
const { data: remoteProjects = [], isLoading: loadingRemoteProjects } = const { data: remoteProjects = [], isLoading: loadingRemoteProjects } =

View File

@@ -73,7 +73,7 @@ export function ProjectSettings() {
// Fetch all projects // Fetch all projects
const { const {
data: projects, projects,
isLoading: projectsLoading, isLoading: projectsLoading,
error: projectsError, error: projectsError,
} = useProjects(); } = useProjects();