diff --git a/.swift-format.json b/.swift-format.json index efe5aa44..314ccf34 100644 --- a/.swift-format.json +++ b/.swift-format.json @@ -12,7 +12,7 @@ "lineBreakBeforeEachArgument" : false, "lineBreakBeforeEachGenericRequirement" : false, "lineBreakBetweenDeclarationAttributes" : false, - "lineLength" : 100, + "lineLength" : 120, "maximumBlankLines" : 1, "multiElementCollectionTrailingCommas" : true, "noAssignmentInExpressions" : { diff --git a/.swiftlint.yml b/.swiftlint.yml index c158429e..d06569f8 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -4,6 +4,11 @@ included: excluded: - ThirdParty/ghostty +disabled_rules: + - file_length + - closure_parameter_position + - type_body_length + opt_in_rules: - accessibility_label_for_image - accessibility_trait_for_button @@ -21,17 +26,9 @@ cyclomatic_complexity: warning: 15 error: 20 -file_length: - warning: 600 - error: 800 - function_body_length: warning: 100 error: 150 -type_body_length: - warning: 500 - error: 650 - redundant_discardable_let: ignore_swiftui_view_bodies: true diff --git a/supacode/Commands/UpdateCommands.swift b/supacode/Commands/UpdateCommands.swift index 92886848..e0e2c2c7 100644 --- a/supacode/Commands/UpdateCommands.swift +++ b/supacode/Commands/UpdateCommands.swift @@ -10,7 +10,8 @@ struct UpdateCommands: Commands { updateController.checkForUpdates() } .keyboardShortcut( - AppShortcuts.checkForUpdates.keyEquivalent, modifiers: AppShortcuts.checkForUpdates.modifiers + AppShortcuts.checkForUpdates.keyEquivalent, + modifiers: AppShortcuts.checkForUpdates.modifiers ) .help("Check for Updates (\(AppShortcuts.checkForUpdates.display))") } diff --git a/supacode/ContentView.swift b/supacode/ContentView.swift index 51ecea2d..57de308d 100644 --- a/supacode/ContentView.swift +++ b/supacode/ContentView.swift @@ -362,10 +362,14 @@ private struct RepositorySectionView: View { let onRequestRemoval: (Worktree, Repository) -> Void let onRequestRepositoryRemoval: (Repository) -> Void @Environment(RepositoryStore.self) private var repositoryStore + @Environment(\.openWindow) private var openWindow var body: some View { let isExpanded = expandedRepoIDs.contains(repository.id) let isRemovingRepository = repositoryStore.isRemovingRepository(repository) + let openRepoSettings = { + openWindow(id: WindowIdentifiers.repoSettings, value: repository.id) + } Section { WorktreeRowsView( repository: repository, @@ -394,6 +398,10 @@ private struct RepositorySectionView: View { .buttonStyle(.plain) .disabled(isRemovingRepository) .contextMenu { + Button("Repo Settings") { + openRepoSettings() + } + .help("Repo Settings (no shortcut)") Button("Remove Repository") { onRequestRepositoryRemoval(repository) } @@ -405,6 +413,19 @@ private struct RepositorySectionView: View { ProgressView() .controlSize(.small) } + Menu { + Button("Repo Settings") { + openRepoSettings() + } + .help("Repo Settings (no shortcut)") + } label: { + Label("Repository options", systemImage: "ellipsis") + } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .foregroundStyle(.primary) + .help("Repository options (no shortcut)") + .disabled(isRemovingRepository) Button("New Worktree", systemImage: "plus") { createWorktree(repository) } @@ -440,7 +461,7 @@ private struct WorktreeRowsView: View { private func rowView(_ row: WorktreeRowModel, isRepositoryRemoving: Bool) -> some View { let displayDetail = row.isDeleting ? "Removing..." : row.detail if row.isRemovable, let worktree = repositoryStore.worktree(for: row.id), - !isRepositoryRemoving + !isRepositoryRemoving { WorktreeRow( name: row.name, diff --git a/supacode/GhosttyEmbed/GhosttyRuntime.swift b/supacode/GhosttyEmbed/GhosttyRuntime.swift index eab296a6..a777a30c 100644 --- a/supacode/GhosttyEmbed/GhosttyRuntime.swift +++ b/supacode/GhosttyEmbed/GhosttyRuntime.swift @@ -99,12 +99,16 @@ final class GhosttyRuntime { return Unmanaged.fromOpaque(userdata).takeUnretainedValue() } - private static func surfaceBridge(fromUserdata userdata: UnsafeMutableRawPointer?) -> GhosttySurfaceBridge? { + private static func surfaceBridge(fromUserdata userdata: UnsafeMutableRawPointer?) + -> GhosttySurfaceBridge? + { guard let userdata else { return nil } return Unmanaged.fromOpaque(userdata).takeUnretainedValue() } - private static func surfaceBridge(fromSurface surface: ghostty_surface_t?) -> GhosttySurfaceBridge? { + private static func surfaceBridge(fromSurface surface: ghostty_surface_t?) + -> GhosttySurfaceBridge? + { guard let surface, let userdata = ghostty_surface_userdata(surface) else { return nil } return Unmanaged.fromOpaque(userdata).takeUnretainedValue() } diff --git a/supacode/GhosttyEmbed/GhosttySurfaceBridge.swift b/supacode/GhosttyEmbed/GhosttySurfaceBridge.swift index eda8c5d3..ed90f146 100644 --- a/supacode/GhosttyEmbed/GhosttySurfaceBridge.swift +++ b/supacode/GhosttyEmbed/GhosttySurfaceBridge.swift @@ -200,7 +200,8 @@ final class GhosttySurfaceBridge { state.keyTableTag = table.tag switch table.tag { case GHOSTTY_KEY_TABLE_ACTIVATE: - state.keyTableName = string(from: table.value.activate.name, length: table.value.activate.len) + state.keyTableName = string( + from: table.value.activate.name, length: table.value.activate.len) default: state.keyTableName = nil } diff --git a/supacode/GitClient.swift b/supacode/GitClient.swift index 6c894808..205dca04 100644 --- a/supacode/GitClient.swift +++ b/supacode/GitClient.swift @@ -34,6 +34,7 @@ struct GitClient { nonisolated func worktrees(for repoRoot: URL) async throws -> [Worktree] { let baseDirectory = wtBaseDirectory(for: repoRoot) + let repositoryRootURL = repoRoot.standardizedFileURL let output = try await runWtList(repoRoot: repoRoot, baseDirectory: baseDirectory) let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { @@ -55,7 +56,8 @@ struct GitClient { id: id, name: entry.branch, detail: detail, - workingDirectory: worktreeURL + workingDirectory: worktreeURL, + repositoryRootURL: repositoryRootURL ), createdAt: createdAt, index: index @@ -91,6 +93,7 @@ struct GitClient { nonisolated func createWorktree(named name: String, in repoRoot: URL) async throws -> Worktree { let baseDirectory = wtBaseDirectory(for: repoRoot) + let repositoryRootURL = repoRoot.standardizedFileURL let wtURL = try wtScriptURL() let output = try await runProcess( executableURL: wtURL, @@ -106,7 +109,13 @@ struct GitClient { let worktreeURL = URL(fileURLWithPath: pathLine).standardizedFileURL let detail = Self.relativePath(from: baseDirectory, to: worktreeURL) let id = worktreeURL.path(percentEncoded: false) - return Worktree(id: id, name: name, detail: detail, workingDirectory: worktreeURL) + return Worktree( + id: id, + name: name, + detail: detail, + workingDirectory: worktreeURL, + repositoryRootURL: repositoryRootURL + ) } nonisolated func isWorktreeDirty(at worktreeURL: URL) async throws -> Bool { @@ -184,11 +193,7 @@ struct GitClient { } nonisolated private func wtBaseDirectory(for repoRoot: URL) -> URL { - let repoName = repoRoot.lastPathComponent - let fallback = repoRoot.path(percentEncoded: false).replacingOccurrences(of: "/", with: "_") - let name = repoName.isEmpty ? fallback : repoName - return SupacodePaths.reposDirectory - .appending(path: name, directoryHint: .isDirectory) + SupacodePaths.repositoryDirectory(for: repoRoot) } nonisolated private func runProcess( diff --git a/supacode/RepositoryStore.swift b/supacode/RepositoryStore.swift index 080b76f4..571f2105 100644 --- a/supacode/RepositoryStore.swift +++ b/supacode/RepositoryStore.swift @@ -498,7 +498,9 @@ final class RepositoryStore { let repositoryIDs = Set(loaded.map(\.id)) pendingWorktrees = pendingWorktrees.filter { repositoryIDs.contains($0.repositoryID) } if !isSelectionValid(selectedWorktreeID) { - print("[RepositoryStore] selectedWorktreeID \(String(describing: selectedWorktreeID)) is invalid, clearing") + print( + "[RepositoryStore] selectedWorktreeID \(String(describing: selectedWorktreeID)) is invalid, clearing" + ) selectedWorktreeID = nil } print("[RepositoryStore] applyRepositories done, repositories.count = \(repositories.count)") diff --git a/supacode/Settings/RepositorySettings.swift b/supacode/Settings/RepositorySettings.swift new file mode 100644 index 00000000..447ad803 --- /dev/null +++ b/supacode/Settings/RepositorySettings.swift @@ -0,0 +1,7 @@ +import Foundation + +nonisolated struct RepositorySettings: Codable, Equatable { + var startupCommand: String + + static let `default` = RepositorySettings(startupCommand: "echo 123") +} diff --git a/supacode/Settings/RepositorySettingsModel.swift b/supacode/Settings/RepositorySettingsModel.swift new file mode 100644 index 00000000..7bb99394 --- /dev/null +++ b/supacode/Settings/RepositorySettingsModel.swift @@ -0,0 +1,26 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class RepositorySettingsModel { + private let store: RepositorySettingsStore + private let rootURL: URL + private var settings: RepositorySettings + + var startupCommand: String { + get { + settings.startupCommand + } + set { + settings.startupCommand = newValue + store.save(settings, for: rootURL) + } + } + + init(rootURL: URL, store: RepositorySettingsStore = RepositorySettingsStore()) { + self.rootURL = rootURL + self.store = store + settings = store.load(for: rootURL) + } +} diff --git a/supacode/Settings/RepositorySettingsStore.swift b/supacode/Settings/RepositorySettingsStore.swift new file mode 100644 index 00000000..9fd089b0 --- /dev/null +++ b/supacode/Settings/RepositorySettingsStore.swift @@ -0,0 +1,33 @@ +import Foundation + +nonisolated struct RepositorySettingsStore { + func load(for rootURL: URL) -> RepositorySettings { + let settingsURL = settingsURL(for: rootURL) + if let data = try? Data(contentsOf: settingsURL), + let settings = try? JSONDecoder().decode(RepositorySettings.self, from: data) + { + return settings + } + let defaults = RepositorySettings.default + save(defaults, for: rootURL) + return defaults + } + + func save(_ settings: RepositorySettings, for rootURL: URL) { + do { + let settingsURL = settingsURL(for: rootURL) + let directory = settingsURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(settings) + try data.write(to: settingsURL, options: [.atomic]) + } catch { + } + } + + private func settingsURL(for rootURL: URL) -> URL { + SupacodePaths.repositoryDirectory(for: rootURL) + .appending(path: "settings.json", directoryHint: .notDirectory) + } +} diff --git a/supacode/Settings/RepositorySettingsView.swift b/supacode/Settings/RepositorySettingsView.swift new file mode 100644 index 00000000..aa142134 --- /dev/null +++ b/supacode/Settings/RepositorySettingsView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct RepositorySettingsView: View { + let repositoryRootURL: URL + @State private var model: RepositorySettingsModel + + init(repositoryRootURL: URL) { + self.repositoryRootURL = repositoryRootURL + _model = State(initialValue: RepositorySettingsModel(rootURL: repositoryRootURL)) + } + + var body: some View { + @Bindable var model = model + + Form { + Section { + TextField("Start up command", text: $model.startupCommand, prompt: Text("echo 123")) + } + } + .formStyle(.grouped) + .scenePadding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} diff --git a/supacode/Settings/UpdatesSettingsView.swift b/supacode/Settings/UpdatesSettingsView.swift index ea3c842e..797fdd9d 100644 --- a/supacode/Settings/UpdatesSettingsView.swift +++ b/supacode/Settings/UpdatesSettingsView.swift @@ -9,7 +9,9 @@ struct UpdatesSettingsView: View { VStack(alignment: .leading, spacing: 0) { Form { Section("Automatic Updates") { - Toggle("Check for updates automatically", isOn: $updateController.automaticallyChecksForUpdates) + Toggle( + "Check for updates automatically", isOn: $updateController.automaticallyChecksForUpdates + ) Toggle( "Download and install updates automatically", isOn: $updateController.automaticallyDownloadsUpdates diff --git a/supacode/SupacodePaths.swift b/supacode/SupacodePaths.swift index 31ea2406..738db952 100644 --- a/supacode/SupacodePaths.swift +++ b/supacode/SupacodePaths.swift @@ -10,6 +10,13 @@ nonisolated enum SupacodePaths { baseDirectory.appending(path: "repos", directoryHint: .isDirectory) } + static func repositoryDirectory(for rootURL: URL) -> URL { + let repoName = rootURL.lastPathComponent + let fallback = rootURL.path(percentEncoded: false).replacing("/", with: "_") + let name = repoName.isEmpty ? fallback : repoName + return reposDirectory.appending(path: name, directoryHint: .isDirectory) + } + static var settingsURL: URL { baseDirectory.appending(path: "settings.json", directoryHint: .notDirectory) } diff --git a/supacode/Terminals/WorktreeTerminalState.swift b/supacode/Terminals/WorktreeTerminalState.swift index 5e66efff..2d25f0d4 100644 --- a/supacode/Terminals/WorktreeTerminalState.swift +++ b/supacode/Terminals/WorktreeTerminalState.swift @@ -6,6 +6,7 @@ final class WorktreeTerminalState: BonsplitDelegate { let controller: BonsplitController private let runtime: GhosttyRuntime private let worktree: Worktree + private let settingsStore = RepositorySettingsStore() private var surfaces: [TabID: GhosttySurfaceView] = [:] init(runtime: GhosttyRuntime, worktree: Worktree) { @@ -52,7 +53,7 @@ final class WorktreeTerminalState: BonsplitDelegate { return nil } controller.selectTab(tabId) - surfaceView(for: tabId, initialInput: "echo \(title)\n").requestFocus() + surfaceView(for: tabId, initialInput: startupInput()).requestFocus() return tabId } @@ -74,7 +75,7 @@ final class WorktreeTerminalState: BonsplitDelegate { if let existing = surfaces[tabId] { return existing } - let resolvedInput = initialInput ?? defaultInitialInput(for: tabId) + let resolvedInput = initialInput ?? defaultInitialInput() let view = GhosttySurfaceView( runtime: runtime, workingDirectory: worktree.workingDirectory, @@ -87,9 +88,20 @@ final class WorktreeTerminalState: BonsplitDelegate { return view } - private func defaultInitialInput(for tabId: TabID) -> String? { - guard let title = controller.tab(tabId)?.title else { return nil } - return "echo \(title)\n" + private func defaultInitialInput() -> String? { + startupInput() + } + + private func startupInput() -> String? { + let settings = settingsStore.load(for: worktree.repositoryRootURL) + let command = settings.startupCommand + if command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return nil + } + if command.hasSuffix("\n") { + return command + } + return "\(command)\n" } func closeAllSurfaces() { diff --git a/supacode/WindowIdentifiers.swift b/supacode/WindowIdentifiers.swift new file mode 100644 index 00000000..c11b287e --- /dev/null +++ b/supacode/WindowIdentifiers.swift @@ -0,0 +1,3 @@ +enum WindowIdentifiers { + static let repoSettings = "repo-settings" +} diff --git a/supacode/Worktree.swift b/supacode/Worktree.swift index c9a55943..68278c14 100644 --- a/supacode/Worktree.swift +++ b/supacode/Worktree.swift @@ -5,4 +5,5 @@ struct Worktree: Identifiable, Hashable { let name: String let detail: String let workingDirectory: URL + let repositoryRootURL: URL } diff --git a/supacode/supacodeApp.swift b/supacode/supacodeApp.swift index 67b56f0b..f5b01d3b 100644 --- a/supacode/supacodeApp.swift +++ b/supacode/supacodeApp.swift @@ -5,6 +5,7 @@ // Created by khoi on 20/1/26. // +import Foundation import GhosttyKit import SwiftUI @@ -53,6 +54,16 @@ struct SupacodeApp: App { .preferredColorScheme(settings.preferredColorScheme) } .environment(repositoryStore) + WindowGroup("Repo Settings", id: WindowIdentifiers.repoSettings, for: Repository.ID.self) { $repositoryID in + if let repositoryID { + RepositorySettingsView(repositoryRootURL: URL(fileURLWithPath: repositoryID)) + } else { + Text("Select a repository to edit settings.") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .scenePadding() + } + } + .environment(repositoryStore) .commands { OpenRepositoryCommands(repositoryStore: repositoryStore) WorktreeCommands(repositoryStore: repositoryStore)