Files
Termind/TFM/Features/Workspaces/WindowState.swift
T

149 lines
4.5 KiB
Swift

// 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?
}