From 84573a6822145b34851432e3dbc477fe0bbed105 Mon Sep 17 00:00:00 2001 From: Des Kramer Date: Fri, 19 Sep 2025 11:08:49 +0000 Subject: [PATCH] feat: implement tab management in AppShellView with new TabsViewModel for multiple document handling Co-authored-by: Genie --- JSONViewer/UI/AppShellView.swift | 59 ++++++++++++++++++++--- JSONViewer/ViewModels/TabsViewModel.swift | 40 +++++++++++++++ 2 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 JSONViewer/ViewModels/TabsViewModel.swift diff --git a/JSONViewer/UI/AppShellView.swift b/JSONViewer/UI/AppShellView.swift index 6ee2070..df333d9 100644 --- a/JSONViewer/UI/AppShellView.swift +++ b/JSONViewer/UI/AppShellView.swift @@ -5,14 +5,18 @@ import AppKit #endif struct AppShellView: View { - @StateObject private var viewModel = AppViewModel() - #if os(macOS) + @StateObject private var tabs = TabsViewModel() + #if os(macOS) @State private var nsWindow: NSWindow? #endif @State private var isInspectorVisible: Bool = false @State private var isAISidebarVisible: Bool = false @Environment(\.openWindow) private var openWindow + private var viewModel: AppViewModel { + tabs.selected + } + private var displayText: String { viewModel.prettyJSON } @@ -47,7 +51,7 @@ struct AppShellView: View { if viewModel.mode != .none { CommandBarContainer( - mode: $viewModel.commandMode, + mode: Binding(get: { viewModel.commandMode }, set: { viewModel.commandMode = $0 }), placeholder: viewModel.commandMode == .jq ? "jq filter (e.g. .items | length)" : "Use natural language to search or transform" @@ -76,10 +80,38 @@ struct AppShellView: View { } } .navigationSplitViewColumnWidth(min: 420, ideal: 680, max: .infinity) - .navigationTitle(viewModel.fileURL?.lastPathComponent ?? "Prism") + .navigationTitle(windowTitle) } - .toolbar { + // Tabs in the top bar + ToolbarItemGroup { + HStack(spacing: 8) { + Picker("", selection: Binding(get: { tabs.selectedIndex }, set: { tabs.selectedIndex = $0 })) { + ForEach(Array(tabs.tabs.enumerated()), id: \.offset) { idx, vm in + Text(vm.fileURL?.lastPathComponent ?? "Untitled") + .tag(idx) + } + } + .pickerStyle(.segmented) + .help("Switch document tab") + + Button { + _ = tabs.newTab() + withAnimation { + isInspectorVisible = false + isAISidebarVisible = false + } + #if os(macOS) + nsWindow?.makeFirstResponder(nil) + #endif + } label: { + Image(systemName: "plus") + } + .help("New Tab") + } + } + + // Existing controls ToolbarItemGroup { Button { openFile() @@ -95,7 +127,7 @@ struct AppShellView: View { } .help("Paste JSON/JSONL from clipboard (⌘V)") - Picker("", selection: $viewModel.presentation) { + Picker("", selection: Binding(get: { viewModel.presentation }, set: { viewModel.presentation = $0 })) { Image(systemName: "text.alignleft").tag(AppViewModel.ContentPresentation.text) Image(systemName: "list.bullet.indent").tag(AppViewModel.ContentPresentation.tree) } @@ -156,7 +188,7 @@ struct AppShellView: View { // Show native title (file name when available) nsWindow?.titleVisibility = .visible nsWindow?.representedURL = viewModel.fileURL - nsWindow?.title = viewModel.fileURL?.lastPathComponent ?? "Prism" + nsWindow?.title = windowTitle // Ensure no text field is focused by default so Cmd+V pastes into the viewer. nsWindow?.makeFirstResponder(nil) WindowRegistry.shared.register(viewModel) @@ -169,6 +201,11 @@ struct AppShellView: View { OpenRequests.shared.drain(into: viewModel) { id in openWindow(id: id) } + // If there are multiple pending, open them in new tabs in this window. + while OpenRequests.shared.pendingCount > 0 { + let vm = tabs.newTab() + _ = OpenRequests.shared.deliverNext(to: vm) + } // Ensure no UI element steals focus on first launch so Cmd+V pastes into viewer. DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { nsWindow?.makeFirstResponder(nil) @@ -183,6 +220,14 @@ struct AppShellView: View { nsWindow?.representedURL = newURL nsWindow?.title = newURL?.lastPathComponent ?? "Prism" } + .onChange(of: tabs.selectedIndex) { _ in + // Update window chrome when switching tabs + nsWindow?.titleVisibility = .visible + nsWindow?.representedURL = viewModel.fileURL + nsWindow?.title = windowTitle + // Do not keep inspector/AI sidebar open automatically across tabs unless desired + // Leave their visibility as-is for now. + } #endif } diff --git a/JSONViewer/ViewModels/TabsViewModel.swift b/JSONViewer/ViewModels/TabsViewModel.swift new file mode 100644 index 0000000..74920bb --- /dev/null +++ b/JSONViewer/ViewModels/TabsViewModel.swift @@ -0,0 +1,40 @@ +import SwiftUI +import Foundation + +@MainActor +final class TabsViewModel: ObservableObject { + @Published var tabs: [AppViewModel] + @Published var selectedIndex: Int + + init() { + let initial = AppViewModel() + self.tabs = [initial] + self.selectedIndex = 0 + } + + var selected: AppViewModel { + tabs[selectedIndex] + } + + @discardableResult + func newTab() -> AppViewModel { + let vm = AppViewModel() + tabs.append(vm) + selectedIndex = tabs.count - 1 + return vm + } + + func closeTab(at index: Int) { + guard tabs.indices.contains(index) else { return } + tabs.remove(at: index) + if tabs.isEmpty { + _ = newTab() + } else if selectedIndex >= tabs.count { + selectedIndex = tabs.count - 1 + } + } + + var titles: [String] { + tabs.map { $0.fileURL?.lastPathComponent ?? "Untitled" } + } +} \ No newline at end of file