// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import SwiftUI import TermindApplication import TermindDomain import TermindPresentation /// Window-scoped ownership for the dynamic workspace collection. /// Navigation remains in `WindowModel`; recoverable close history belongs to the app. @Observable final class WorkspaceDeckModel { var workspaces: [Workspace] init(workspaces: [Workspace] = []) { self.workspaces = workspaces } } /// The one left sidebar's window-lifetime state. /// /// Navigation and contextual tools share a physical column. The selected mode /// belongs to the window and remains unchanged as its destination changes. @Observable final class WindowSidebarState { nonisolated enum Mode: String, Hashable, Sendable, CaseIterable, Identifiable { case navigation, assistant, snippets, history nonisolated var id: String { rawValue } var label: LocalizedStringResource { switch self { case .navigation: AppStrings.Workspace.navigator case .assistant: AppStrings.Navigation.assistant case .snippets: AppStrings.Navigation.snippets case .history: AppStrings.Common.history } } var symbol: String { switch self { case .navigation: "list.bullet" case .assistant: "sparkles" case .snippets: "curlybraces" case .history: "clock.arrow.circlepath" } } } var isPresented = true /// The last usable width survives hiding the column and is the initial /// width for a window created from this one. var preferredWidth: CGFloat = Metrics.Sidebar.ideal var observedWidth: CGFloat? var measuredWidth: CGFloat? /// A late scene request rebuilds only the new window's split view so its /// first column can take the requested width. var initialLayoutGeneration = 0 /// The contextual Sidebar keeps its own draft, but its open Chat/Archive /// route is shared with the full Assistant through `WindowModel`. var assistantDraft = ConversationModel() /// What the focused pane is, for whichever composer this column is showing. /// /// Here rather than on a conversation, because it describes the window's /// current view of a host — not the chat. `WindowModel.refreshSessionContext` /// is the only writer, and it clears this to `.none` when no Workspace is /// selected so a stale host and directory cannot be left on screen. var sessionContext: SessionContext = .none var mode: Mode = .navigation } @Observable final class WindowPresentationState { enum PresentationRoute: Identifiable { case hostChooser(HostChooserPurpose) case quickOpen case workspaceTemplateName(WorkspaceTemplateNamePresentation) var id: String { switch self { case .hostChooser(let purpose): "host-chooser-\(purpose.id)" case .quickOpen: "quick-open" case .workspaceTemplateName(let presentation): presentation.id.uuidString } } } var route: PresentationRoute? var hostChooser: HostChooserPurpose? { get { guard case .hostChooser(let purpose) = route else { return nil } return purpose } set { route = newValue.map(PresentationRoute.hostChooser) } } var isQuickOpenPresented: Bool { get { guard case .quickOpen = route else { return false } return true } set { if newValue { route = .quickOpen } else if case .quickOpen = route { route = nil } } } var workspaceTemplateName: WorkspaceTemplateNamePresentation? { get { guard case .workspaceTemplateName(let presentation) = route else { return nil } return presentation } set { if let newValue { route = .workspaceTemplateName(newValue) } else if case .workspaceTemplateName = route { route = nil } } } } @Observable final class WorkspaceTemplateNamePresentation: Identifiable { let id = UUID() let workspaceID: Workspace.ID var name: String var error: String? init(workspaceID: Workspace.ID, name: String) { self.workspaceID = workspaceID self.name = name } } @Observable final class WorkspaceDragState { var payload: DragPayload? }