Compare commits
3 Commits
d7145644d9
...
62e41bda66
| Author | SHA1 | Date | |
|---|---|---|---|
| 62e41bda66 | |||
| e916ecd95e | |||
| 137620daa3 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -43,6 +43,9 @@ frontend/coverage/
|
|||||||
# Volatile agent live-state snapshot ("who is doing what right now", lot LS2):
|
# Volatile agent live-state snapshot ("who is doing what right now", lot LS2):
|
||||||
# rebuilt at runtime, keyed last-writer-wins — not versioned (unlike .ideai/memory/).
|
# rebuilt at runtime, keyed last-writer-wins — not versioned (unlike .ideai/memory/).
|
||||||
.ideai/live-state.json
|
.ideai/live-state.json
|
||||||
|
# UI layout runtime state (active layout id + session ids) — machine-local,
|
||||||
|
# rebuilt at runtime, last-writer-wins — not versioned (same class as live-state.json).
|
||||||
|
/.ideai/layouts.json
|
||||||
|
|
||||||
# ─── Editors / OS ───────────────────────────────────────────────────────────
|
# ─── Editors / OS ───────────────────────────────────────────────────────────
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"activeId": "49c6b0fc-13c1-40f5-9464-d3248ab54597",
|
|
||||||
"layouts": [
|
|
||||||
{
|
|
||||||
"id": "49c6b0fc-13c1-40f5-9464-d3248ab54597",
|
|
||||||
"name": "Default",
|
|
||||||
"kind": "terminal",
|
|
||||||
"tree": {
|
|
||||||
"root": {
|
|
||||||
"type": "leaf",
|
|
||||||
"node": {
|
|
||||||
"id": "852f5128-5b98-4d36-9a18-e49a57fc4940"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -19,12 +19,11 @@ use application::{
|
|||||||
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
|
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
|
||||||
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
|
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
|
||||||
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
||||||
ReconcileLiveStateInput,
|
ReconcileLiveStateInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
||||||
RenameLayoutInput, ResolveAgentPermissionsInput, ResolveMemoryLinksInput,
|
ResolveMemoryLinksInput, RotateConversationLogInput, SetActiveLayoutInput,
|
||||||
RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
|
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
||||||
StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
|
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
|
||||||
UpdateAgentContextInput, UpdateAgentPermissionsInput, UpdateMemoryInput,
|
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
||||||
UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
|
||||||
};
|
};
|
||||||
use domain::ports::PtyHandle;
|
use domain::ports::PtyHandle;
|
||||||
|
|
||||||
@ -49,13 +48,13 @@ use crate::dto::{
|
|||||||
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
|
||||||
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||||
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||||
SaveProfileRequestDto, SetActiveLayoutRequestDto, SkillDto, SkillListDto,
|
SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto,
|
||||||
StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto,
|
SkillListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
||||||
SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto,
|
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||||
TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||||
UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
|
UpdateAgentContextRequestDto, UpdateAgentPermissionsRequestDto, UpdateMemoryRequestDto,
|
||||||
UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
|
UpdateProjectContextRequestDto, UpdateProjectPermissionsRequestDto, UpdateSkillRequestDto,
|
||||||
WriteTerminalRequestDto,
|
UpdateTemplateRequestDto, WriteTerminalRequestDto,
|
||||||
};
|
};
|
||||||
use crate::pty::{PtyBridge, PtyChunk};
|
use crate::pty::{PtyBridge, PtyChunk};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@ -678,14 +677,16 @@ pub async fn delete_layout(
|
|||||||
|
|
||||||
/// `set_active_layout` — switch the active named layout of a project.
|
/// `set_active_layout` — switch the active named layout of a project.
|
||||||
///
|
///
|
||||||
|
/// Self-healing: a stale `layout_id` does not error; the active layout is left
|
||||||
|
/// unchanged and the actually-active id is returned (authoritative).
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Returns an [`ErrorDto`] (`INVALID` for malformed id, `NOT_FOUND` if the
|
/// Returns an [`ErrorDto`] (`INVALID` for malformed id) or a persistence error.
|
||||||
/// project or layout is unknown).
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_active_layout(
|
pub async fn set_active_layout(
|
||||||
request: SetActiveLayoutRequestDto,
|
request: SetActiveLayoutRequestDto,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), ErrorDto> {
|
) -> Result<SetActiveLayoutResultDto, ErrorDto> {
|
||||||
let project_id = parse_project_id(&request.project_id)?;
|
let project_id = parse_project_id(&request.project_id)?;
|
||||||
let layout_id = parse_layout_id(&request.layout_id)?;
|
let layout_id = parse_layout_id(&request.layout_id)?;
|
||||||
state
|
state
|
||||||
@ -695,6 +696,7 @@ pub async fn set_active_layout(
|
|||||||
layout_id,
|
layout_id,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
.map(SetActiveLayoutResultDto::from)
|
||||||
.map_err(ErrorDto::from)
|
.map_err(ErrorDto::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -390,7 +390,7 @@ pub fn parse_session_id(raw: &str) -> Result<SessionId, ErrorDto> {
|
|||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput,
|
CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput,
|
||||||
LoadLayoutOutput, MutateLayoutOutput,
|
LoadLayoutOutput, MutateLayoutOutput, SetActiveLayoutOutput,
|
||||||
};
|
};
|
||||||
use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId};
|
use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId};
|
||||||
|
|
||||||
@ -700,6 +700,23 @@ pub struct DeleteLayoutRequestDto {
|
|||||||
pub layout_id: String,
|
pub layout_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Response DTO for `set_active_layout`.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SetActiveLayoutResultDto {
|
||||||
|
/// The layout actually activated — equals the requested id when valid, else
|
||||||
|
/// the unchanged current active id (self-healing fallback). Authoritative.
|
||||||
|
pub active_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SetActiveLayoutOutput> for SetActiveLayoutResultDto {
|
||||||
|
fn from(out: SetActiveLayoutOutput) -> Self {
|
||||||
|
Self {
|
||||||
|
active_id: out.active_id.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Request DTO for `set_active_layout`.
|
/// Request DTO for `set_active_layout`.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -297,6 +297,15 @@ pub struct SetActiveLayoutInput {
|
|||||||
pub layout_id: LayoutId,
|
pub layout_id: LayoutId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Output of [`SetActiveLayout::execute`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SetActiveLayoutOutput {
|
||||||
|
/// The id of the layout that was **actually** made active. Equals the
|
||||||
|
/// requested id when it exists; otherwise the unchanged current active id
|
||||||
|
/// (self-healing fallback). Authoritative for the frontend (invariant I4).
|
||||||
|
pub active_id: LayoutId,
|
||||||
|
}
|
||||||
|
|
||||||
/// Switches the active layout of a project.
|
/// Switches the active layout of a project.
|
||||||
pub struct SetActiveLayout {
|
pub struct SetActiveLayout {
|
||||||
store: Arc<dyn ProjectStore>,
|
store: Arc<dyn ProjectStore>,
|
||||||
@ -317,20 +326,30 @@ impl SetActiveLayout {
|
|||||||
|
|
||||||
/// Sets the active layout.
|
/// Sets the active layout.
|
||||||
///
|
///
|
||||||
|
/// A stale requested id (e.g. an `activeId` left over after git overwrote
|
||||||
|
/// `layouts.json`) must **not** freeze the workspace: instead of hard-
|
||||||
|
/// erroring, it degrades silently to the current active layout (invariant
|
||||||
|
/// I3). The returned [`SetActiveLayoutOutput::active_id`] is the id that was
|
||||||
|
/// actually activated and is authoritative for the frontend (invariant I4).
|
||||||
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`AppError::NotFound`] if the project or layout is unknown.
|
/// - [`AppError::FileSystem`] on persistence failure,
|
||||||
pub async fn execute(&self, input: SetActiveLayoutInput) -> Result<(), AppError> {
|
/// - [`AppError::Store`] on registry I/O failure.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
input: SetActiveLayoutInput,
|
||||||
|
) -> Result<SetActiveLayoutOutput, AppError> {
|
||||||
let project = self.store.load_project(input.project_id).await?;
|
let project = self.store.load_project(input.project_id).await?;
|
||||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||||
if doc.find(input.layout_id).is_none() {
|
// Self-heal: keep the requested id when valid, else fall back to the
|
||||||
return Err(AppError::NotFound(format!("layout {}", input.layout_id)));
|
// (always-valid, I2) current active id rather than erroring.
|
||||||
}
|
let active_id = doc.resolve_existing_id(Some(input.layout_id));
|
||||||
doc.active_id = input.layout_id;
|
doc.active_id = active_id;
|
||||||
|
|
||||||
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
persist_doc(self.fs.as_ref(), &project, &doc).await?;
|
||||||
self.events.publish(DomainEvent::LayoutChanged {
|
self.events.publish(DomainEvent::LayoutChanged {
|
||||||
project_id: input.project_id,
|
project_id: input.project_id,
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(SetActiveLayoutOutput { active_id })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,7 @@ mod usecases;
|
|||||||
pub use management::{
|
pub use management::{
|
||||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||||
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
|
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
|
||||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
|
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput,
|
||||||
};
|
};
|
||||||
pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOutput};
|
pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOutput};
|
||||||
pub use snapshot::{
|
pub use snapshot::{
|
||||||
|
|||||||
@ -77,6 +77,22 @@ impl LayoutsDoc {
|
|||||||
id.unwrap_or(self.active_id)
|
id.unwrap_or(self.active_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolves a requested view id to a **valid** layout id, never failing.
|
||||||
|
///
|
||||||
|
/// Returns `id` when it is present in `layouts`; otherwise falls back to
|
||||||
|
/// `active_id` (guaranteed valid by invariant I2 / [`Self::is_valid`]).
|
||||||
|
/// A `None` request (the current view) also resolves to `active_id`. This
|
||||||
|
/// is the self-healing path: a stale `active_id` written by an external tool
|
||||||
|
/// (e.g. git overwriting `layouts.json`) degrades silently instead of
|
||||||
|
/// hard-erroring and freezing the workspace (invariant I3).
|
||||||
|
#[must_use]
|
||||||
|
pub fn resolve_existing_id(&self, id: Option<LayoutId>) -> LayoutId {
|
||||||
|
match id {
|
||||||
|
Some(id) if self.find(id).is_some() => id,
|
||||||
|
_ => self.active_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Finds a layout by id.
|
/// Finds a layout by id.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn find(&self, id: LayoutId) -> Option<&NamedLayout> {
|
pub fn find(&self, id: LayoutId) -> Option<&NamedLayout> {
|
||||||
@ -177,3 +193,55 @@ pub async fn resolve_doc(fs: &dyn FileSystem, project: &Project) -> Result<Layou
|
|||||||
persist_doc(fs, project, &doc).await?;
|
persist_doc(fs, project, &doc).await?;
|
||||||
Ok(doc)
|
Ok(doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Builds a doc with two layouts; the first one is active.
|
||||||
|
fn two_layout_doc() -> (LayoutsDoc, LayoutId, LayoutId) {
|
||||||
|
let active = LayoutId::new_random();
|
||||||
|
let other = LayoutId::new_random();
|
||||||
|
let doc = LayoutsDoc {
|
||||||
|
version: LAYOUTS_VERSION,
|
||||||
|
active_id: active,
|
||||||
|
layouts: vec![
|
||||||
|
NamedLayout {
|
||||||
|
id: active,
|
||||||
|
name: "Default".to_owned(),
|
||||||
|
kind: LayoutKind::Terminal,
|
||||||
|
tree: default_tree(),
|
||||||
|
},
|
||||||
|
NamedLayout {
|
||||||
|
id: other,
|
||||||
|
name: "Second".to_owned(),
|
||||||
|
kind: LayoutKind::Terminal,
|
||||||
|
tree: default_tree(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
(doc, active, other)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_existing_id_returns_present_id() {
|
||||||
|
let (doc, _active, other) = two_layout_doc();
|
||||||
|
// A present, non-active id is returned verbatim.
|
||||||
|
assert_eq!(doc.resolve_existing_id(Some(other)), other);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_existing_id_falls_back_to_active_when_absent() {
|
||||||
|
let (doc, active, _other) = two_layout_doc();
|
||||||
|
// A stale id absent from `layouts` degrades to the valid active id.
|
||||||
|
let stale = LayoutId::new_random();
|
||||||
|
assert_eq!(doc.resolve_existing_id(Some(stale)), active);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_existing_id_none_resolves_to_active() {
|
||||||
|
let (doc, active, _other) = two_layout_doc();
|
||||||
|
// The "current view" request resolves to the active id.
|
||||||
|
assert_eq!(doc.resolve_existing_id(None), active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -169,7 +169,10 @@ impl LoadLayout {
|
|||||||
pub async fn execute(&self, input: LoadLayoutInput) -> Result<LoadLayoutOutput, AppError> {
|
pub async fn execute(&self, input: LoadLayoutInput) -> Result<LoadLayoutOutput, AppError> {
|
||||||
let project = self.store.load_project(input.project_id).await?;
|
let project = self.store.load_project(input.project_id).await?;
|
||||||
let doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
let doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||||
let id = doc.resolve_id(input.layout_id);
|
// Self-healing (I3): a stale requested id degrades to the valid active
|
||||||
|
// id instead of hard-erroring. `find` is now infallible in practice;
|
||||||
|
// the `ok_or` is kept as a purely defensive guard.
|
||||||
|
let id = doc.resolve_existing_id(input.layout_id);
|
||||||
let named = doc
|
let named = doc
|
||||||
.find(id)
|
.find(id)
|
||||||
.ok_or_else(|| AppError::NotFound(format!("layout {id}")))?;
|
.ok_or_else(|| AppError::NotFound(format!("layout {id}")))?;
|
||||||
@ -229,7 +232,10 @@ impl MutateLayout {
|
|||||||
pub async fn execute(&self, input: MutateLayoutInput) -> Result<MutateLayoutOutput, AppError> {
|
pub async fn execute(&self, input: MutateLayoutInput) -> Result<MutateLayoutOutput, AppError> {
|
||||||
let project = self.store.load_project(input.project_id).await?;
|
let project = self.store.load_project(input.project_id).await?;
|
||||||
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
|
||||||
let id = doc.resolve_id(input.layout_id);
|
// Self-healing (I3): a stale requested id degrades to the valid active
|
||||||
|
// id instead of hard-erroring. `find_mut` is now infallible in
|
||||||
|
// practice; the `ok_or` is kept as a purely defensive guard.
|
||||||
|
let id = doc.resolve_existing_id(input.layout_id);
|
||||||
|
|
||||||
let named = doc
|
let named = doc
|
||||||
.find_mut(id)
|
.find_mut(id)
|
||||||
|
|||||||
@ -31,8 +31,7 @@ pub mod window;
|
|||||||
pub mod workstate;
|
pub mod workstate;
|
||||||
|
|
||||||
pub use agent::{
|
pub use agent::{
|
||||||
drain_with_readiness, drain_with_readiness_outcome,
|
drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles,
|
||||||
reference_profile_id, reference_profiles,
|
|
||||||
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
|
selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile,
|
||||||
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
|
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
|
||||||
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
|
||||||
@ -73,8 +72,8 @@ pub use layout::{
|
|||||||
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
|
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
|
||||||
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, ReconcileLayouts,
|
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, ReconcileLayouts,
|
||||||
ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout, RenameLayoutInput,
|
ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout, RenameLayoutInput,
|
||||||
SetActiveLayout, SetActiveLayoutInput, SnapshotRunningAgents, SnapshotRunningAgentsInput,
|
SetActiveLayout, SetActiveLayoutInput, SetActiveLayoutOutput, SnapshotRunningAgents,
|
||||||
SnapshotRunningAgentsOutput, LAYOUTS_FILE,
|
SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
|
||||||
};
|
};
|
||||||
pub use memory::{
|
pub use memory::{
|
||||||
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
|
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
|
||||||
|
|||||||
@ -338,21 +338,24 @@ async fn load_tolerates_corrupt_json_with_default() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn load_unknown_layout_id_is_not_found() {
|
async fn load_unknown_layout_id_self_heals_to_active() {
|
||||||
let store = FakeStore::default();
|
let store = FakeStore::default();
|
||||||
let fs = FakeFs::default();
|
let fs = FakeFs::default();
|
||||||
let id = register_project(&store, pid(5)).await;
|
let id = register_project(&store, pid(5)).await;
|
||||||
seed_layouts(&fs, lid(1), &single_leaf(nid(1)));
|
seed_layouts(&fs, lid(1), &single_leaf(nid(1)));
|
||||||
|
|
||||||
|
// A stale requested id (e.g. left over after git overwrote layouts.json)
|
||||||
|
// must NOT hard-error and freeze the workspace; it degrades silently to the
|
||||||
|
// valid active layout (invariant I3), whose id is authoritative (I4).
|
||||||
let load = LoadLayout::new(Arc::new(store), Arc::new(fs));
|
let load = LoadLayout::new(Arc::new(store), Arc::new(fs));
|
||||||
let err = load
|
let out = load
|
||||||
.execute(LoadLayoutInput {
|
.execute(LoadLayoutInput {
|
||||||
project_id: id,
|
project_id: id,
|
||||||
layout_id: Some(lid(999)),
|
layout_id: Some(lid(999)),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect_err("unknown layout id rejected");
|
.expect("stale layout id must self-heal, not error");
|
||||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
assert_eq!(out.layout_id, lid(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -937,13 +940,15 @@ async fn set_active_layout_switches_and_load_follows() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Switch back to the Default layout.
|
// Switch back to the Default layout.
|
||||||
SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
|
let set = SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
|
||||||
.execute(SetActiveLayoutInput {
|
.execute(SetActiveLayoutInput {
|
||||||
project_id: pid(35),
|
project_id: pid(35),
|
||||||
layout_id: lid(1),
|
layout_id: lid(1),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
// Output echoes the actually-activated id (I4).
|
||||||
|
assert_eq!(set.active_id, lid(1));
|
||||||
|
|
||||||
let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs))
|
let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs))
|
||||||
.execute(LoadLayoutInput {
|
.execute(LoadLayoutInput {
|
||||||
@ -955,3 +960,31 @@ async fn set_active_layout_switches_and_load_follows() {
|
|||||||
assert_eq!(loaded.layout_id, lid(1));
|
assert_eq!(loaded.layout_id, lid(1));
|
||||||
assert_ne!(loaded.layout_id, created.layout_id);
|
assert_ne!(loaded.layout_id, created.layout_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_active_layout_stale_id_self_heals_to_current_active() {
|
||||||
|
// Seeded env: a single "Default" layout, active = lid(1).
|
||||||
|
let (store, fs, bus) = mgmt_env(pid(36)).await;
|
||||||
|
|
||||||
|
// Targeting a stale id (e.g. an activeId left over after git overwrote
|
||||||
|
// layouts.json) must NOT error and must NOT freeze the workspace (I3):
|
||||||
|
// it degrades to the current active id and returns it (I4).
|
||||||
|
let out = SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
|
||||||
|
.execute(SetActiveLayoutInput {
|
||||||
|
project_id: pid(36),
|
||||||
|
layout_id: lid(999),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("stale set_active must self-heal, not error");
|
||||||
|
assert_eq!(out.active_id, lid(1));
|
||||||
|
|
||||||
|
// And a subsequent load of the current view resolves to that valid id.
|
||||||
|
let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs))
|
||||||
|
.execute(LoadLayoutInput {
|
||||||
|
project_id: pid(36),
|
||||||
|
layout_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(loaded.layout_id, lid(1));
|
||||||
|
}
|
||||||
|
|||||||
@ -48,8 +48,8 @@ export class TauriLayoutGateway implements LayoutGateway {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveLayout(projectId: string, layoutId: string): Promise<void> {
|
setActiveLayout(projectId: string, layoutId: string): Promise<{ activeId: string }> {
|
||||||
return invoke<void>("set_active_layout", {
|
return invoke<{ activeId: string }>("set_active_layout", {
|
||||||
request: { projectId, layoutId },
|
request: { projectId, layoutId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -846,11 +846,17 @@ export class MockLayoutGateway implements LayoutGateway {
|
|||||||
return { activeId: ps.activeId };
|
return { activeId: ps.activeId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async setActiveLayout(projectId: string, layoutId: string): Promise<void> {
|
async setActiveLayout(
|
||||||
|
projectId: string,
|
||||||
|
layoutId: string,
|
||||||
|
): Promise<{ activeId: string }> {
|
||||||
const ps = this.getProjectLayouts(projectId);
|
const ps = this.getProjectLayouts(projectId);
|
||||||
|
// Self-heal (I4): only adopt the requested id when it exists; otherwise keep
|
||||||
|
// the current active id. Either way the returned id is authoritative.
|
||||||
if (ps.layouts.some((l) => l.id === layoutId)) {
|
if (ps.layouts.some((l) => l.id === layoutId)) {
|
||||||
ps.activeId = layoutId;
|
ps.activeId = layoutId;
|
||||||
}
|
}
|
||||||
|
return { activeId: ps.activeId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -117,14 +117,25 @@ describe("MockLayoutGateway", () => {
|
|||||||
expect(activeId).toBe(newActive);
|
expect(activeId).toBe(newActive);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("setActiveLayout switches the active layout", async () => {
|
it("setActiveLayout switches the active layout and returns the authoritative id", async () => {
|
||||||
const gw = new MockLayoutGateway();
|
const gw = new MockLayoutGateway();
|
||||||
const { layoutId } = await gw.createLayout("p1", "Alt");
|
const { layoutId } = await gw.createLayout("p1", "Alt");
|
||||||
await gw.setActiveLayout("p1", layoutId);
|
const result = await gw.setActiveLayout("p1", layoutId);
|
||||||
|
expect(result.activeId).toBe(layoutId);
|
||||||
const { activeId } = await gw.listLayouts("p1");
|
const { activeId } = await gw.listLayouts("p1");
|
||||||
expect(activeId).toBe(layoutId);
|
expect(activeId).toBe(layoutId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("setActiveLayout self-heals: a stale id keeps (and returns) the current active id", async () => {
|
||||||
|
const gw = new MockLayoutGateway();
|
||||||
|
const { activeId: initial } = await gw.listLayouts("p1");
|
||||||
|
const result = await gw.setActiveLayout("p1", "ghost-layout-id");
|
||||||
|
// The requested id does not exist → active id is unchanged and returned.
|
||||||
|
expect(result.activeId).toBe(initial);
|
||||||
|
const { activeId } = await gw.listLayouts("p1");
|
||||||
|
expect(activeId).toBe(initial);
|
||||||
|
});
|
||||||
|
|
||||||
it("loadLayout with a specific layoutId loads that layout's tree", async () => {
|
it("loadLayout with a specific layoutId loads that layout's tree", async () => {
|
||||||
const gw = new MockLayoutGateway();
|
const gw = new MockLayoutGateway();
|
||||||
const { layoutId } = await gw.createLayout("p1", "Named");
|
const { layoutId } = await gw.createLayout("p1", "Named");
|
||||||
|
|||||||
@ -6,7 +6,12 @@ import { describe, it, expect, vi } from "vitest";
|
|||||||
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
|
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
|
||||||
|
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import { MockLayoutGateway, MockAgentGateway, MockTerminalGateway } from "@/adapters/mock";
|
import {
|
||||||
|
MockLayoutGateway,
|
||||||
|
MockAgentGateway,
|
||||||
|
MockTerminalGateway,
|
||||||
|
MockSystemGateway,
|
||||||
|
} from "@/adapters/mock";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { LayoutTabs } from "./LayoutTabs";
|
import { LayoutTabs } from "./LayoutTabs";
|
||||||
|
|
||||||
@ -14,11 +19,13 @@ function renderTabs(
|
|||||||
layout: MockLayoutGateway,
|
layout: MockLayoutGateway,
|
||||||
projectId = "p1",
|
projectId = "p1",
|
||||||
onActiveLayoutChange = vi.fn(),
|
onActiveLayoutChange = vi.fn(),
|
||||||
|
system?: MockSystemGateway,
|
||||||
) {
|
) {
|
||||||
const gateways = {
|
const gateways = {
|
||||||
layout,
|
layout,
|
||||||
terminal: new MockTerminalGateway(),
|
terminal: new MockTerminalGateway(),
|
||||||
agent: new MockAgentGateway(),
|
agent: new MockAgentGateway(),
|
||||||
|
...(system ? { system } : {}),
|
||||||
} as unknown as Gateways;
|
} as unknown as Gateways;
|
||||||
return render(
|
return render(
|
||||||
<DIProvider gateways={gateways}>
|
<DIProvider gateways={gateways}>
|
||||||
@ -133,4 +140,83 @@ describe("LayoutTabs", () => {
|
|||||||
expect(screen.queryByText("Second")).toBeNull();
|
expect(screen.queryByText("Second")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Active-layout self-heal (feature/layouts-active-self-heal) ──────────────
|
||||||
|
|
||||||
|
it("switching tabs adopts the backend's authoritative active id", async () => {
|
||||||
|
const layout = new MockLayoutGateway();
|
||||||
|
const { layoutId: betaId } = await layout.createLayout("p1", "Beta");
|
||||||
|
const onActiveLayoutChange = vi.fn();
|
||||||
|
renderTabs(layout, "p1", onActiveLayoutChange);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Beta")).toBeTruthy());
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText("Beta"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onActiveLayoutChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: betaId, name: "Beta" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on layoutChanged it re-lists and purges a vanished active id (self-heal)", async () => {
|
||||||
|
const layout = new MockLayoutGateway();
|
||||||
|
const system = new MockSystemGateway();
|
||||||
|
const { layoutId: betaId } = await layout.createLayout("p1", "Beta");
|
||||||
|
const onActiveLayoutChange = vi.fn();
|
||||||
|
renderTabs(layout, "p1", onActiveLayoutChange, system);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Beta")).toBeTruthy());
|
||||||
|
|
||||||
|
// Make Beta the active layout from the UI.
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText("Beta"));
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onActiveLayoutChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: betaId }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate an EXTERNAL overwrite of layouts.json: Beta is removed and the
|
||||||
|
// backend active id falls back to Default — done straight on the gateway,
|
||||||
|
// bypassing the hook, so the hook still locally remembers the (now stale) id.
|
||||||
|
const { layouts } = await layout.listLayouts("p1");
|
||||||
|
const defaultId = layouts.find((l) => l.name === "Default")!.id;
|
||||||
|
await layout.deleteLayout("p1", betaId);
|
||||||
|
|
||||||
|
onActiveLayoutChange.mockClear();
|
||||||
|
// The backend notifies the change → the hook re-lists and purges the stale id.
|
||||||
|
await act(async () => {
|
||||||
|
system.emit({ type: "layoutChanged", projectId: "p1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText("Beta")).toBeNull();
|
||||||
|
expect(onActiveLayoutChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: defaultId, name: "Default" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores layoutChanged events for another project", async () => {
|
||||||
|
const layout = new MockLayoutGateway();
|
||||||
|
const system = new MockSystemGateway();
|
||||||
|
await layout.createLayout("p1", "Beta");
|
||||||
|
renderTabs(layout, "p1", vi.fn(), system);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Beta")).toBeTruthy());
|
||||||
|
|
||||||
|
// An event for a different project must not trigger a re-list/purge here.
|
||||||
|
await act(async () => {
|
||||||
|
system.emit({ type: "layoutChanged", projectId: "other-project" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both tabs are still present (no spurious reconciliation).
|
||||||
|
expect(screen.getByText("Beta")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Default")).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -36,13 +36,42 @@ function describe(e: unknown): string {
|
|||||||
return String(e);
|
return String(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when `e` is the backend `NOT_FOUND` refusal (a {@link GatewayError}). */
|
||||||
|
function isNotFound(e: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof e === "object" &&
|
||||||
|
e !== null &&
|
||||||
|
"code" in e &&
|
||||||
|
(e as { code: unknown }).code === "NOT_FOUND"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useLayouts(projectId: string | null): LayoutsViewModel {
|
export function useLayouts(projectId: string | null): LayoutsViewModel {
|
||||||
const { layout: gateway } = useGateways();
|
const { layout: gateway, system } = useGateways();
|
||||||
const [layouts, setLayouts] = useState<LayoutInfo[]>([]);
|
const [layouts, setLayouts] = useState<LayoutInfo[]>([]);
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Re-list the layouts and reconcile the locally-remembered active id against
|
||||||
|
// the backend's authoritative set (invariants I3/I4). Any local active id that
|
||||||
|
// is absent from the freshly-listed layouts is *purged* — we never replay a
|
||||||
|
// vanished id (the bug after git overwrites `layouts.json`); we fall back to
|
||||||
|
// the active id the backend reports. The shared self-heal used on mount, on
|
||||||
|
// `layoutChanged`, and after a NOT_FOUND from an explicit op (rename/delete).
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!projectId || !gateway) return;
|
||||||
|
try {
|
||||||
|
const { layouts: list, activeId: aid } = await gateway.listLayouts(projectId);
|
||||||
|
setLayouts(list);
|
||||||
|
setActiveId((prev) =>
|
||||||
|
prev && list.some((l) => l.id === prev) ? prev : aid,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
}
|
||||||
|
}, [gateway, projectId]);
|
||||||
|
|
||||||
// Load the list on mount and whenever projectId changes.
|
// Load the list on mount and whenever projectId changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId || !gateway) {
|
if (!projectId || !gateway) {
|
||||||
@ -71,14 +100,40 @@ export function useLayouts(projectId: string | null): LayoutsViewModel {
|
|||||||
};
|
};
|
||||||
}, [gateway, projectId]);
|
}, [gateway, projectId]);
|
||||||
|
|
||||||
|
// Re-sync on external layout changes (e.g. git overwriting `layouts.json`,
|
||||||
|
// reboot reconciliation): the backend emits `layoutChanged`. We re-list and
|
||||||
|
// purge any stale local active id so the tab bar never replays a vanished id.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId || !system) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
void system
|
||||||
|
.onDomainEvent((event) => {
|
||||||
|
if (event.type === "layoutChanged" && event.projectId === projectId) {
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((un) => {
|
||||||
|
if (cancelled) un();
|
||||||
|
else unsubscribe = un;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
}, [system, projectId, refresh]);
|
||||||
|
|
||||||
const setActive = useCallback(
|
const setActive = useCallback(
|
||||||
async (layoutId: string) => {
|
async (layoutId: string) => {
|
||||||
if (!projectId || !gateway) return;
|
if (!projectId || !gateway) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await gateway.setActiveLayout(projectId, layoutId);
|
// I4: the backend's returned id is authoritative — adopt it instead of
|
||||||
setActiveId(layoutId);
|
// re-imposing the requested id (which may be stale). Code defensively in
|
||||||
|
// case an older backend still returns void: fall back to the requested id.
|
||||||
|
const result = await gateway.setActiveLayout(projectId, layoutId);
|
||||||
|
setActiveId(result?.activeId ?? layoutId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(describe(e));
|
setError(describe(e));
|
||||||
} finally {
|
} finally {
|
||||||
@ -119,12 +174,19 @@ export function useLayouts(projectId: string | null): LayoutsViewModel {
|
|||||||
prev.map((l) => (l.id === layoutId ? { ...l, name } : l)),
|
prev.map((l) => (l.id === layoutId ? { ...l, name } : l)),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(describe(e));
|
// NOT_FOUND on an explicit op is legitimate (the layout vanished, e.g.
|
||||||
|
// an external overwrite) and must NOT freeze the UI: re-list and adopt
|
||||||
|
// the backend's active id instead of surfacing a hard error.
|
||||||
|
if (isNotFound(e)) {
|
||||||
|
await refresh();
|
||||||
|
} else {
|
||||||
|
setError(describe(e));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[gateway, projectId],
|
[gateway, projectId, refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteLayout = useCallback(
|
const deleteLayout = useCallback(
|
||||||
@ -141,12 +203,18 @@ export function useLayouts(projectId: string | null): LayoutsViewModel {
|
|||||||
setLayouts((prev) => prev.filter((l) => l.id !== layoutId));
|
setLayouts((prev) => prev.filter((l) => l.id !== layoutId));
|
||||||
setActiveId(newActiveId);
|
setActiveId(newActiveId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(describe(e));
|
// NOT_FOUND means the layout was already gone (e.g. external overwrite):
|
||||||
|
// treat it as non-fatal — re-list and adopt the backend's active id.
|
||||||
|
if (isNotFound(e)) {
|
||||||
|
await refresh();
|
||||||
|
} else {
|
||||||
|
setError(describe(e));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[gateway, projectId, layouts.length],
|
[gateway, projectId, layouts.length, refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { layouts, activeId, busy, error, setActive, create, rename, deleteLayout };
|
return { layouts, activeId, busy, error, setActive, create, rename, deleteLayout };
|
||||||
|
|||||||
@ -375,8 +375,14 @@ export interface LayoutGateway {
|
|||||||
renameLayout(projectId: string, layoutId: string, name: string): Promise<void>;
|
renameLayout(projectId: string, layoutId: string, name: string): Promise<void>;
|
||||||
/** Deletes a layout; returns the new active layout id. */
|
/** Deletes a layout; returns the new active layout id. */
|
||||||
deleteLayout(projectId: string, layoutId: string): Promise<{ activeId: string }>;
|
deleteLayout(projectId: string, layoutId: string): Promise<{ activeId: string }>;
|
||||||
/** Sets the active layout for a project. */
|
/**
|
||||||
setActiveLayout(projectId: string, layoutId: string): Promise<void>;
|
* Sets the active layout for a project. Returns the layout id the backend
|
||||||
|
* *actually* activated — authoritative (invariant I4): it equals the requested
|
||||||
|
* id when valid, else the unchanged current active id (self-healing fallback
|
||||||
|
* when the requested id was stale, e.g. after an external overwrite of
|
||||||
|
* `layouts.json`). Callers must adopt this id rather than the one they asked for.
|
||||||
|
*/
|
||||||
|
setActiveLayout(projectId: string, layoutId: string): Promise<{ activeId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Git: status/commit/checkout/… (L8). */
|
/** Git: status/commit/checkout/… (L8). */
|
||||||
|
|||||||
Reference in New Issue
Block a user