feat: added tabs 77580a86
Steve · 2025-12-25 13:27 4 file(s) · +267 −1
Titan/Models/Tab.swift (added) +24 −0
1 +
//
2 +
//  Tab.swift
3 +
//  Titan
4 +
//
5 +
6 +
import Foundation
7 +
8 +
struct Tab: Identifiable, Codable, Equatable {
9 +
    let id: UUID
10 +
    var url: String
11 +
    var title: String
12 +
    var responseText: String
13 +
    var history: [String]
14 +
    var historyIndex: Int
15 +
16 +
    init(id: UUID = UUID(), url: String = "", title: String = "", responseText: String = "", history: [String] = [], historyIndex: Int = -1) {
17 +
        self.id = id
18 +
        self.url = url
19 +
        self.title = title
20 +
        self.responseText = responseText
21 +
        self.history = history
22 +
        self.historyIndex = historyIndex
23 +
    }
24 +
}
Titan/Services/TabManager.swift (added) +106 −0
1 +
//
2 +
//  TabManager.swift
3 +
//  Titan
4 +
//
5 +
6 +
import Foundation
7 +
8 +
@Observable
9 +
class TabManager {
10 +
    private let storageKey = "titan_tabs"
11 +
    private let activeTabKey = "titan_active_tab"
12 +
13 +
    var tabs: [Tab] = []
14 +
    var activeTabId: UUID?
15 +
16 +
    var activeTab: Tab? {
17 +
        get { tabs.first { $0.id == activeTabId } }
18 +
        set {
19 +
            if let newValue = newValue,
20 +
               let index = tabs.firstIndex(where: { $0.id == newValue.id }) {
21 +
                tabs[index] = newValue
22 +
                save()
23 +
            }
24 +
        }
25 +
    }
26 +
27 +
    var activeTabIndex: Int? {
28 +
        tabs.firstIndex { $0.id == activeTabId }
29 +
    }
30 +
31 +
    init() {
32 +
        load()
33 +
        if tabs.isEmpty {
34 +
            createTab()
35 +
        }
36 +
    }
37 +
38 +
    @discardableResult
39 +
    func createTab(url: String = "") -> Tab {
40 +
        let tab = Tab(url: url)
41 +
        tabs.append(tab)
42 +
        activeTabId = tab.id
43 +
        save()
44 +
        return tab
45 +
    }
46 +
47 +
    func closeTab(id: UUID?) {
48 +
        guard let id = id else { return }
49 +
        guard tabs.count > 1 else { return }
50 +
51 +
        let closingIndex = tabs.firstIndex { $0.id == id }
52 +
        tabs.removeAll { $0.id == id }
53 +
54 +
        if activeTabId == id {
55 +
            if let closingIndex = closingIndex {
56 +
                let newIndex = min(closingIndex, tabs.count - 1)
57 +
                activeTabId = tabs[newIndex].id
58 +
            } else {
59 +
                activeTabId = tabs.first?.id
60 +
            }
61 +
        }
62 +
        save()
63 +
    }
64 +
65 +
    func switchTo(id: UUID) {
66 +
        guard tabs.contains(where: { $0.id == id }) else { return }
67 +
        activeTabId = id
68 +
        save()
69 +
    }
70 +
71 +
    func updateActiveTab(url: String? = nil, title: String? = nil, responseText: String? = nil, history: [String]? = nil, historyIndex: Int? = nil) {
72 +
        guard var tab = activeTab else { return }
73 +
74 +
        if let url = url { tab.url = url }
75 +
        if let title = title { tab.title = title }
76 +
        if let responseText = responseText { tab.responseText = responseText }
77 +
        if let history = history { tab.history = history }
78 +
        if let historyIndex = historyIndex { tab.historyIndex = historyIndex }
79 +
80 +
        activeTab = tab
81 +
    }
82 +
83 +
    private func save() {
84 +
        if let data = try? JSONEncoder().encode(tabs) {
85 +
            UserDefaults.standard.set(data, forKey: storageKey)
86 +
        }
87 +
        if let activeId = activeTabId {
88 +
            UserDefaults.standard.set(activeId.uuidString, forKey: activeTabKey)
89 +
        }
90 +
    }
91 +
92 +
    private func load() {
93 +
        if let data = UserDefaults.standard.data(forKey: storageKey),
94 +
           let decoded = try? JSONDecoder().decode([Tab].self, from: data) {
95 +
            tabs = decoded
96 +
        }
97 +
98 +
        if let activeIdString = UserDefaults.standard.string(forKey: activeTabKey),
99 +
           let activeId = UUID(uuidString: activeIdString),
100 +
           tabs.contains(where: { $0.id == activeId }) {
101 +
            activeTabId = activeId
102 +
        } else {
103 +
            activeTabId = tabs.first?.id
104 +
        }
105 +
    }
106 +
}
Titan/Views/ContentView.swift +67 −1
62 62
    // Settings
63 63
    @State private var showSettings = false
64 64
65 +
    // Tabs
66 +
    @State private var tabManager = TabManager()
67 +
    @State private var showTabs = false
68 +
65 69
    // URL input focus state
66 70
    @FocusState private var isURLFocused: Bool
67 71
147 151
                                .transition(.opacity.combined(with: .scale(scale: 0.8)))
148 152
                            } else {
149 153
                                Menu {
154 +
                                    Button {
155 +
                                        showTabs = true
156 +
                                    } label: {
157 +
                                        Label("Tabs (\(tabManager.tabs.count))", systemImage: "square.on.square")
158 +
                                    }
159 +
160 +
                                    Button {
161 +
                                        saveCurrentTabState()
162 +
                                        tabManager.createTab(url: themeSettings.homePage)
163 +
                                        loadActiveTabState()
164 +
                                        navigateTo(themeSettings.homePage)
165 +
                                    } label: {
166 +
                                        Label("New Tab", systemImage: "plus")
167 +
                                    }
168 +
169 +
                                    Button {
170 +
                                        tabManager.closeTab(id: tabManager.activeTabId)
171 +
                                        loadActiveTabState()
172 +
                                    } label: {
173 +
                                        Label("Close Tab", systemImage: "xmark")
174 +
                                    }
175 +
                                    .disabled(tabManager.tabs.count <= 1)
176 +
177 +
                                    Divider()
150 178
151 179
                                    Button {
152 180
                                        showSettings = true
205 233
            }
206 234
        }
207 235
        .onAppear {
208 -
            navigateTo(themeSettings.homePage)
236 +
            loadActiveTabState()
237 +
            // If this is a fresh tab with no content, navigate to home
238 +
            if urlText.isEmpty {
239 +
                navigateTo(themeSettings.homePage)
240 +
            } else if responseText.isEmpty && !urlText.isEmpty {
241 +
                // Tab has URL but no content (restored from persistence)
242 +
                navigateTo(urlText)
243 +
            }
209 244
        }
210 245
        .alert("Input Required", isPresented: $showInputPrompt) {
211 246
            if inputIsSensitive {
245 280
                navigateTo(item.url)
246 281
            }
247 282
        }
283 +
        .sheet(isPresented: $showTabs) {
284 +
            TabsListView(tabManager: tabManager) { tab in
285 +
                showTabs = false
286 +
                saveCurrentTabState()
287 +
                tabManager.switchTo(id: tab.id)
288 +
                loadActiveTabState()
289 +
            }
290 +
        }
291 +
    }
292 +
293 +
    // MARK: - Tab State Management
294 +
295 +
    private func saveCurrentTabState() {
296 +
        let title = extractPageTitle() ?? urlText
297 +
        tabManager.updateActiveTab(
298 +
            url: urlText,
299 +
            title: title,
300 +
            responseText: responseText,
301 +
            history: history,
302 +
            historyIndex: historyIndex
303 +
        )
304 +
    }
305 +
306 +
    private func loadActiveTabState() {
307 +
        guard let tab = tabManager.activeTab else { return }
308 +
        urlText = tab.url
309 +
        responseText = tab.responseText
310 +
        history = tab.history
311 +
        historyIndex = tab.historyIndex
248 312
    }
249 313
250 314
    private func submitInput() {
363 427
                            let title = extractPageTitle() ?? finalURL
364 428
                            historyManager.addToHistory(url: finalURL, title: title)
365 429
                        }
430 +
                        // Save tab state
431 +
                        saveCurrentTabState()
366 432
                    }
367 433
                case .input:
368 434
                    pendingInputURL = finalURL
Titan/Views/TabsListView.swift (added) +70 −0
1 +
//
2 +
//  TabsListView.swift
3 +
//  Titan
4 +
//
5 +
6 +
import SwiftUI
7 +
8 +
struct TabsListView: View {
9 +
    @Bindable var tabManager: TabManager
10 +
    let onSelect: (Tab) -> Void
11 +
12 +
    @Environment(\.dismiss) private var dismiss
13 +
14 +
    var body: some View {
15 +
        NavigationStack {
16 +
            List {
17 +
                ForEach(tabManager.tabs) { tab in
18 +
                    Button {
19 +
                        onSelect(tab)
20 +
                    } label: {
21 +
                        HStack {
22 +
                            VStack(alignment: .leading, spacing: 4) {
23 +
                                Text(tab.title.isEmpty ? (tab.url.isEmpty ? "New Tab" : tab.url) : tab.title)
24 +
                                    .font(.system(.body, design: .monospaced))
25 +
                                    .foregroundStyle(.primary)
26 +
                                    .lineLimit(2)
27 +
28 +
                                if !tab.url.isEmpty && !tab.title.isEmpty {
29 +
                                    Text(tab.url)
30 +
                                        .font(.system(.caption, design: .monospaced))
31 +
                                        .foregroundStyle(.secondary)
32 +
                                        .lineLimit(1)
33 +
                                }
34 +
                            }
35 +
                            .padding(.vertical, 4)
36 +
37 +
                            Spacer()
38 +
39 +
                            if tab.id == tabManager.activeTabId {
40 +
                                Image(systemName: "checkmark")
41 +
                                    .foregroundStyle(Color.accentColor)
42 +
                            }
43 +
                        }
44 +
                    }
45 +
                }
46 +
                .onDelete { offsets in
47 +
                    for index in offsets {
48 +
                        let tab = tabManager.tabs[index]
49 +
                        tabManager.closeTab(id: tab.id)
50 +
                    }
51 +
                }
52 +
            }
53 +
            .navigationTitle("Tabs")
54 +
            .navigationBarTitleDisplayMode(.inline)
55 +
            .toolbar {
56 +
                ToolbarItem(placement: .cancellationAction) {
57 +
                    Button("Done") {
58 +
                        dismiss()
59 +
                    }
60 +
                }
61 +
62 +
                if tabManager.tabs.count > 1 {
63 +
                    ToolbarItem(placement: .primaryAction) {
64 +
                        EditButton()
65 +
                    }
66 +
                }
67 +
            }
68 +
        }
69 +
    }
70 +
}