213 lines
8.6 KiB
Swift
213 lines
8.6 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
|
|
extension Workspace {
|
|
func workspaceTemplateLayout(in hosts: HostLibraryModel) -> WorkspaceTemplateLayout? {
|
|
layout.workspaceTemplateLayout(in: hosts)
|
|
}
|
|
}
|
|
|
|
extension SplitNode {
|
|
func workspaceTemplateLayout(in hosts: HostLibraryModel) -> WorkspaceTemplateLayout? {
|
|
switch self {
|
|
case .leaf(let pane):
|
|
switch pane.content {
|
|
case .sshSession(let session):
|
|
guard hosts.host(session.hostID) != nil else { return nil }
|
|
switch session.transport {
|
|
case .mosh: return .pane(.mosh(hostID: session.hostID))
|
|
case .eternalTerminal: return .pane(.eternalTerminal(hostID: session.hostID))
|
|
case .ssh, .telnet: return .pane(.ssh(hostID: session.hostID))
|
|
}
|
|
case .telnetSession(let session):
|
|
guard hosts.host(session.hostID) != nil else { return nil }
|
|
return .pane(.telnet(hostID: session.hostID))
|
|
case .sftpChannel(let session):
|
|
guard hosts.host(session.hostID) != nil else { return nil }
|
|
return .pane(.sftp(hostID: session.hostID))
|
|
case .empty:
|
|
return .pane(.empty)
|
|
case .serialSession:
|
|
return nil
|
|
}
|
|
case .split(let branch):
|
|
guard let first = branch.first.workspaceTemplateLayout(in: hosts),
|
|
let second = branch.second.workspaceTemplateLayout(in: hosts)
|
|
else { return nil }
|
|
return .split(
|
|
axis: branch.axis == .horizontal ? .horizontal : .vertical,
|
|
fraction: branch.fraction,
|
|
first: first,
|
|
second: second
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension WorkspaceTemplateLayout {
|
|
@MainActor
|
|
func makeSplitNode(using hosts: HostLibraryModel) -> (node: SplitNode, missingHosts: Int) {
|
|
switch self {
|
|
case .pane(let descriptor):
|
|
switch descriptor {
|
|
case .empty:
|
|
return (.leaf(Pane(.empty)), 0)
|
|
case .ssh(let hostID):
|
|
guard let host = hosts.host(hostID), host.sshEnabled else { return (.leaf(Pane(.empty)), 1) }
|
|
return (.leaf(.sshSession(host: host)), 0)
|
|
case .mosh(let hostID):
|
|
guard let host = hosts.host(hostID), host.isEnabled(.mosh) else { return (.leaf(Pane(.empty)), 1) }
|
|
return (.leaf(.sshSession(host: host, transport: .mosh)), 0)
|
|
case .eternalTerminal(let hostID):
|
|
guard let host = hosts.host(hostID), host.isEnabled(.eternalTerminal) else {
|
|
return (.leaf(Pane(.empty)), 1)
|
|
}
|
|
return (.leaf(.sshSession(host: host, transport: .eternalTerminal)), 0)
|
|
case .telnet(let hostID):
|
|
guard let host = hosts.host(hostID), host.telnetEnabled else { return (.leaf(Pane(.empty)), 1) }
|
|
return (.leaf(.telnetSession(host: host)), 0)
|
|
case .sftp(let hostID):
|
|
guard let host = hosts.host(hostID), host.sshEnabled else { return (.leaf(Pane(.empty)), 1) }
|
|
return (.leaf(.sftpChannel(host: host)), 0)
|
|
}
|
|
case .split(let axis, let fraction, let first, let second):
|
|
let firstResult = first.makeSplitNode(using: hosts)
|
|
let secondResult = second.makeSplitNode(using: hosts)
|
|
return (
|
|
.split(
|
|
SplitNode.Branch(
|
|
axis: axis == .horizontal ? .horizontal : .vertical,
|
|
fraction: fraction,
|
|
first: firstResult.node,
|
|
second: secondResult.node
|
|
)
|
|
),
|
|
firstResult.missingHosts + secondResult.missingHosts
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension WindowModel {
|
|
var canSaveSelectedWorkspaceTemplate: Bool {
|
|
guard let hostStore, let workspace = selectedWorkspace else { return false }
|
|
return workspace.workspaceTemplateLayout(in: hostStore) != nil
|
|
}
|
|
|
|
func requestSaveWorkspaceTemplate(_ workspaceID: Workspace.ID? = nil) {
|
|
guard let hosts = hostStore,
|
|
let workspace = workspaceID.flatMap({ id in workspaces.first { $0.id == id } }) ?? selectedWorkspace,
|
|
let layout = workspace.workspaceTemplateLayout(in: hosts)
|
|
else { return }
|
|
|
|
select(workspace.id)
|
|
if let templateID = workspace.sourceTemplateID,
|
|
hosts.workspaceTemplate(templateID) != nil
|
|
{
|
|
let name = workspace.title(in: hosts)
|
|
let updated = WorkspaceTemplate(id: templateID, name: name, layout: layout)
|
|
if !hosts.saveWorkspaceTemplate(updated) {
|
|
workspaceTemplateNotice = hosts.storageError
|
|
}
|
|
return
|
|
}
|
|
|
|
presentations.workspaceTemplateName = WorkspaceTemplateNamePresentation(
|
|
workspaceID: workspace.id,
|
|
name: workspace.title(in: hosts)
|
|
)
|
|
}
|
|
|
|
func commitWorkspaceTemplateName() {
|
|
guard let presentation = presentations.workspaceTemplateName,
|
|
let hosts = hostStore
|
|
else { return }
|
|
|
|
guard let workspace = workspaces.first(where: { $0.id == presentation.workspaceID }),
|
|
let layout = workspace.workspaceTemplateLayout(in: hosts)
|
|
else {
|
|
presentation.error = String(localized: AppStrings.Workspace.thisWorkspaceCanNoLongerBeSavedAsATemplate)
|
|
return
|
|
}
|
|
let template = WorkspaceTemplate(name: presentation.name, layout: layout)
|
|
guard hosts.saveWorkspaceTemplate(template) else {
|
|
presentation.error = hosts.storageError
|
|
return
|
|
}
|
|
setTemplateName(template.name, on: workspace)
|
|
workspace.sourceTemplateID = template.id
|
|
presentations.workspaceTemplateName = nil
|
|
}
|
|
|
|
@discardableResult
|
|
func openWorkspaceTemplate(_ template: WorkspaceTemplate) -> Workspace? {
|
|
guard let hosts = hostStore else { return nil }
|
|
let result = template.layout.makeSplitNode(using: hosts)
|
|
let workspace = Workspace(pane: result.node.firstPane)
|
|
workspace.layout = result.node
|
|
workspace.focusedPaneID = result.node.firstPane.id
|
|
setTemplateName(template.name, on: workspace)
|
|
workspace.sourceTemplateID = template.id
|
|
open(workspace)
|
|
if result.missingHosts > 0 {
|
|
workspaceTemplateNotice = String(
|
|
localized: AppStrings.Workspace.unavailableHostsWereOpenedAsEmptyPanes(result.missingHosts)
|
|
)
|
|
}
|
|
return workspace
|
|
}
|
|
|
|
private func setTemplateName(_ name: String, on workspace: Workspace) {
|
|
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if workspace.isSplit {
|
|
workspace.customTitle = name
|
|
} else {
|
|
workspace.focusedPane.customTitle = name
|
|
}
|
|
}
|
|
}
|
|
|
|
struct WorkspaceTemplateNameSheet: View {
|
|
@Environment(WindowModel.self) private var window
|
|
@Bindable var presentation: WorkspaceTemplateNamePresentation
|
|
@FocusState private var isNameFocused: Bool
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.l) {
|
|
Text(title)
|
|
.font(.title2.weight(.semibold))
|
|
TextField(
|
|
AppStrings.Workspace.workspaceName.swiftUIKey, text: $presentation.name.singleLineText,
|
|
axis: .horizontal
|
|
).singleLinePaste()
|
|
.lineLimit(1)
|
|
.textFieldStyle(.roundedBorder)
|
|
.focused($isNameFocused)
|
|
if let error = presentation.error {
|
|
Text(error)
|
|
.font(.callout)
|
|
.foregroundStyle(.red)
|
|
}
|
|
HStack {
|
|
Spacer()
|
|
Button(AppStrings.Common.cancel.swiftUIKey) { window.presentations.workspaceTemplateName = nil }
|
|
.keyboardShortcut(.cancelAction)
|
|
Button(AppStrings.Common.save.swiftUIKey) { window.commitWorkspaceTemplateName() }
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(presentation.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
}
|
|
.padding(Metrics.Space.xl)
|
|
.frame(width: 420)
|
|
.onAppear { isNameFocused = true }
|
|
}
|
|
|
|
private var title: LocalizedStringResource { AppStrings.Workspace.saveWorkspaceTemplate }
|
|
}
|