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:
56
crates/db/.sqlx/query-d6218ce758d0ef58edc775de68a28f9be72d02217ef43f0b79494b63380ea9a8.json
generated
Normal file
56
crates/db/.sqlx/query-d6218ce758d0ef58edc775de68a28f9be72d02217ef43f0b79494b63380ea9a8.json
generated
Normal 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"
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 } =
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user