diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ae86d408a..2a868ad7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tables from every schema in Open Quickly and the sidebar filter, and `schema.table` searches in both. (#3048) - Recent-tab switching on Control-Tab, with a list of the window's tabs while Control is held. (#2524) - **Extensions** for SQLite and local libSQL connections, loading sqlite-vec, SpatiaLite and other libraries on connect. (#2502) +- Version history for saved queries, with **Restore This Version**. (#2505) +- Git status letters, history and **Discard Changes…** for files in a linked SQL folder. (#2505) ### Changed diff --git a/TablePro/Core/Events/AppEvents.swift b/TablePro/Core/Events/AppEvents.swift index 787b557672..5c10196086 100644 --- a/TablePro/Core/Events/AppEvents.swift +++ b/TablePro/Core/Events/AppEvents.swift @@ -115,6 +115,8 @@ final class AppEvents { /// uniformly handle "this update may affect me" via `payload == nil || payload == self.connectionId`. let linkedSQLFoldersDidUpdate = PassthroughSubject() + let versionHistoryRefreshRequested = PassthroughSubject() + // MARK: - License & Sync let licenseStatusDidChange = PassthroughSubject() diff --git a/TablePro/Core/Git/GitClient.swift b/TablePro/Core/Git/GitClient.swift new file mode 100644 index 0000000000..e81f6e3938 --- /dev/null +++ b/TablePro/Core/Git/GitClient.swift @@ -0,0 +1,73 @@ +// +// GitClient.swift +// TablePro +// + +import Foundation +import os + +internal struct GitCommandFailure: LocalizedError, Equatable { + let exitCode: Int32 + let message: String + + var errorDescription: String? { + message.isEmpty ? String(format: String(localized: "Git exited with status %d."), exitCode) : message + } +} + +internal struct GitClient: Sendable { + private static let logger = Logger(subsystem: "com.TablePro", category: "GitClient") + + let runner: GitProcessRunner + + static func make(locator: GitExecutableLocator = .system) -> GitClient? { + locator.locate().map { GitClient(runner: GitProcessRunner(executableURL: $0)) } + } + + func repositoryInfo(in directory: URL) async throws -> GitRepositoryInfo? { + let result = try await runner.run(.repositoryInfo(in: directory)) + guard result.succeeded else { + Self.logger.debug("Not a work tree: \(result.errorMessage, privacy: .private)") + return nil + } + return GitRepositoryInfoParser.parse(result.standardOutput) + } + + func hasCommits(in directory: URL) async throws -> Bool { + try await headCommit(in: directory) != nil + } + + func headCommit(in directory: URL) async throws -> String? { + let result = try await runner.run(.verifyHead(in: directory)) + guard result.succeeded else { return nil } + return (String(bytes: result.standardOutput, encoding: .utf8) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + func status(in directory: URL, pathspec: String = ".") async throws -> [GitStatusRecord] { + let result = try await runner.run(.status(in: directory, pathspec: pathspec)) + guard result.succeeded else { throw failure(result) } + return GitStatusParser.parse(result.standardOutput) + } + + func trackedFiles(in directory: URL) async throws -> [String] { + let result = try await runner.run(.trackedFiles(in: directory)) + guard result.succeeded else { throw failure(result) } + return GitOutputTokens.split(result.standardOutput, separator: 0).filter { !$0.isEmpty } + } + + func history(of fileURL: URL, limit: Int) async throws -> [GitCommitRecord] { + let result = try await runner.run(.fileHistory(of: fileURL, limit: limit)) + guard result.succeeded else { throw failure(result) } + return GitLogParser.parse(result.standardOutput) + } + + func blob(revision: String, path: String, in directory: URL) async throws -> Data { + let result = try await runner.run(.blob(revision: revision, path: path, in: directory)) + guard result.succeeded else { throw failure(result) } + return result.standardOutput + } + + private func failure(_ result: GitProcessResult) -> GitCommandFailure { + GitCommandFailure(exitCode: result.exitCode, message: result.errorMessage) + } +} diff --git a/TablePro/Core/Git/GitCommand.swift b/TablePro/Core/Git/GitCommand.swift new file mode 100644 index 0000000000..c9fef8c942 --- /dev/null +++ b/TablePro/Core/Git/GitCommand.swift @@ -0,0 +1,91 @@ +// +// GitCommand.swift +// TablePro +// + +import Foundation + +internal struct GitCommand: Equatable, Sendable { + let arguments: [String] + let workingDirectory: URL + + static let hardeningArguments: [String] = [ + "--no-optional-locks", + "--no-pager", + "--literal-pathspecs", + "-c", "core.fsmonitor=false", + "-c", "core.hooksPath=/dev/null", + "-c", "color.ui=never", + "-c", "log.showSignature=false", + "-c", "safe.bareRepository=explicit", + ] + + static let environmentOverrides: [String: String] = [ + "GIT_OPTIONAL_LOCKS": "0", + "GIT_TERMINAL_PROMPT": "0", + "GIT_PAGER": "cat", + "GIT_NO_LAZY_FETCH": "1", + ] + + static let inheritedRepositoryVariables: Set = [ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_NAMESPACE", + "GIT_CEILING_DIRECTORIES", + "GIT_CONFIG_PARAMETERS", + "GIT_CONFIG_COUNT", + "GIT_EXTERNAL_DIFF", + ] + + static let historyFormat = "%x1e%H%x1f%an%x1f%aI%x1f%s" + + var processArguments: [String] { + Self.hardeningArguments + arguments + } + + static func environment(base: [String: String]) -> [String: String] { + var environment = base.filter { !inheritedRepositoryVariables.contains($0.key) } + environment.merge(environmentOverrides) { _, override in override } + return environment + } + + static func repositoryInfo(in directory: URL) -> GitCommand { + GitCommand( + arguments: ["rev-parse", "--show-toplevel", "--absolute-git-dir", "--show-prefix"], + workingDirectory: directory + ) + } + + static func verifyHead(in directory: URL) -> GitCommand { + GitCommand(arguments: ["rev-parse", "--verify", "--quiet", "HEAD"], workingDirectory: directory) + } + + static func status(in directory: URL, pathspec: String = ".") -> GitCommand { + GitCommand( + arguments: ["status", "--porcelain=v2", "-z", "--untracked-files=all", "--find-renames", "--", pathspec], + workingDirectory: directory + ) + } + + static func trackedFiles(in directory: URL) -> GitCommand { + GitCommand(arguments: ["ls-files", "-z", "--full-name", "--", "."], workingDirectory: directory) + } + + static func fileHistory(of fileURL: URL, limit: Int) -> GitCommand { + GitCommand( + arguments: [ + "log", "--follow", "-z", "--no-color", "--no-show-signature", "--name-status", + "--max-count=\(limit)", "--format=\(historyFormat)", "--", fileURL.lastPathComponent, + ], + workingDirectory: fileURL.deletingLastPathComponent() + ) + } + + static func blob(revision: String, path: String, in directory: URL) -> GitCommand { + GitCommand(arguments: ["cat-file", "blob", "--end-of-options", "\(revision):\(path)"], workingDirectory: directory) + } +} diff --git a/TablePro/Core/Git/GitExecutableLocator.swift b/TablePro/Core/Git/GitExecutableLocator.swift new file mode 100644 index 0000000000..2e5f2490d3 --- /dev/null +++ b/TablePro/Core/Git/GitExecutableLocator.swift @@ -0,0 +1,42 @@ +// +// GitExecutableLocator.swift +// TablePro +// + +import Foundation + +internal struct GitExecutableLocator: Sendable { + static let developerDirectoryLink = "/var/db/xcode_select_link" + static let homebrewCandidates = ["/opt/homebrew/bin/git", "/usr/local/bin/git"] + static let commandLineToolsGit = "/Library/Developer/CommandLineTools/usr/bin/git" + static let defaultXcodeGit = "/Applications/Xcode.app/Contents/Developer/usr/bin/git" + static let installerShim = "/usr/bin/git" + + let isExecutable: @Sendable (String) -> Bool + let developerDirectory: @Sendable () -> String? + + static let system = GitExecutableLocator( + isExecutable: { FileManager.default.isExecutableFile(atPath: $0) }, + developerDirectory: { try? FileManager.default.destinationOfSymbolicLink(atPath: developerDirectoryLink) } + ) + + func candidates() -> [String] { + var paths = Self.homebrewCandidates + if let developerDirectory = developerDirectory(), developerDirectory.hasPrefix("/") { + paths.append((developerDirectory as NSString).appendingPathComponent("usr/bin/git")) + } + paths.append(Self.commandLineToolsGit) + paths.append(Self.defaultXcodeGit) + + var seen: Set = [] + return paths.filter { path in + let standardized = (path as NSString).standardizingPath + guard standardized != Self.installerShim else { return false } + return seen.insert(standardized).inserted + } + } + + func locate() -> URL? { + candidates().first(where: isExecutable).map { URL(fileURLWithPath: $0) } + } +} diff --git a/TablePro/Core/Git/GitFileStatus.swift b/TablePro/Core/Git/GitFileStatus.swift new file mode 100644 index 0000000000..7013e9e429 --- /dev/null +++ b/TablePro/Core/Git/GitFileStatus.swift @@ -0,0 +1,126 @@ +// +// GitFileStatus.swift +// TablePro +// + +import Foundation + +internal struct GitFileStatus: Hashable, Sendable { + internal enum Change: Hashable, Sendable { + case unmodified + case modified + case added + case deleted + case renamed + case copied + case typeChanged + case unmerged + + init(code: Character) { + switch code { + case "M": self = .modified + case "A": self = .added + case "D": self = .deleted + case "R": self = .renamed + case "C": self = .copied + case "T": self = .typeChanged + case "U": self = .unmerged + default: self = .unmodified + } + } + } + + internal enum Badge: Hashable, Sendable { + case modified + case added + case renamed + case untracked + case conflicted + + var letter: String { + switch self { + case .modified: return "M" + case .added: return "A" + case .renamed: return "R" + case .untracked: return "U" + case .conflicted: return "!" + } + } + + var label: String { + switch self { + case .modified: return String(localized: "Modified") + case .added: return String(localized: "Added") + case .renamed: return String(localized: "Renamed") + case .untracked: return String(localized: "Untracked") + case .conflicted: return String(localized: "Conflicted") + } + } + } + + let staged: Change + let unstaged: Change + let isUntracked: Bool + let isConflicted: Bool + + static let untracked = GitFileStatus(staged: .unmodified, unstaged: .unmodified, isUntracked: true) + + init(staged: Change, unstaged: Change, isUntracked: Bool = false, isUnmergedEntry: Bool = false) { + self.staged = staged + self.unstaged = unstaged + self.isUntracked = isUntracked + self.isConflicted = isUnmergedEntry || staged == .unmerged || unstaged == .unmerged + } + + init(code: Substring, isUnmergedEntry: Bool = false) { + let characters = Array(code) + self.init( + staged: Change(code: characters.first ?? "."), + unstaged: Change(code: characters.count > 1 ? characters[1] : "."), + isUnmergedEntry: isUnmergedEntry + ) + } + + var hasStagedChanges: Bool { + !isUntracked && staged != .unmodified + } + + var hasUnstagedChanges: Bool { + isUntracked || unstaged != .unmodified + } + + var badge: Badge { + if isConflicted { return .conflicted } + if isUntracked { return .untracked } + switch staged { + case .added: return .added + case .renamed, .copied: return .renamed + default: return .modified + } + } + + var canDiscardChanges: Bool { + guard !isUntracked, !isConflicted else { return false } + return unstaged == .modified || unstaged == .typeChanged + } + + var hasCommittedHistory: Bool { + guard !isUntracked else { return false } + switch staged { + case .added, .renamed, .copied: return false + default: return true + } + } + + var accessibilityDescription: String { + guard !isUntracked, !isConflicted else { return badge.label } + switch (hasStagedChanges, unstaged != .unmodified) { + case (true, false): + return String(format: String(localized: "%@, staged"), badge.label) + case (true, true): + return String(format: String(localized: "%@, partly staged"), badge.label) + default: + return badge.label + } + } +} diff --git a/TablePro/Core/Git/GitOutputParsers.swift b/TablePro/Core/Git/GitOutputParsers.swift new file mode 100644 index 0000000000..4d512dcc28 --- /dev/null +++ b/TablePro/Core/Git/GitOutputParsers.swift @@ -0,0 +1,149 @@ +// +// GitOutputParsers.swift +// TablePro +// + +import Foundation + +internal struct GitStatusRecord: Equatable, Sendable { + let path: String + let originalPath: String? + let status: GitFileStatus +} + +internal struct GitRepositoryInfo: Equatable, Sendable { + let topLevel: URL + let gitDirectory: URL + let prefix: String +} + +internal struct GitCommitRecord: Equatable, Sendable { + let hash: String + let author: String + let date: Date? + let subject: String + let path: String? +} + +internal enum GitStatusParser { + static func parse(_ data: Data) -> [GitStatusRecord] { + let tokens = GitOutputTokens.split(data, separator: 0) + var records: [GitStatusRecord] = [] + var index = 0 + while index < tokens.count { + let token = tokens[index] + index += 1 + guard let kind = token.first else { continue } + switch kind { + case "1": + if let record = trackedRecord(token, fieldCount: 8) { records.append(record) } + case "2": + let originalPath = index < tokens.count ? tokens[index] : nil + index += 1 + if let record = trackedRecord(token, fieldCount: 9, originalPath: originalPath) { + records.append(record) + } + case "u": + if let record = trackedRecord(token, fieldCount: 10, isUnmergedEntry: true) { records.append(record) } + case "?": + let path = String(token.dropFirst(2)) + guard !path.isEmpty else { continue } + records.append(GitStatusRecord(path: path, originalPath: nil, status: .untracked)) + default: + continue + } + } + return records + } + + private static func trackedRecord( + _ token: String, + fieldCount: Int, + originalPath: String? = nil, + isUnmergedEntry: Bool = false + ) -> GitStatusRecord? { + let fields = token.split(separator: " ", maxSplits: fieldCount, omittingEmptySubsequences: false) + guard fields.count == fieldCount + 1 else { return nil } + let path = String(fields[fieldCount]) + guard !path.isEmpty else { return nil } + return GitStatusRecord( + path: path, + originalPath: originalPath, + status: GitFileStatus(code: fields[1], isUnmergedEntry: isUnmergedEntry) + ) + } +} + +internal enum GitRepositoryInfoParser { + static func parse(_ data: Data) -> GitRepositoryInfo? { + let lines = (String(bytes: data, encoding: .utf8) ?? "") + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + guard lines.count >= 2, !lines[0].isEmpty, !lines[1].isEmpty else { return nil } + return GitRepositoryInfo( + topLevel: URL(fileURLWithPath: lines[0], isDirectory: true), + gitDirectory: URL(fileURLWithPath: lines[1], isDirectory: true), + prefix: lines.count > 2 ? lines[2] : "" + ) + } +} + +internal enum GitLogParser { + private static let recordSeparator: Character = "\u{1E}" + private static let fieldSeparator: Character = "\u{1F}" + + static func parse(_ data: Data) -> [GitCommitRecord] { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime] + var commits: [GitCommitRecord] = [] + var header: [Substring]? + var paths: [String] = [] + var changeStatus = "" + var expectsStatus = false + + func flush() { + guard let header, header.count >= 3, isCommitHash(header[0]), !changeStatus.hasPrefix("D") else { return } + commits.append(GitCommitRecord( + hash: String(header[0]), + author: String(header[1]), + date: dateFormatter.date(from: String(header[2])), + subject: header.count > 3 ? String(header[3]) : "", + path: paths.last + )) + } + + for token in GitOutputTokens.split(data, separator: 0) { + if token.first == recordSeparator { + flush() + header = token.dropFirst().split(separator: fieldSeparator, maxSplits: 3, omittingEmptySubsequences: false) + paths = [] + changeStatus = "" + expectsStatus = true + continue + } + let trimmed = token.trimmingCharacters(in: .newlines) + guard !trimmed.isEmpty else { continue } + if expectsStatus { + expectsStatus = false + changeStatus = trimmed + continue + } + paths.append(trimmed) + } + flush() + return commits + } + + static func isCommitHash(_ value: Substring) -> Bool { + guard value.count == 40 || value.count == 64 else { return false } + return value.allSatisfy(\.isHexDigit) + } +} + +internal enum GitOutputTokens { + static func split(_ data: Data, separator: UInt8) -> [String] { + data.split(separator: separator, omittingEmptySubsequences: false).map { bytes in + String(bytes: bytes, encoding: .utf8) ?? String(bytes: bytes, encoding: .isoLatin1) ?? "" + } + } +} diff --git a/TablePro/Core/Git/GitProcessRunner.swift b/TablePro/Core/Git/GitProcessRunner.swift new file mode 100644 index 0000000000..d133f64307 --- /dev/null +++ b/TablePro/Core/Git/GitProcessRunner.swift @@ -0,0 +1,149 @@ +// +// GitProcessRunner.swift +// TablePro +// + +import Foundation +import os + +internal struct GitProcessResult: Sendable { + let exitCode: Int32 + let standardOutput: Data + let standardError: Data + + var succeeded: Bool { exitCode == 0 } + + var errorMessage: String { + (String(bytes: standardError, encoding: .utf8) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +internal enum GitProcessError: Error, Equatable { + case launchFailed(String) + case timedOut + case outputTooLarge +} + +internal struct GitProcessRunner: Sendable { + private static let logger = Logger(subsystem: "com.TablePro", category: "GitProcessRunner") + + static let defaultTimeout: TimeInterval = 20 + static let defaultOutputLimit = 128 * 1_024 * 1_024 + static let errorOutputLimit = 64 * 1_024 + + let executableURL: URL + var timeout: TimeInterval = defaultTimeout + var outputLimit: Int = defaultOutputLimit + + func run(_ command: GitCommand) async throws -> GitProcessResult { + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + continuation.resume(with: Result { try runSynchronously(command) }) + } + } + } + + private func runSynchronously(_ command: GitCommand) throws -> GitProcessResult { + let process = Process() + process.executableURL = executableURL + process.arguments = command.processArguments + process.currentDirectoryURL = command.workingDirectory + process.environment = GitCommand.environment(base: ProcessInfo.processInfo.environment) + process.standardInput = FileHandle.nullDevice + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + do { + try process.run() + } catch { + throw GitProcessError.launchFailed(error.localizedDescription) + } + + let handle = RunningProcess(process) + let output = BoundedBuffer(limit: outputLimit) + let errors = BoundedBuffer(limit: Self.errorOutputLimit) + let readers = DispatchGroup() + DispatchQueue.global(qos: .utility).async(group: readers) { + Self.drain(outputPipe.fileHandleForReading, into: output) { handle.terminate() } + } + DispatchQueue.global(qos: .utility).async(group: readers) { + Self.drain(errorPipe.fileHandleForReading, into: errors, onOverflow: nil) + } + + guard readers.wait(timeout: .now() + timeout) == .success else { + handle.terminate() + _ = readers.wait(timeout: .now() + 2) + Self.logger.warning("git \(command.arguments.first ?? "", privacy: .public) timed out after \(timeout, privacy: .public)s") + throw GitProcessError.timedOut + } + process.waitUntilExit() + + guard !output.overflowed else { throw GitProcessError.outputTooLarge } + return GitProcessResult( + exitCode: process.terminationStatus, + standardOutput: output.data, + standardError: errors.data + ) + } + + private static func drain(_ handle: FileHandle, into buffer: BoundedBuffer, onOverflow: (@Sendable () -> Void)?) { + while true { + let chunk: Data + do { + chunk = try handle.read(upToCount: 64 * 1_024) ?? Data() + } catch { + return + } + guard !chunk.isEmpty else { return } + guard buffer.append(chunk) || onOverflow == nil else { + onOverflow?() + return + } + } + } +} + +private final class RunningProcess: @unchecked Sendable { + private let process: Process + + init(_ process: Process) { + self.process = process + } + + func terminate() { + guard process.isRunning else { return } + process.terminate() + } +} + +private final class BoundedBuffer: @unchecked Sendable { + private let lock = NSLock() + private let limit: Int + private var storage = Data() + private var didOverflow = false + + init(limit: Int) { + self.limit = limit + } + + var data: Data { + lock.withLock { storage } + } + + var overflowed: Bool { + lock.withLock { didOverflow } + } + + func append(_ chunk: Data) -> Bool { + lock.withLock { + guard storage.count + chunk.count <= limit else { + didOverflow = true + return false + } + storage.append(chunk) + return true + } + } +} diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift b/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift index 8a9dbdeb81..bb36a9ade9 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift @@ -138,6 +138,7 @@ private extension TabType { case .insights: "insights" case .usersRoles: "usersRoles" case .objectSource: "objectSource" + case .versionHistory: "versionHistory" } } } diff --git a/TablePro/Core/Scripting/ScriptEnumerations.swift b/TablePro/Core/Scripting/ScriptEnumerations.swift index a345040bb9..6ec52c90ea 100644 --- a/TablePro/Core/Scripting/ScriptEnumerations.swift +++ b/TablePro/Core/Scripting/ScriptEnumerations.swift @@ -42,6 +42,7 @@ internal enum ScriptEnumerations { case .usersRoles: fourCharCode("TPk6") case .insights: fourCharCode("TPk7") case .objectSource: fourCharCode("TPk8") + case .versionHistory: fourCharCode("TPk9") } } diff --git a/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift b/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift index 71d3638cbf..ba7a4bac0b 100644 --- a/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift +++ b/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift @@ -87,6 +87,9 @@ internal enum EditorTabOpener { case .objectSource: guard let objectRef = payload.objectRef else { return } tabManager.addObjectSourceTab(objectRef: objectRef) + case .versionHistory: + guard let subject = payload.versionHistorySubject else { return } + tabManager.addVersionHistoryTab(subject: subject, title: payload.tabTitle ?? QueryTabManager.versionHistoryFallbackTitle) } } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index d1052f7ac2..e1ee794a1a 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -1342,7 +1342,7 @@ internal final class MainSplitViewController: NSSplitViewController { switch tabType { case .usersRoles: return UsersRolesLayoutMetrics.tabMinimumWidth - case .query, .table, .createTable, .erDiagram, .serverDashboard, .insights, .objectSource: + case .query, .table, .createTable, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory: return defaultDetailMinThickness } } diff --git a/TablePro/Core/Services/Infrastructure/SQLFileService.swift b/TablePro/Core/Services/Infrastructure/SQLFileService.swift index 1c984303da..5c31a314d3 100644 --- a/TablePro/Core/Services/Infrastructure/SQLFileService.swift +++ b/TablePro/Core/Services/Infrastructure/SQLFileService.swift @@ -37,6 +37,12 @@ enum SQLFileService { }.value } + static func writeData(_ data: Data, to url: URL) async throws { + try await Task.detached { + try data.write(to: url, options: .atomic) + }.value + } + /// Shows a save panel for .sql files. @MainActor static func showSavePanel(suggestedName: String = "query.sql") async -> URL? { diff --git a/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift b/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift index 292a85583f..fd1c0c6536 100644 --- a/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift +++ b/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift @@ -60,7 +60,7 @@ internal enum ToolbarContextResolver { String(localized: "Create Table") case .usersRoles: String(localized: "Apply Changes") - case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, nil: + case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, nil: String(localized: "Save Changes") } } @@ -93,8 +93,8 @@ internal enum ToolbarContextResolver { case .createTable: /// A definition that is not on the server yet has nothing to reload. return [MainWindowToolbar.refresh] - case .erDiagram, .serverDashboard, .insights, .objectSource: - /// None of these four can stage a change, so the commit control could only ever be dim. + case .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory: + /// None of these can stage a change, so the commit control could only ever be dim. return [MainWindowToolbar.saveChanges] case .query, .table, .usersRoles: return [] diff --git a/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift b/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift index 2adad5ac95..5b643970d7 100644 --- a/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift +++ b/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift @@ -155,6 +155,11 @@ enum WindowTitleResolver { return explicitTitle } return String(localized: "Source") + case .versionHistory: + if let explicitTitle, !explicitTitle.isBlank { + return explicitTitle + } + return QueryTabManager.versionHistoryFallbackTitle default: break } diff --git a/TablePro/Core/Services/SQL/GitRepositoryWatcher.swift b/TablePro/Core/Services/SQL/GitRepositoryWatcher.swift new file mode 100644 index 0000000000..3c2cb7be52 --- /dev/null +++ b/TablePro/Core/Services/SQL/GitRepositoryWatcher.swift @@ -0,0 +1,65 @@ +// +// GitRepositoryWatcher.swift +// TablePro +// + +import CoreServices +import Foundation +import os + +@MainActor +internal final class GitRepositoryWatcher { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "GitRepositoryWatcher") + + var onChange: (() -> Void)? + + private var eventStream: FSEventStreamRef? + private var watchedPaths: Set = [] + + nonisolated private static let eventCallback: FSEventStreamCallback = { _, info, _, _, _, _ in + guard let info else { return } + let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() + Task { @MainActor in + watcher.onChange?() + } + } + + func watch(_ paths: Set) { + guard paths != watchedPaths else { return } + cancel() + watchedPaths = paths + guard !paths.isEmpty else { return } + + var context = FSEventStreamContext( + version: 0, + info: Unmanaged.passUnretained(self).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + guard let stream = FSEventStreamCreate( + kCFAllocatorDefault, + Self.eventCallback, + &context, + Array(paths) as CFArray, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 0.5, + UInt32(kFSEventStreamCreateFlagNoDefer | kFSEventStreamCreateFlagWatchRoot) + ) else { + Self.logger.error("Failed to create the Git repository event stream") + return + } + FSEventStreamSetDispatchQueue(stream, .global(qos: .utility)) + FSEventStreamStart(stream) + eventStream = stream + } + + func cancel() { + watchedPaths = [] + guard let stream = eventStream else { return } + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + eventStream = nil + } +} diff --git a/TablePro/Core/Services/SQL/LinkedFolderGitStatusStore.swift b/TablePro/Core/Services/SQL/LinkedFolderGitStatusStore.swift new file mode 100644 index 0000000000..7359fa8148 --- /dev/null +++ b/TablePro/Core/Services/SQL/LinkedFolderGitStatusStore.swift @@ -0,0 +1,152 @@ +// +// LinkedFolderGitStatusStore.swift +// TablePro +// + +import AppKit +import Combine +import Foundation +import os + +internal enum LinkedFileGitState: Equatable, Sendable { + case clean + case changed(GitFileStatus) + + var status: GitFileStatus? { + guard case .changed(let status) = self else { return nil } + return status + } + + var hasCommittedHistory: Bool { + status?.hasCommittedHistory ?? true + } + + var canDiscardChanges: Bool { + status?.canDiscardChanges ?? false + } +} + +internal struct LinkedFolderGitSnapshot: Equatable, Sendable { + let repository: GitRepositoryInfo + let head: String? + let statuses: [String: GitFileStatus] + let trackedPaths: Set + + func state(forRelativePath path: String) -> LinkedFileGitState? { + if let status = statuses[path] { + return .changed(status) + } + return trackedPaths.contains(path) ? .clean : nil + } +} + +@MainActor +internal final class LinkedFolderGitStatusStore: ObservableObject { + static let shared = LinkedFolderGitStatusStore() + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "LinkedFolderGitStatus") + + @Published private(set) var snapshots: [UUID: LinkedFolderGitSnapshot] = [:] + + private let clientFactory: @Sendable () -> GitClient? + private let foldersProvider: @MainActor () -> [LinkedSQLFolder] + private let repositoryWatcher = GitRepositoryWatcher() + private var cancellables: Set = [] + private var refreshTask: Task? + private var hasStarted = false + + init( + clientFactory: @escaping @Sendable () -> GitClient? = { GitClient.make() }, + foldersProvider: @escaping @MainActor () -> [LinkedSQLFolder] = { LinkedSQLFolderStorage.shared.loadFolders() } + ) { + self.clientFactory = clientFactory + self.foldersProvider = foldersProvider + } + + func start() { + guard !hasStarted else { return } + hasStarted = true + AppEvents.shared.linkedSQLFoldersDidUpdate + .sink { [weak self] _ in self?.scheduleRefresh() } + .store(in: &cancellables) + NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification) + .sink { [weak self] _ in self?.scheduleRefresh() } + .store(in: &cancellables) + repositoryWatcher.onChange = { [weak self] in self?.scheduleRefresh() } + scheduleRefresh(after: .zero) + } + + func state(for favorite: LinkedSQLFavorite) -> LinkedFileGitState? { + snapshots[favorite.folderId]?.state(forRelativePath: favorite.relativePath) + } + + func states(for favorites: [LinkedSQLFavorite]) -> [UUID: LinkedFileGitState] { + var states: [UUID: LinkedFileGitState] = [:] + for favorite in favorites { + states[favorite.id] = state(for: favorite) + } + return states + } + + func scheduleRefresh(after delay: Duration = .milliseconds(400)) { + refreshTask?.cancel() + refreshTask = Task { [weak self] in + do { + try await Task.sleep(for: delay) + } catch { + return + } + await self?.refresh() + } + } + + func refresh() async { + let folders = foldersProvider().filter(\.isEnabled) + let loaded = await Self.loadSnapshots(folders: folders, client: clientFactory(), previous: snapshots) + guard !Task.isCancelled else { return } + if loaded != snapshots { + snapshots = loaded + } + repositoryWatcher.watch(Set(loaded.values.map(\.repository.gitDirectory.path))) + } + + nonisolated static func loadSnapshots( + folders: [LinkedSQLFolder], + client: GitClient?, + previous: [UUID: LinkedFolderGitSnapshot] + ) async -> [UUID: LinkedFolderGitSnapshot] { + guard let client else { return [:] } + var loaded: [UUID: LinkedFolderGitSnapshot] = [:] + for folder in folders { + let directory = folder.expandedURL + do { + guard let repository = try await client.repositoryInfo(in: directory) else { continue } + let records = try await client.status(in: directory) + let tracked = try await client.trackedFiles(in: directory) + loaded[folder.id] = LinkedFolderGitSnapshot( + repository: repository, + head: try await client.headCommit(in: directory), + statuses: folderRelativeStatuses(records, prefix: repository.prefix), + trackedPaths: folderRelativePaths(tracked, prefix: repository.prefix) + ) + } catch { + logger.warning("Git status failed for a linked folder: \(error.publicLogShape, privacy: .public)") + if let kept = previous[folder.id] { + loaded[folder.id] = kept + } + } + } + return loaded + } + + nonisolated static func folderRelativeStatuses(_ records: [GitStatusRecord], prefix: String) -> [String: GitFileStatus] { + var statuses: [String: GitFileStatus] = [:] + for record in records where record.path.hasPrefix(prefix) { + statuses[String(record.path.dropFirst(prefix.count))] = record.status + } + return statuses + } + + nonisolated static func folderRelativePaths(_ paths: [String], prefix: String) -> Set { + Set(paths.filter { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) }) + } +} diff --git a/TablePro/Core/Services/SQL/SQLFolderWatcher.swift b/TablePro/Core/Services/SQL/SQLFolderWatcher.swift index 98cab5b488..7fe8639279 100644 --- a/TablePro/Core/Services/SQL/SQLFolderWatcher.swift +++ b/TablePro/Core/Services/SQL/SQLFolderWatcher.swift @@ -19,14 +19,23 @@ internal final class SQLFolderWatcher: ObservableObject { private var debounceTask: Task? private var hasStarted = false - nonisolated private static let eventCallback: FSEventStreamCallback = { _, info, _, _, _, _ in + nonisolated private static let eventCallback: FSEventStreamCallback = { _, info, _, eventPaths, _, _ in guard let info else { return } + let paths = (Unmanaged.fromOpaque(eventPaths).takeUnretainedValue() as NSArray) as? [String] ?? [] + guard !touchesOnlyRepositoryMetadata(paths) else { return } let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() Task { @MainActor in watcher.scheduleDebouncedRescan() } } + nonisolated static func touchesOnlyRepositoryMetadata(_ paths: [String]) -> Bool { + guard !paths.isEmpty else { return false } + return paths.allSatisfy { path in + path.split(separator: "/").contains(".git") + } + } + private init() {} func start() { @@ -68,6 +77,7 @@ internal final class SQLFolderWatcher: ObservableObject { kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagNoDefer | kFSEventStreamCreateFlagWatchRoot + | kFSEventStreamCreateFlagUseCFTypes ) guard let stream = FSEventStreamCreate( diff --git a/TablePro/Core/Storage/SQLFavoriteManager.swift b/TablePro/Core/Storage/SQLFavoriteManager.swift index 5be28fc1c1..596bd5cc23 100644 --- a/TablePro/Core/Storage/SQLFavoriteManager.swift +++ b/TablePro/Core/Storage/SQLFavoriteManager.swift @@ -126,6 +126,24 @@ internal final class SQLFavoriteManager: @unchecked Sendable { ) } + // MARK: - Versions + + func fetchVersions(favoriteId: UUID) async -> [SQLFavoriteVersion] { + await storage.fetchVersions(favoriteId: favoriteId) + } + + func querySavedAt(favoriteId: UUID) async -> Date? { + await storage.querySavedAt(favoriteId: favoriteId) + } + + func restore(_ version: SQLFavoriteVersion) async -> Bool { + let result = await storage.replaceQuery(favoriteId: version.favoriteId, query: version.query, updatedAt: Date()) + guard result.succeeded else { return false } + syncTracker.markDirty(.favorite, id: version.favoriteId.uuidString) + postUpdateNotification(connectionId: result.retainedScope) + return true + } + // MARK: - Folders func addFolder(_ folder: SQLFavoriteFolder) async -> Bool { diff --git a/TablePro/Core/Storage/SQLFavoriteStorage+Versions.swift b/TablePro/Core/Storage/SQLFavoriteStorage+Versions.swift new file mode 100644 index 0000000000..bbf04c01ca --- /dev/null +++ b/TablePro/Core/Storage/SQLFavoriteStorage+Versions.swift @@ -0,0 +1,148 @@ +// +// SQLFavoriteStorage+Versions.swift +// TablePro +// + +import Foundation +import os +import SQLite3 + +extension SQLFavoriteStorage { + private static let versionsLogger = Logger(subsystem: "com.TablePro", category: "SQLFavoriteVersions") + + static let retainedVersionCount = 50 + + static var versionSchemaStatements: [String] { + [ + """ + CREATE TABLE IF NOT EXISTS favorite_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + favorite_id TEXT NOT NULL, + name TEXT NOT NULL, + query TEXT NOT NULL, + saved_at REAL NOT NULL + ); + """, + "CREATE INDEX IF NOT EXISTS idx_favorite_versions_favorite ON favorite_versions(favorite_id, id);", + """ + CREATE TABLE IF NOT EXISTS favorite_query_times ( + favorite_id TEXT PRIMARY KEY, + saved_at REAL NOT NULL + ); + """, + """ + INSERT OR IGNORE INTO favorite_query_times (favorite_id, saved_at) + SELECT id, updated_at FROM favorites; + """, + """ + CREATE TRIGGER IF NOT EXISTS favorites_version_ai AFTER INSERT ON favorites BEGIN + DELETE FROM favorite_query_times WHERE favorite_id = new.id; + INSERT INTO favorite_query_times (favorite_id, saved_at) VALUES (new.id, new.updated_at); + END; + """, + """ + CREATE TRIGGER IF NOT EXISTS favorites_version_au AFTER UPDATE OF query ON favorites + WHEN old.query IS NOT new.query BEGIN + INSERT INTO favorite_versions (favorite_id, name, query, saved_at) + VALUES ( + old.id, old.name, old.query, + COALESCE((SELECT saved_at FROM favorite_query_times WHERE favorite_id = old.id), old.updated_at) + ); + DELETE FROM favorite_query_times WHERE favorite_id = new.id; + INSERT INTO favorite_query_times (favorite_id, saved_at) VALUES (new.id, new.updated_at); + DELETE FROM favorite_versions WHERE favorite_id = old.id AND id NOT IN ( + SELECT id FROM favorite_versions WHERE favorite_id = old.id + ORDER BY id DESC LIMIT \(retainedVersionCount) + ); + END; + """, + """ + CREATE TRIGGER IF NOT EXISTS favorites_version_ad AFTER DELETE ON favorites BEGIN + DELETE FROM favorite_versions WHERE favorite_id = old.id; + DELETE FROM favorite_query_times WHERE favorite_id = old.id; + END; + """, + ] + } + + func fetchVersions(favoriteId: UUID) -> [SQLFavoriteVersion] { + let sql = """ + SELECT id, favorite_id, name, query, saved_at FROM favorite_versions + WHERE favorite_id = ? ORDER BY id DESC; + """ + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + Self.versionsLogger.error("Failed to prepare version fetch: \(String(cString: sqlite3_errmsg(self.db)))") + return [] + } + + defer { sqlite3_finalize(statement) } + + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, favoriteId.uuidString, -1, SQLITE_TRANSIENT) + + var versions: [SQLFavoriteVersion] = [] + while sqlite3_step(statement) == SQLITE_ROW { + if let version = Self.parseVersion(from: statement) { + versions.append(version) + } + } + return versions + } + + func querySavedAt(favoriteId: UUID) -> Date? { + let sql = "SELECT saved_at FROM favorite_query_times WHERE favorite_id = ? LIMIT 1;" + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(statement) } + + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, favoriteId.uuidString, -1, SQLITE_TRANSIENT) + guard sqlite3_step(statement) == SQLITE_ROW else { return nil } + return Date(timeIntervalSince1970: sqlite3_column_double(statement, 0)) + } + + func replaceQuery(favoriteId: UUID, query: String, updatedAt: Date) -> FavoriteScopeWrite { + guard case .found(let connectionId) = currentScope(table: "favorites", id: favoriteId) else { + return .failed + } + + let sql = "UPDATE favorites SET query = ?, updated_at = ? WHERE id = ?;" + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return .failed + } + + defer { sqlite3_finalize(statement) } + + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, query, -1, SQLITE_TRANSIENT) + sqlite3_bind_double(statement, 2, updatedAt.timeIntervalSince1970) + sqlite3_bind_text(statement, 3, favoriteId.uuidString, -1, SQLITE_TRANSIENT) + + guard sqlite3_step(statement) == SQLITE_DONE else { + Self.versionsLogger.error("Failed to restore favorite: \(String(cString: sqlite3_errmsg(self.db)))") + return .failed + } + return .updatedExisting(previousConnectionId: connectionId) + } + + private static func parseVersion(from statement: OpaquePointer?) -> SQLFavoriteVersion? { + guard let statement, + let favoriteIdString = sqlite3_column_text(statement, 1).map({ String(cString: $0) }), + let favoriteId = UUID(uuidString: favoriteIdString), + let name = sqlite3_column_text(statement, 2).map({ String(cString: $0) }), + let query = sqlite3_column_text(statement, 3).map({ String(cString: $0) }) + else { + return nil + } + + return SQLFavoriteVersion( + id: sqlite3_column_int64(statement, 0), + favoriteId: favoriteId, + name: name, + query: query, + savedAt: Date(timeIntervalSince1970: sqlite3_column_double(statement, 4)) + ) + } +} diff --git a/TablePro/Core/Storage/SQLFavoriteStorage.swift b/TablePro/Core/Storage/SQLFavoriteStorage.swift index 94d5ce3957..3dcd259fc0 100644 --- a/TablePro/Core/Storage/SQLFavoriteStorage.swift +++ b/TablePro/Core/Storage/SQLFavoriteStorage.swift @@ -75,6 +75,7 @@ internal actor SQLFavoriteStorage { createTables() migrateIfNeeded() + Self.versionSchemaStatements.forEach { execute($0) } } // MARK: - Schema Migration diff --git a/TablePro/Core/Utilities/File/FileTextLoader.swift b/TablePro/Core/Utilities/File/FileTextLoader.swift index f811f76a51..692868dd0e 100644 --- a/TablePro/Core/Utilities/File/FileTextLoader.swift +++ b/TablePro/Core/Utilities/File/FileTextLoader.swift @@ -35,6 +35,23 @@ internal enum FileTextLoader { return nil } + static func decode(_ data: Data) -> String? { + let utf8ByteOrderMark: [UInt8] = [0xEF, 0xBB, 0xBF] + if data.starts(with: utf8ByteOrderMark) { + return String(data: data.dropFirst(utf8ByteOrderMark.count), encoding: .utf8) + } + if data.starts(with: [0xFF, 0xFE, 0x00, 0x00]) || data.starts(with: [0x00, 0x00, 0xFE, 0xFF]) { + return String(data: data, encoding: .utf32) + } + if data.starts(with: [0xFF, 0xFE]) || data.starts(with: [0xFE, 0xFF]) { + return String(data: data, encoding: .utf16) + } + if let content = String(data: data, encoding: .utf8) { + return content + } + return String(data: data, encoding: .isoLatin1) + } + static func modificationDate(of url: URL) -> Date? { (try? FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate]) as? Date } diff --git a/TablePro/Core/VersionHistory/LinkedFileVersionHistoryProvider.swift b/TablePro/Core/VersionHistory/LinkedFileVersionHistoryProvider.swift new file mode 100644 index 0000000000..0aaeb0648d --- /dev/null +++ b/TablePro/Core/VersionHistory/LinkedFileVersionHistoryProvider.swift @@ -0,0 +1,169 @@ +// +// LinkedFileVersionHistoryProvider.swift +// TablePro +// + +import Foundation + +internal struct LinkedFileVersionHistoryProvider: VersionHistoryProvider { + static let commitLimit = 200 + static let largeFileStoragePointerPrefix = Data("version https://git-lfs.github.com/spec/v1".utf8) + + let fileURL: URL + var locator: GitExecutableLocator = .system + + private var directory: URL { + fileURL.deletingLastPathComponent() + } + + private var indexPath: String { + "./" + fileURL.lastPathComponent + } + + func loadHistory() async throws -> VersionHistoryPage { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + throw VersionHistoryError.subjectNotFound + } + let client = try makeClient() + guard let info = try await gitCall({ try await client.repositoryInfo(in: directory) }) else { + throw VersionHistoryError.notInRepository + } + let records = try await gitCall { try await client.status(in: directory, pathspec: fileURL.lastPathComponent) } + let current = VersionHistoryEntry( + reference: .current, + date: FileTextLoader.modificationDate(of: fileURL), + hasUncommittedChanges: !records.isEmpty + ) + guard try await gitCall({ try await client.hasCommits(in: directory) }) else { + return VersionHistoryPage(entries: [current]) + } + + let commits = try await gitCall { try await client.history(of: fileURL, limit: Self.commitLimit + 1) } + let currentPath = info.prefix + fileURL.lastPathComponent + let past = commits.prefix(Self.commitLimit).map { commit in + VersionHistoryEntry( + reference: .gitRevision(commit: commit.hash, path: commit.path ?? currentPath), + summary: commit.subject, + author: commit.author, + date: commit.date + ) + } + return VersionHistoryPage( + entries: [current] + past, + notice: commits.count > Self.commitLimit ? .showsLatestCommits(Self.commitLimit) : nil + ) + } + + func content(of reference: VersionHistoryReference) async throws -> String { + switch reference { + case .current: + guard let loaded = FileTextLoader.load(fileURL) else { + throw VersionHistoryError.subjectNotFound + } + return loaded.content + case .gitRevision(let commit, let path): + let client = try makeClient() + let data = try await gitCall { try await client.blob(revision: commit, path: path, in: directory) } + guard let content = FileTextLoader.decode(data) else { + throw VersionHistoryError.undecodableContent + } + return content + case .savedQueryVersion: + throw VersionHistoryError.versionNotFound + } + } + + func prepareRestore(_ reference: VersionHistoryReference) async throws -> VersionRestorePlan { + guard case .gitRevision(let commit, let path) = reference else { + throw VersionHistoryError.versionNotFound + } + let client = try makeClient() + let current = try currentBytes() + let replacement = try await gitCall { try await client.blob(revision: commit, path: path, in: directory) } + try Self.rejectLargeFileStoragePointer(replacement) + let replacesUncommittedChanges = try await differsFromLastCommit(current, client: client) + return writePlan( + replacing: current, + with: replacement, + replacesUncommittedChanges: replacesUncommittedChanges, + sourceIsUnchanged: { true } + ) + } + + func prepareDiscard() async throws -> VersionRestorePlan { + let client = try makeClient() + let current = try currentBytes() + let staged = try await gitCall { try await client.blob(revision: "", path: indexPath, in: directory) } + try Self.rejectLargeFileStoragePointer(staged) + let directory = directory + let indexPath = indexPath + return writePlan( + replacing: current, + with: staged, + replacesUncommittedChanges: true, + sourceIsUnchanged: { (try? await client.blob(revision: "", path: indexPath, in: directory)) == staged } + ) + } + + static func rejectLargeFileStoragePointer(_ data: Data) throws { + guard !data.starts(with: largeFileStoragePointerPrefix) else { + throw VersionHistoryError.storedInLargeFileStorage + } + } + + private func writePlan( + replacing expected: Data, + with replacement: Data, + replacesUncommittedChanges: Bool, + sourceIsUnchanged: @escaping @Sendable () async -> Bool + ) -> VersionRestorePlan { + let fileURL = fileURL + return VersionRestorePlan(replacesUncommittedChanges: replacesUncommittedChanges) { + guard await sourceIsUnchanged(), (try? Data(contentsOf: fileURL)) == expected else { + throw VersionHistoryError.fileChangedBeforeWriting + } + do { + try await SQLFileService.writeData(replacement, to: fileURL) + } catch { + throw VersionHistoryError.restoreFailed(error.localizedDescription) + } + } + } + + private func currentBytes() throws -> Data { + do { + return try Data(contentsOf: fileURL) + } catch { + throw VersionHistoryError.subjectNotFound + } + } + + private func differsFromLastCommit(_ current: Data, client: GitClient) async throws -> Bool { + let records = try await gitCall { try await client.status(in: directory, pathspec: fileURL.lastPathComponent) } + guard records.isEmpty else { return true } + guard try await gitCall({ try await client.hasCommits(in: directory) }) else { return true } + let committed = try? await client.blob(revision: "HEAD", path: indexPath, in: directory) + return committed != current + } + + private func makeClient() throws -> GitClient { + guard let client = GitClient.make(locator: locator) else { + throw VersionHistoryError.gitUnavailable + } + return client + } + + private func gitCall(_ operation: () async throws -> T) async throws -> T { + do { + return try await operation() + } catch let failure as GitCommandFailure { + throw VersionHistoryError.commandFailed(failure.errorDescription ?? failure.message) + } catch GitProcessError.timedOut { + throw VersionHistoryError.commandFailed(String(localized: "Git did not respond in time.")) + } catch GitProcessError.outputTooLarge { + throw VersionHistoryError.commandFailed(String(localized: "The file is too large to read from Git.")) + } catch GitProcessError.launchFailed(let message) { + throw VersionHistoryError.commandFailed(message) + } + } +} diff --git a/TablePro/Core/VersionHistory/SavedQueryVersionHistoryProvider.swift b/TablePro/Core/VersionHistory/SavedQueryVersionHistoryProvider.swift new file mode 100644 index 0000000000..7e4cfc4571 --- /dev/null +++ b/TablePro/Core/VersionHistory/SavedQueryVersionHistoryProvider.swift @@ -0,0 +1,66 @@ +// +// SavedQueryVersionHistoryProvider.swift +// TablePro +// + +import Foundation + +internal struct SavedQueryVersionHistoryProvider: VersionHistoryProvider { + let favoriteId: UUID + let manager: SQLFavoriteManager + + func loadHistory() async throws -> VersionHistoryPage { + guard let favorite = await manager.fetchFavorite(id: favoriteId) else { + throw VersionHistoryError.subjectNotFound + } + let versions = await manager.fetchVersions(favoriteId: favoriteId) + let savedAt = await manager.querySavedAt(favoriteId: favoriteId) ?? favorite.updatedAt + let current = VersionHistoryEntry(reference: .current, date: savedAt) + let past = versions.map { version in + VersionHistoryEntry( + reference: .savedQueryVersion(id: version.id), + summary: version.name == favorite.name ? nil : version.name, + date: version.savedAt + ) + } + return VersionHistoryPage( + entries: [current] + past, + notice: .keepsLatestVersions(SQLFavoriteStorage.retainedVersionCount) + ) + } + + func content(of reference: VersionHistoryReference) async throws -> String { + switch reference { + case .current: + guard let favorite = await manager.fetchFavorite(id: favoriteId) else { + throw VersionHistoryError.subjectNotFound + } + return favorite.query + case .savedQueryVersion(let id): + return try await version(id: id).query + case .gitRevision: + throw VersionHistoryError.versionNotFound + } + } + + func prepareRestore(_ reference: VersionHistoryReference) async throws -> VersionRestorePlan { + guard case .savedQueryVersion(let id) = reference else { + throw VersionHistoryError.versionNotFound + } + let version = try await version(id: id) + let manager = manager + return VersionRestorePlan(replacesUncommittedChanges: false) { + guard await manager.restore(version) else { + throw VersionHistoryError.subjectNotFound + } + } + } + + private func version(id: Int64) async throws -> SQLFavoriteVersion { + let versions = await manager.fetchVersions(favoriteId: favoriteId) + guard let version = versions.first(where: { $0.id == id }) else { + throw VersionHistoryError.versionNotFound + } + return version + } +} diff --git a/TablePro/Core/VersionHistory/VersionComparison.swift b/TablePro/Core/VersionHistory/VersionComparison.swift new file mode 100644 index 0000000000..cd585e9389 --- /dev/null +++ b/TablePro/Core/VersionHistory/VersionComparison.swift @@ -0,0 +1,23 @@ +// +// VersionComparison.swift +// TablePro +// + +import Foundation + +internal enum VersionComparison: Equatable, Sendable { + case identical + case differs([DiffPair]) + case tooLarge + + static let maximumLineCount = 5_000 + + static func compare(baseline: String, current: String) -> VersionComparison { + guard baseline != current else { return .identical } + let before = FileConflictDiff.lines(baseline) + let after = FileConflictDiff.lines(current) + guard before != after else { return .identical } + guard before.count <= maximumLineCount, after.count <= maximumLineCount else { return .tooLarge } + return .differs(DiffComputer.computeSplit(before: before, after: after)) + } +} diff --git a/TablePro/Core/VersionHistory/VersionHistoryModels.swift b/TablePro/Core/VersionHistory/VersionHistoryModels.swift new file mode 100644 index 0000000000..a394028ad3 --- /dev/null +++ b/TablePro/Core/VersionHistory/VersionHistoryModels.swift @@ -0,0 +1,103 @@ +// +// VersionHistoryModels.swift +// TablePro +// + +import Foundation + +internal enum VersionHistorySubject: Codable, Hashable, Sendable { + case savedQuery(id: UUID) + case linkedFile(url: URL) +} + +internal enum VersionHistoryReference: Hashable, Sendable { + case current + case savedQueryVersion(id: Int64) + case gitRevision(commit: String, path: String) + + var shortRevision: String? { + guard case .gitRevision(let commit, _) = self else { return nil } + return String(commit.prefix(7)) + } +} + +internal struct VersionHistoryEntry: Identifiable, Hashable, Sendable { + let reference: VersionHistoryReference + var summary: String? + var author: String? + var date: Date? + var hasUncommittedChanges = false + + var id: VersionHistoryReference { reference } + var isCurrent: Bool { reference == .current } +} + +internal enum VersionHistoryNotice: Hashable, Sendable { + case keepsLatestVersions(Int) + case showsLatestCommits(Int) +} + +internal struct VersionHistoryPage: Equatable, Sendable { + let entries: [VersionHistoryEntry] + var notice: VersionHistoryNotice? + + static let empty = VersionHistoryPage(entries: []) + + var current: VersionHistoryEntry? { + entries.first(where: \.isCurrent) + } + + func baseline(for reference: VersionHistoryReference) -> VersionHistoryEntry? { + guard reference == .current else { return current } + return entries.first { !$0.isCurrent } + } +} + +internal enum VersionHistoryError: LocalizedError, Equatable { + case subjectNotFound + case versionNotFound + case gitUnavailable + case notInRepository + case noCommitsYet + case commandFailed(String) + case undecodableContent + case storedInLargeFileStorage + case fileChangedBeforeWriting + case restoreFailed(String) + + var errorDescription: String? { + switch self { + case .subjectNotFound: + return String(localized: "This item no longer exists.") + case .versionNotFound: + return String(localized: "This version is no longer available.") + case .gitUnavailable: + return String(localized: "Git is not installed. Install the Command Line Tools or Xcode to see file history.") + case .notInRepository: + return String(localized: "This file is not in a Git repository.") + case .noCommitsYet: + return String(localized: "This repository has no commits yet.") + case .commandFailed(let message): + return message + case .undecodableContent: + return String(localized: "This version is not readable as text.") + case .storedInLargeFileStorage: + return String(localized: "This file is stored with Git LFS. Restore it with your Git client.") + case .fileChangedBeforeWriting: + return String(localized: "The file or its staged version changed before it could be written. Nothing was replaced.") + case .restoreFailed(let message): + return String(format: String(localized: "The version could not be restored: %@"), message) + } + } +} + +internal struct VersionRestorePlan: Sendable { + let replacesUncommittedChanges: Bool + let apply: @Sendable () async throws -> Void +} + +internal protocol VersionHistoryProvider: Sendable { + func loadHistory() async throws -> VersionHistoryPage + func content(of reference: VersionHistoryReference) async throws -> String + func prepareRestore(_ reference: VersionHistoryReference) async throws -> VersionRestorePlan +} diff --git a/TablePro/Models/Query/EditorTabPayload.swift b/TablePro/Models/Query/EditorTabPayload.swift index d4e76f1c31..c461d5cb01 100644 --- a/TablePro/Models/Query/EditorTabPayload.swift +++ b/TablePro/Models/Query/EditorTabPayload.swift @@ -58,6 +58,7 @@ internal struct EditorTabPayload: Codable, Hashable { internal let erDiagramSchemaKey: String? /// The routine or trigger a .objectSource tab shows internal let objectRef: DatabaseObjectRef? + internal let versionHistorySubject: VersionHistorySubject? /// Tab title (for restoring persisted tabs with their original names) internal let tabTitle: String? /// The intent behind creating this tab @@ -68,7 +69,7 @@ internal struct EditorTabPayload: Codable, Hashable { case initialQuery, isView, objectType, showStructure, skipAutoExecute, isPreview case forcesNewTab case tabTitle - case initialFilterState, sourceFileURL, erDiagramSchemaKey, objectRef, intent + case initialFilterState, sourceFileURL, erDiagramSchemaKey, objectRef, versionHistorySubject, intent // Legacy key for backward decoding only case isNewTab } @@ -91,6 +92,7 @@ internal struct EditorTabPayload: Codable, Hashable { sourceFileURL: URL? = nil, erDiagramSchemaKey: String? = nil, objectRef: DatabaseObjectRef? = nil, + versionHistorySubject: VersionHistorySubject? = nil, tabTitle: String? = nil, intent: TabIntent = .openContent ) { @@ -111,6 +113,7 @@ internal struct EditorTabPayload: Codable, Hashable { self.sourceFileURL = sourceFileURL self.erDiagramSchemaKey = erDiagramSchemaKey self.objectRef = objectRef + self.versionHistorySubject = versionHistorySubject self.tabTitle = tabTitle self.intent = intent } @@ -137,6 +140,7 @@ internal struct EditorTabPayload: Codable, Hashable { sourceFileURL = try container.decodeIfPresent(URL.self, forKey: .sourceFileURL) erDiagramSchemaKey = try container.decodeIfPresent(String.self, forKey: .erDiagramSchemaKey) objectRef = try container.decodeIfPresent(DatabaseObjectRef.self, forKey: .objectRef) + versionHistorySubject = try container.decodeIfPresent(VersionHistorySubject.self, forKey: .versionHistorySubject) tabTitle = try container.decodeIfPresent(String.self, forKey: .tabTitle) if let decodedIntent = try container.decodeIfPresent(TabIntent.self, forKey: .intent) { intent = decodedIntent @@ -165,6 +169,7 @@ internal struct EditorTabPayload: Codable, Hashable { try container.encodeIfPresent(sourceFileURL, forKey: .sourceFileURL) try container.encodeIfPresent(erDiagramSchemaKey, forKey: .erDiagramSchemaKey) try container.encodeIfPresent(objectRef, forKey: .objectRef) + try container.encodeIfPresent(versionHistorySubject, forKey: .versionHistorySubject) try container.encodeIfPresent(tabTitle, forKey: .tabTitle) try container.encode(intent, forKey: .intent) } @@ -188,6 +193,7 @@ internal struct EditorTabPayload: Codable, Hashable { self.sourceFileURL = tab.content.sourceFileURL self.erDiagramSchemaKey = tab.display.erDiagramSchemaKey self.objectRef = tab.display.objectRef + self.versionHistorySubject = tab.display.versionHistorySubject self.tabTitle = tab.title self.intent = .openContent } diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 4998fef612..12402c6322 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -233,7 +233,8 @@ struct QueryTab: Identifiable, Equatable { ) self.display = TabDisplayState( erDiagramSchemaKey: persisted.erDiagramSchemaKey, - objectRef: persisted.objectRef + objectRef: persisted.objectRef, + versionHistorySubject: persisted.versionHistorySubject ) self.pendingChanges = TabChangeSnapshot() self.selectedRowIndices = [] @@ -410,6 +411,7 @@ struct QueryTab: Identifiable, Equatable { sourceFileURL: content.sourceFileURL, erDiagramSchemaKey: display.erDiagramSchemaKey, objectRef: display.objectRef, + versionHistorySubject: display.versionHistorySubject, queryParameters: content.queryParameters.isEmpty ? nil : content.queryParameters, sortColumns: persistedSort, sortSource: persistedSortSource, diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index f6dd20a554..c0d94886df 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -508,6 +508,30 @@ final class QueryTabManager: ObservableObject { selectedTabId = newTab.id } + func addVersionHistoryTab(subject: VersionHistorySubject, title: String) { + if let existing = tabs.first(where: { + $0.tabType == .versionHistory && $0.display.versionHistorySubject == subject + }) { + selectedTabId = existing.id + return + } + var newTab = QueryTab(title: title, tabType: .versionHistory) + newTab.tableContext.isEditable = false + newTab.display.versionHistorySubject = subject + newTab.hasUserInteraction = true + tabs.append(newTab) + selectedTabId = newTab.id + } + + static var versionHistoryFallbackTitle: String { + String(localized: "History") + } + + static func versionHistoryTitle(for name: String) -> String { + guard !name.isBlank else { return versionHistoryFallbackTitle } + return String(format: String(localized: "History: %@"), name) + } + static func objectSourceTitle(for objectRef: DatabaseObjectRef) -> String { let format: String switch objectRef.kind { diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index 8f011c2818..76a0a38bbf 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -22,6 +22,7 @@ enum TabType: Equatable, Codable, Hashable { case usersRoles case insights case objectSource + case versionHistory } /// Minimal representation of a tab for persistence @@ -41,6 +42,7 @@ struct PersistedTab: Codable { var sourceFileURL: URL? var erDiagramSchemaKey: String? var objectRef: DatabaseObjectRef? + var versionHistorySubject: VersionHistorySubject? var queryParameters: [QueryParameter]? var sortColumns: [PersistedSortColumn]? /// Who chose the saved order. Absent in every file written before this existed, which decodes @@ -72,6 +74,7 @@ struct PersistedTab: Codable { sourceFileURL: URL? = nil, erDiagramSchemaKey: String? = nil, objectRef: DatabaseObjectRef? = nil, + versionHistorySubject: VersionHistorySubject? = nil, queryParameters: [QueryParameter]? = nil, sortColumns: [PersistedSortColumn]? = nil, sortSource: SortSource? = nil, @@ -96,6 +99,7 @@ struct PersistedTab: Codable { self.sourceFileURL = sourceFileURL self.erDiagramSchemaKey = erDiagramSchemaKey self.objectRef = objectRef + self.versionHistorySubject = versionHistorySubject self.queryParameters = queryParameters self.sortColumns = sortColumns self.sortSource = sortSource @@ -111,7 +115,7 @@ struct PersistedTab: Codable { private enum CodingKeys: String, CodingKey { case id, title, query, tabType, tableName, isView, objectTypeRawValue, databaseName, schemaName - case sourceFileURL, erDiagramSchemaKey, objectRef, queryParameters + case sourceFileURL, erDiagramSchemaKey, objectRef, versionHistorySubject, queryParameters case sortColumns, sortSource, restoredPage, restoredPageSize, cursorOffset, cursorLength case collapsedFoldRanges case columnWidths, columnContentWidths, windowGroupIndex @@ -132,6 +136,7 @@ struct PersistedTab: Codable { sourceFileURL = try container.decodeIfPresent(URL.self, forKey: .sourceFileURL) erDiagramSchemaKey = try container.decodeIfPresent(String.self, forKey: .erDiagramSchemaKey) objectRef = try container.decodeIfPresent(DatabaseObjectRef.self, forKey: .objectRef) + versionHistorySubject = try container.decodeIfPresent(VersionHistorySubject.self, forKey: .versionHistorySubject) queryParameters = try container.decodeIfPresent([QueryParameter].self, forKey: .queryParameters) sortColumns = try container.decodeIfPresent([PersistedSortColumn].self, forKey: .sortColumns) sortSource = try container.decodeIfPresent(SortSource.self, forKey: .sortSource) @@ -685,6 +690,7 @@ struct TabDisplayState: Equatable { var spatialColumns: [SpatialColumn] = [] var erDiagramSchemaKey: String? var objectRef: DatabaseObjectRef? + var versionHistorySubject: VersionHistorySubject? var isResultsCollapsed: Bool = false var resultSets: [ResultSet] = [] var activeResultSetId: UUID? diff --git a/TablePro/Models/Query/SQLFavoriteVersion.swift b/TablePro/Models/Query/SQLFavoriteVersion.swift new file mode 100644 index 0000000000..e499ebb945 --- /dev/null +++ b/TablePro/Models/Query/SQLFavoriteVersion.swift @@ -0,0 +1,14 @@ +// +// SQLFavoriteVersion.swift +// TablePro +// + +import Foundation + +internal struct SQLFavoriteVersion: Identifiable, Hashable, Sendable { + let id: Int64 + let favoriteId: UUID + let name: String + let query: String + let savedAt: Date +} diff --git a/TablePro/Models/UI/GridSelectionOwner.swift b/TablePro/Models/UI/GridSelectionOwner.swift index a4fb7acfd3..070af19998 100644 --- a/TablePro/Models/UI/GridSelectionOwner.swift +++ b/TablePro/Models/UI/GridSelectionOwner.swift @@ -41,7 +41,7 @@ internal enum GridSelectionOwner: Equatable { /// indices it writes are the data grid's display positions, resolved through /// `DisplayRowMapping`. return .dataGrid - case .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .objectSource: + case .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .objectSource, .versionHistory: return .none } } diff --git a/TablePro/Models/UI/PendingChangeKind.swift b/TablePro/Models/UI/PendingChangeKind.swift index 23f2bf68f7..a93840100b 100644 --- a/TablePro/Models/UI/PendingChangeKind.swift +++ b/TablePro/Models/UI/PendingChangeKind.swift @@ -46,7 +46,7 @@ internal enum PendingChangeKind: Equatable, Hashable, Sendable { return hasCreateTablePending ? .createTable : nil case .usersRoles: return hasPrincipalChanges ? .principals : nil - case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource: + case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory: return contentKind( hasDataChanges: hasDataChanges, hasStructureChanges: hasStructureChanges, diff --git a/TablePro/Resources/TablePro.sdef b/TablePro/Resources/TablePro.sdef index d2eb114f8b..7af1f3b144 100644 --- a/TablePro/Resources/TablePro.sdef +++ b/TablePro/Resources/TablePro.sdef @@ -31,6 +31,7 @@ + diff --git a/TablePro/ViewModels/FavoritesSidebarViewModel+LinkedFolders.swift b/TablePro/ViewModels/FavoritesSidebarViewModel+LinkedFolders.swift index 8f4e705938..b985919b67 100644 --- a/TablePro/ViewModels/FavoritesSidebarViewModel+LinkedFolders.swift +++ b/TablePro/ViewModels/FavoritesSidebarViewModel+LinkedFolders.swift @@ -20,6 +20,7 @@ extension FavoritesSidebarViewModel { internal func startWatchingLinkedFolders() { SQLFolderWatcher.shared.start() + LinkedFolderGitStatusStore.shared.start() } internal func reloadLinkedFolders() { diff --git a/TablePro/ViewModels/VersionHistoryViewModel.swift b/TablePro/ViewModels/VersionHistoryViewModel.swift new file mode 100644 index 0000000000..6d275ca2b5 --- /dev/null +++ b/TablePro/ViewModels/VersionHistoryViewModel.swift @@ -0,0 +1,196 @@ +// +// VersionHistoryViewModel.swift +// TablePro +// + +import Combine +import Foundation +import os + +internal struct VersionHistoryComparison: Equatable { + let older: VersionHistoryEntry + let newer: VersionHistoryEntry + let outcome: VersionComparison +} + +internal struct VersionHistoryDetail: Equatable { + let entry: VersionHistoryEntry + let content: String + let comparison: VersionHistoryComparison? +} + +@MainActor +internal final class VersionHistoryViewModel: ObservableObject { + private static let logger = Logger(subsystem: "com.TablePro", category: "VersionHistory") + + internal enum ListState: Equatable { + case loading + case failed(String) + case loaded + } + + internal enum DetailState: Equatable { + case empty + case loading + case failed(String) + case loaded(VersionHistoryDetail) + } + + internal enum DisplayMode: Hashable { + case changes + case content + } + + @Published private(set) var listState: ListState = .loading + @Published private(set) var page: VersionHistoryPage = .empty + @Published private(set) var detailState: DetailState = .empty + @Published private(set) var isRestoring = false + @Published var displayMode: DisplayMode = .changes + @Published var diffLayout: TextDiffLayout = .split + @Published var selection: VersionHistoryReference? { + didSet { + guard selection != oldValue else { return } + loadDetail() + } + } + + var onRestored: () -> Void = {} + var confirmReplacingUncommittedChanges: () async -> Bool = { true } + var reportRestoreFailure: (String) -> Void = { _ in } + + let subject: VersionHistorySubject + private let provider: any VersionHistoryProvider + private var pastContentCache: [VersionHistoryReference: String] = [:] + private var listTask: Task? + private(set) var detailTask: Task? + private var cancellables: Set = [] + + init( + subject: VersionHistorySubject, + provider: any VersionHistoryProvider, + refreshSignal: AnyPublisher + ) { + self.subject = subject + self.provider = provider + refreshSignal + .debounce(for: .milliseconds(250), scheduler: DispatchQueue.main) + .sink { [weak self] in self?.reload() } + .store(in: &cancellables) + } + + var selectedEntry: VersionHistoryEntry? { + guard let selection else { return nil } + return page.entries.first { $0.reference == selection } + } + + private var shownReference: VersionHistoryReference? { + guard case .loaded(let detail) = detailState else { return nil } + return detail.entry.reference + } + + var canRestoreSelection: Bool { + guard let entry = selectedEntry else { return false } + return !entry.isCurrent && !isRestoring + } + + func reload() { + listTask?.cancel() + listTask = Task { [weak self] in + await self?.loadList() + } + } + + func loadList() async { + if page.entries.isEmpty { + listState = .loading + } + do { + let loaded = try await provider.loadHistory() + guard !Task.isCancelled else { return } + page = loaded + listState = .loaded + if let selection, loaded.entries.contains(where: { $0.reference == selection }) { + loadDetail() + } else { + selection = loaded.entries.first?.reference + } + } catch { + guard !Task.isCancelled else { return } + Self.logger.warning("Version history failed to load: \(error.publicLogShape, privacy: .public)") + let subjectIsGone = (error as? VersionHistoryError) == .subjectNotFound + guard page.entries.isEmpty || subjectIsGone else { return } + page = .empty + selection = nil + detailState = .empty + listState = .failed(error.localizedDescription) + } + } + + func restore(_ entry: VersionHistoryEntry) async { + guard !entry.isCurrent, !isRestoring else { return } + isRestoring = true + defer { isRestoring = false } + do { + let plan = try await provider.prepareRestore(entry.reference) + if plan.replacesUncommittedChanges { + guard await confirmReplacingUncommittedChanges() else { return } + } + try await plan.apply() + onRestored() + selection = .current + await loadList() + } catch { + reportRestoreFailure(error.localizedDescription) + } + } + + private func loadDetail() { + detailTask?.cancel() + guard let entry = selectedEntry else { + detailState = .empty + return + } + if shownReference != entry.reference { + detailState = .loading + } + let baseline = page.baseline(for: entry.reference) + detailTask = Task { [weak self] in + guard let self else { return } + do { + let content = try await content(of: entry.reference) + let comparison = try await comparison(for: entry, content: content, baseline: baseline) + guard !Task.isCancelled else { return } + detailState = .loaded(VersionHistoryDetail(entry: entry, content: content, comparison: comparison)) + } catch { + guard !Task.isCancelled else { return } + detailState = .failed(error.localizedDescription) + } + } + } + + private func comparison( + for entry: VersionHistoryEntry, + content: String, + baseline: VersionHistoryEntry? + ) async throws -> VersionHistoryComparison? { + guard let baseline else { return nil } + let baselineContent = try await self.content(of: baseline.reference) + let (older, newer) = entry.isCurrent ? (baseline, entry) : (entry, baseline) + let (olderContent, newerContent) = entry.isCurrent ? (baselineContent, content) : (content, baselineContent) + let outcome = await Task.detached(priority: .userInitiated) { + VersionComparison.compare(baseline: olderContent, current: newerContent) + }.value + return VersionHistoryComparison(older: older, newer: newer, outcome: outcome) + } + + private func content(of reference: VersionHistoryReference) async throws -> String { + if let cached = pastContentCache[reference] { + return cached + } + let content = try await provider.content(of: reference) + if reference != .current { + pastContentCache[reference] = content + } + return content + } +} diff --git a/TablePro/Views/Compare/StructureDefinitionDiffView.swift b/TablePro/Views/Compare/StructureDefinitionDiffView.swift index 518791e2b2..c18a9b8501 100644 --- a/TablePro/Views/Compare/StructureDefinitionDiffView.swift +++ b/TablePro/Views/Compare/StructureDefinitionDiffView.swift @@ -18,8 +18,6 @@ internal struct StructureDefinitionDiffView: View { @State private var isUnified = false - @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor - private var pairs: [DiffPair] { DiffComputer.computeSplit(before: targetLines, after: sourceLines) } @@ -40,109 +38,12 @@ internal struct StructureDefinitionDiffView: View { } .padding(.bottom, 6) - if isUnified { - unifiedBody - } else { - splitBody - } - } - } - - private var splitBody: some View { - VStack(spacing: 0) { - HStack(spacing: 0) { - columnHeader(targetLabel) - columnHeader(sourceLabel) - } - ForEach(Array(pairs.enumerated()), id: \.offset) { _, pair in - HStack(spacing: 0) { - diffCell(pair.before, kind: pair.kind, isBefore: true) - diffCell(pair.after, kind: pair.kind, isBefore: false) - } - } - } - .background(RoundedRectangle(cornerRadius: 4).stroke(Color.secondary.opacity(0.2))) - } - - private var unifiedBody: some View { - VStack(alignment: .leading, spacing: 0) { - ForEach(DiffComputer.computeUnified(from: pairs)) { line in - HStack(spacing: 6) { - Text(marker(for: line.kind)) - .font(.system(.caption2, design: .monospaced)) - .frame(width: 12) - Text(line.text) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - Spacer(minLength: 0) - } - .padding(.horizontal, 6) - .padding(.vertical, 1) - .background(background(for: line.kind)) - } - } - .background(RoundedRectangle(cornerRadius: 4).stroke(Color.secondary.opacity(0.2))) - } - - private func columnHeader(_ title: String) -> some View { - Text(title) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 6) - .padding(.vertical, 3) - } - - private func diffCell(_ text: String?, kind: DiffPair.Kind, isBefore: Bool) -> some View { - HStack(spacing: 4) { - Text(glyph(for: kind, isBefore: isBefore)) - .font(.system(.caption2, design: .monospaced)) - .frame(width: 10) - Text(text ?? "") - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - Spacer(minLength: 0) - } - .padding(.horizontal, 6) - .padding(.vertical, 1) - .frame(maxWidth: .infinity, alignment: .leading) - .background(splitBackground(kind: kind, isBefore: isBefore, isEmpty: text == nil)) - } - - private func glyph(for kind: DiffPair.Kind, isBefore: Bool) -> String { - switch kind { - case .unchanged: return " " - case .changed: return "~" - case .added: return isBefore ? " " : "+" - case .removed: return isBefore ? "-" : " " - } - } - - private func marker(for kind: DiffUnifiedLine.Kind) -> String { - switch kind { - case .context: return " " - case .added: return "+" - case .removed: return "-" - } - } - - private func splitBackground(kind: DiffPair.Kind, isBefore: Bool, isEmpty: Bool) -> Color { - guard !differentiateWithoutColor else { return .clear } - guard !isEmpty else { return Color.secondary.opacity(0.05) } - switch kind { - case .unchanged: return .clear - case .changed: return CompareStatusStyle.rowTint(for: .update) - case .added: return isBefore ? .clear : CompareStatusStyle.rowTint(for: .insert) - case .removed: return isBefore ? CompareStatusStyle.rowTint(for: .delete) : .clear - } - } - - private func background(for kind: DiffUnifiedLine.Kind) -> Color { - guard !differentiateWithoutColor else { return .clear } - switch kind { - case .context: return .clear - case .added: return CompareStatusStyle.rowTint(for: .insert) - case .removed: return CompareStatusStyle.rowTint(for: .delete) + TextDiffView( + pairs: pairs, + beforeLabel: targetLabel, + afterLabel: sourceLabel, + layout: isUnified ? .unified : .split + ) } } } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 96a5e6b017..4ff31557d6 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -255,6 +255,40 @@ struct MainEditorContentView: View { queryInsightsContent(tab: tab) case .objectSource: objectSourceContent(tab: tab) + case .versionHistory: + versionHistoryContent(tab: tab) + } + } + + // MARK: - Version History Tab Content + + @ViewBuilder + private func versionHistoryContent(tab: QueryTab) -> some View { + if let subject = tab.display.versionHistorySubject { + VersionHistoryTabView( + tabId: tab.id, + subject: subject, + databaseType: connection.type, + exportFileName: Self.versionHistoryExportName(for: subject, title: tab.title), + onOpenInEditor: { content in + coordinator.openVersionInEditor(content) + } + ) + .id(subject) + } else { + UnavailableStateView( + String(localized: "No History"), + systemImage: "clock.arrow.circlepath" + ) + } + } + + private static func versionHistoryExportName(for subject: VersionHistorySubject, title: String) -> String { + switch subject { + case .linkedFile(let url): + return url.lastPathComponent + case .savedQuery: + return "query.sql" } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift index 09faf0c415..a968b11ac8 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift @@ -6,6 +6,7 @@ // import AppKit +import Combine import Foundation extension MainContentCoordinator { @@ -41,6 +42,10 @@ extension MainContentCoordinator { onDiscard: @escaping () -> Void ) { guard let (tab, _) = tabManager.selectedTabAndIndex else { return } + if tab.tabType == .versionHistory { + AppEvents.shared.versionHistoryRefreshRequested.send(tab.id) + return + } if tab.display.resultsViewMode == .structure { structureActions?.refresh?() return diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+VersionHistory.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+VersionHistory.swift new file mode 100644 index 0000000000..0d8313d732 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+VersionHistory.swift @@ -0,0 +1,68 @@ +// +// MainContentCoordinator+VersionHistory.swift +// TablePro +// + +import Foundation +import os + +extension MainContentCoordinator { + private static let versionHistoryLogger = Logger(subsystem: "com.TablePro", category: "VersionHistory") + + func showVersionHistory(of favorite: SQLFavorite) { + openVersionHistory(subject: .savedQuery(id: favorite.id), name: favorite.name) + } + + func showVersionHistory(of linked: LinkedSQLFavorite) { + openVersionHistory(subject: .linkedFile(url: linked.fileURL), name: linked.fileURL.lastPathComponent) + } + + func openVersionInEditor(_ content: String) { + let payload = EditorTabPayload( + connectionId: connectionId, + tabType: .query, + databaseName: browseDatabaseName, + initialQuery: content, + skipAutoExecute: true + ) + WindowManager.shared.openTab(payload: payload) + } + + func discardChanges(to linked: LinkedSQLFavorite, confirm: () async -> Bool) async { + do { + let plan = try await LinkedFileVersionHistoryProvider(fileURL: linked.fileURL).prepareDiscard() + guard await confirm() else { return } + try await plan.apply() + Self.reloadUnmodifiedFileTabs(at: linked.fileURL) + LinkedFolderGitStatusStore.shared.scheduleRefresh(after: .zero) + } catch { + Self.versionHistoryLogger.error("Discarding changes failed: \(error.publicLogShape, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Couldn't Discard Changes"), + message: error.localizedDescription, + window: nil + ) + } + } + + static func reloadUnmodifiedFileTabs(at url: URL) { + let target = url.standardizedFileURL + for coordinator in activeCoordinators.values { + for tab in coordinator.tabManager.tabs + where tab.content.sourceFileURL?.standardizedFileURL == target && !tab.content.isFileDirty { + coordinator.commandActions?.reloadFileFromDisk(tabId: tab.id, url: url) + } + } + } + + private func openVersionHistory(subject: VersionHistorySubject, name: String) { + let payload = EditorTabPayload( + connectionId: connectionId, + tabType: .versionHistory, + databaseName: browseDatabaseName, + versionHistorySubject: subject, + tabTitle: QueryTabManager.versionHistoryTitle(for: name) + ) + WindowManager.shared.openTab(payload: payload) + } +} diff --git a/TablePro/Views/Shared/TextDiffView.swift b/TablePro/Views/Shared/TextDiffView.swift new file mode 100644 index 0000000000..4c8a5d8946 --- /dev/null +++ b/TablePro/Views/Shared/TextDiffView.swift @@ -0,0 +1,143 @@ +// +// TextDiffView.swift +// TablePro +// + +import SwiftUI + +internal enum TextDiffLayout: Hashable { + case split + case unified +} + +internal struct TextDiffView: View { + internal let pairs: [DiffPair] + internal let beforeLabel: String + internal let afterLabel: String + internal let layout: TextDiffLayout + internal var textFont: Font = .system(.caption, design: .monospaced) + + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + + internal var body: some View { + switch layout { + case .split: + splitBody + case .unified: + unifiedBody + } + } + + private var splitBody: some View { + LazyVStack(spacing: 0) { + HStack(spacing: 0) { + columnHeader(beforeLabel) + columnHeader(afterLabel) + } + ForEach(Array(pairs.enumerated()), id: \.offset) { _, pair in + HStack(spacing: 0) { + diffCell(pair.before, kind: pair.kind, side: .before) + diffCell(pair.after, kind: pair.kind, side: .after) + } + } + } + .background(RoundedRectangle(cornerRadius: 4).stroke(Color.secondary.opacity(0.2))) + } + + private var unifiedBody: some View { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(DiffComputer.computeUnified(from: pairs)) { line in + HStack(spacing: 6) { + Text(verbatim: marker(for: line.kind)) + .font(.system(.caption2, design: .monospaced)) + .frame(width: 12) + .accessibilityHidden(true) + Text(verbatim: line.text) + .font(textFont) + .textSelection(.enabled) + Spacer(minLength: 0) + } + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(background(for: line.kind)) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel(for: line)) + } + } + .background(RoundedRectangle(cornerRadius: 4).stroke(Color.secondary.opacity(0.2))) + } + + private func columnHeader(_ title: String) -> some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + .padding(.vertical, 3) + } + + private func diffCell(_ text: String?, kind: DiffPair.Kind, side: SqlWalkthroughAnchor.Side) -> some View { + HStack(spacing: 4) { + Text(verbatim: SplitDiffMarker.resolve(kind: kind, side: side)?.glyph ?? " ") + .font(.system(.caption2, design: .monospaced)) + .frame(width: 10) + .accessibilityHidden(true) + Text(verbatim: text ?? "") + .font(textFont) + .textSelection(.enabled) + Spacer(minLength: 0) + } + .padding(.horizontal, 6) + .padding(.vertical, 1) + .frame(maxWidth: .infinity, alignment: .leading) + .background(splitBackground(kind: kind, side: side, isEmpty: text == nil)) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel(for: text, kind: kind, side: side)) + } + + private func marker(for kind: DiffUnifiedLine.Kind) -> String { + switch kind { + case .context: return " " + case .added: return SplitDiffMarker.added.glyph + case .removed: return SplitDiffMarker.removed.glyph + } + } + + private func accessibilityLabel(for line: DiffUnifiedLine) -> String { + switch line.kind { + case .context: + return line.text + case .added: + return String(format: String(localized: "%@: %@"), SplitDiffMarker.added.label, line.text) + case .removed: + return String(format: String(localized: "%@: %@"), SplitDiffMarker.removed.label, line.text) + } + } + + private func accessibilityLabel(for text: String?, kind: DiffPair.Kind, side: SqlWalkthroughAnchor.Side) -> String { + guard let text else { return "" } + guard let marker = SplitDiffMarker.resolve(kind: kind, side: side) else { return text } + return String(format: String(localized: "%@: %@"), marker.label, text) + } + + private func splitBackground(kind: DiffPair.Kind, side: SqlWalkthroughAnchor.Side, isEmpty: Bool) -> Color { + guard !differentiateWithoutColor else { return .clear } + guard !isEmpty else { return Color.secondary.opacity(0.05) } + switch (kind, side) { + case (.unchanged, _): return .clear + case (.changed, _): return CompareStatusStyle.rowTint(for: .update) + case (.added, .after): return CompareStatusStyle.rowTint(for: .insert) + case (.removed, .before): return CompareStatusStyle.rowTint(for: .delete) + default: return .clear + } + } + + private func background(for kind: DiffUnifiedLine.Kind) -> Color { + guard !differentiateWithoutColor else { return .clear } + switch kind { + case .context: return .clear + case .added: return CompareStatusStyle.rowTint(for: .insert) + case .removed: return CompareStatusStyle.rowTint(for: .delete) + } + } +} diff --git a/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift b/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift index 2df3154b3f..fcf6540a01 100644 --- a/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/FavoritesOutlineCoordinator.swift @@ -324,7 +324,8 @@ internal final class FavoritesOutlineCoordinator: NSObject, NSOutline allFolders: owner.input.allFolders, teamLibraryAvailable: owner.input.teamLibraryAvailable, databaseEntityName: owner.input.databaseEntityName, - activeDatabase: owner.input.activeDatabase + activeDatabase: owner.input.activeDatabase, + linkedFileGitStates: owner.input.linkedFileGitStates ) SidebarMenuBuilder.fill( menu, diff --git a/TablePro/Views/Sidebar/FavoritesOutlineView.swift b/TablePro/Views/Sidebar/FavoritesOutlineView.swift index dafc557b60..635cac5e43 100644 --- a/TablePro/Views/Sidebar/FavoritesOutlineView.swift +++ b/TablePro/Views/Sidebar/FavoritesOutlineView.swift @@ -21,6 +21,7 @@ internal struct FavoritesOutlineInput { internal let renamingFolderId: UUID? internal let allFolders: [SQLFavoriteFolder] internal let teamLibraryAvailable: Bool + internal var linkedFileGitStates: [UUID: LinkedFileGitState] = [:] } internal struct FavoritesOutlineTeamQuery { diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 8f0d57e476..88ee0b4655 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -5,6 +5,7 @@ internal struct FavoritesTabView: View { @ObservedObject private var teamLibrarySync = TeamLibrarySyncCoordinator.shared @ObservedObject private var licenseManager = LicenseManager.shared @ObservedObject private var settingsManager = AppSettingsManager.shared + @ObservedObject private var gitStatusStore = LinkedFolderGitStatusStore.shared @Environment(\.sidebarRowSize) private var systemRowSize @StateObject private var viewModel: FavoritesSidebarViewModel @@ -300,7 +301,8 @@ internal struct FavoritesTabView: View { }, renamingFolderId: viewModel.renamingFolderId, allFolders: viewModel.nodes.collectFolders(), - teamLibraryAvailable: licenseManager.isFeatureAvailable(.teamLibrary) + teamLibraryAvailable: licenseManager.isFeatureAvailable(.teamLibrary), + linkedFileGitStates: gitStatusStore.states(for: Self.linkedFavorites(in: items)) ), selection: $sharedSidebarState.selectedFavorite, rowSizePreference: settingsManager.general.sidebarRowSize, @@ -396,7 +398,13 @@ internal struct FavoritesTabView: View { case .linkedSubfolder(_, let displayName, _): LinkedSubfolderRowLabel(displayName: displayName) case .linkedFavorite(let linked): - LinkedFavoriteRowView(favorite: linked) + LinkedFavoriteRowView(favorite: linked, gitState: gitStatusStore.state(for: linked)) + } + } + + private static func linkedFavorites(in nodes: [FavoriteNode]) -> [LinkedSQLFavorite] { + nodes.flatMap { node in + [node.asLinkedFavorite].compactMap { $0 } + linkedFavorites(in: node.children ?? []) } } @@ -547,6 +555,8 @@ internal struct FavoritesTabView: View { } case .deleteFavorite(let favorite): viewModel.deleteFavorite(favorite) + case .showFavoriteHistory(let favorite): + coordinator?.showVersionHistory(of: favorite) case .openLinkedFavorite(let favorite): coordinator?.openLinkedFavorite(favorite) case .editLinkedMetadata(let favorite): @@ -559,6 +569,10 @@ internal struct FavoritesTabView: View { case .trashLinkedFavorite(let favorite): linkedFileToTrash = favorite showTrashLinkedFileAlert = true + case .showLinkedFileHistory(let favorite): + coordinator?.showVersionHistory(of: favorite) + case .discardLinkedFileChanges(let favorite): + discardChanges(to: favorite) case .revealLinkedFolder(let folder): viewModel.revealLinkedFolder(folder) case .setLinkedFolderEnabled(let folder, let isEnabled): @@ -588,6 +602,21 @@ internal struct FavoritesTabView: View { } } + private func discardChanges(to favorite: LinkedSQLFavorite) { + let keepsStagedChanges = gitStatusStore.state(for: favorite)?.status?.hasStagedChanges == true + Task { @MainActor in + await coordinator?.discardChanges(to: favorite) { + await AlertHelper.confirmDestructive( + title: String(format: String(localized: "Discard changes to \"%@\"?"), favorite.fileURL.lastPathComponent), + message: keepsStagedChanges + ? String(localized: "The file goes back to its staged version. You can't undo this action.") + : String(localized: "The file goes back to its last committed version. You can't undo this action."), + confirmButton: String(localized: "Discard Changes") + ) + } + } + } + private func useDatabase(_ entry: FavoriteDatabaseEntry) { guard entry.database != activeDatabase else { return } Task { await coordinator?.switchDatabase(to: entry.database) } diff --git a/TablePro/Views/Sidebar/LinkedFavoriteRowView.swift b/TablePro/Views/Sidebar/LinkedFavoriteRowView.swift index 59e926d9f0..021950634d 100644 --- a/TablePro/Views/Sidebar/LinkedFavoriteRowView.swift +++ b/TablePro/Views/Sidebar/LinkedFavoriteRowView.swift @@ -7,6 +7,7 @@ import SwiftUI internal struct LinkedFavoriteRowView: View { let favorite: LinkedSQLFavorite + var gitState: LinkedFileGitState? var body: some View { HStack(spacing: 6) { @@ -38,6 +39,15 @@ internal struct LinkedFavoriteRowView: View { .background(Capsule().fill(.quaternary)) .accessibilityHidden(true) } + + if let status = gitState?.status { + Text(verbatim: status.badge.letter) + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .foregroundStyle(.secondary) + .frame(minWidth: 12) + .help(status.accessibilityDescription) + .accessibilityHidden(true) + } } .accessibilityElement(children: .combine) .accessibilityLabel(accessibilityDescription) @@ -51,6 +61,9 @@ internal struct LinkedFavoriteRowView: View { if let keyword = favorite.keyword, !keyword.isEmpty { desc += ", " + String(format: String(localized: "keyword: %@"), keyword) } + if let status = gitState?.status { + desc += ", " + status.accessibilityDescription + } return desc } } diff --git a/TablePro/Views/Sidebar/Menu/FavoritesMenuCommand.swift b/TablePro/Views/Sidebar/Menu/FavoritesMenuCommand.swift index 0e64a14ffe..14c43940c9 100644 --- a/TablePro/Views/Sidebar/Menu/FavoritesMenuCommand.swift +++ b/TablePro/Views/Sidebar/Menu/FavoritesMenuCommand.swift @@ -25,12 +25,15 @@ internal enum FavoritesMenuCommand: Equatable { case editFavorite(SQLFavorite) case moveFavorite(id: UUID, toFolder: UUID?) case deleteFavorite(SQLFavorite) + case showFavoriteHistory(SQLFavorite) case openLinkedFavorite(LinkedSQLFavorite) case editLinkedMetadata(LinkedSQLFavorite) case copyLinkedFavoriteQuery(LinkedSQLFavorite) case revealLinkedFavorite(LinkedSQLFavorite) case trashLinkedFavorite(LinkedSQLFavorite) + case showLinkedFileHistory(LinkedSQLFavorite) + case discardLinkedFileChanges(LinkedSQLFavorite) case revealLinkedFolder(LinkedSQLFolder) case setLinkedFolderEnabled(LinkedSQLFolder, Bool) diff --git a/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift b/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift index a86807f6db..4ca256b55e 100644 --- a/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/FavoritesMenuSpec.swift @@ -12,19 +12,22 @@ internal struct FavoritesMenuContext { internal let teamLibraryAvailable: Bool internal let databaseEntityName: String internal let activeDatabase: String? + internal let linkedFileGitStates: [UUID: LinkedFileGitState] internal init( clicked: FavoritesOutlineNode.Kind?, allFolders: [SQLFavoriteFolder] = [], teamLibraryAvailable: Bool = false, databaseEntityName: String = "Database", - activeDatabase: String? = nil + activeDatabase: String? = nil, + linkedFileGitStates: [UUID: LinkedFileGitState] = [:] ) { self.clicked = clicked self.allFolders = allFolders self.teamLibraryAvailable = teamLibraryAvailable self.databaseEntityName = databaseEntityName self.activeDatabase = activeDatabase + self.linkedFileGitStates = linkedFileGitStates } } @@ -100,7 +103,7 @@ internal enum FavoritesMenuSpec { case .favorite(let favorite): return favoriteSections(favorite, context: context) case .linkedFavorite(let linked): - return linkedFavoriteSections(linked) + return linkedFavoriteSections(linked, gitState: context.linkedFileGitStates[linked.id]) case .folder(let folder): return folderSections(folder) case .linkedFolder(let folder): @@ -118,7 +121,8 @@ internal enum FavoritesMenuSpec { ) -> [FavoritesMenuSection] { var edits: [FavoritesMenuItem] = [ .command(String(localized: "Copy Query"), .copyText(favorite.query)), - .command(String(localized: "Edit…"), .editFavorite(favorite)) + .command(String(localized: "Edit…"), .editFavorite(favorite)), + .command(String(localized: "Show History"), .showFavoriteHistory(favorite)) ] if let moveTo = moveToSubmenu(favorite, folders: context.allFolders) { edits.append(moveTo) @@ -163,12 +167,16 @@ internal enum FavoritesMenuSpec { ) } - private static func linkedFavoriteSections(_ favorite: LinkedSQLFavorite) -> [FavoritesMenuSection] { + private static func linkedFavoriteSections( + _ favorite: LinkedSQLFavorite, + gitState: LinkedFileGitState? + ) -> [FavoritesMenuSection] { [ FavoritesMenuSection([ .command(String(localized: "Open in Editor"), .openLinkedFavorite(favorite)), .command(String(localized: "Edit Metadata…"), .editLinkedMetadata(favorite)) ]), + FavoritesMenuSection(gitItems(favorite, gitState: gitState)), FavoritesMenuSection([ .command(String(localized: "Copy Query"), .copyLinkedFavoriteQuery(favorite)), .command(String(localized: "Show in Finder"), .revealLinkedFavorite(favorite)) @@ -179,6 +187,18 @@ internal enum FavoritesMenuSpec { ] } + private static func gitItems(_ favorite: LinkedSQLFavorite, gitState: LinkedFileGitState?) -> [FavoritesMenuItem] { + guard let gitState else { return [] } + var items: [FavoritesMenuItem] = [] + if gitState.hasCommittedHistory { + items.append(.command(String(localized: "Show History"), .showLinkedFileHistory(favorite))) + } + if gitState.canDiscardChanges { + items.append(.command(String(localized: "Discard Changes…"), .discardLinkedFileChanges(favorite))) + } + return items + } + private static func linkedFolderSections(_ folder: LinkedSQLFolder) -> [FavoritesMenuSection] { [ FavoritesMenuSection([ diff --git a/TablePro/Views/VersionHistory/VersionHistoryChangesView.swift b/TablePro/Views/VersionHistory/VersionHistoryChangesView.swift new file mode 100644 index 0000000000..a0204f47fb --- /dev/null +++ b/TablePro/Views/VersionHistory/VersionHistoryChangesView.swift @@ -0,0 +1,64 @@ +// +// VersionHistoryChangesView.swift +// TablePro +// + +import SwiftUI + +internal struct VersionHistoryChangesView: View { + let detail: VersionHistoryDetail + let layout: TextDiffLayout + let databaseType: DatabaseType + let exportFileName: String + let onOpenInEditor: (String) -> Void + + var body: some View { + if let comparison = detail.comparison { + switch comparison.outcome { + case .identical: + UnavailableStateView( + String(localized: "No Changes"), + systemImage: "equal.circle", + description: Text(String( + format: String(localized: "This version matches %@."), + VersionHistoryFormatting.shortLabel(for: detail.entry.isCurrent ? comparison.older : comparison.newer) + )) + ) + case .tooLarge: + VStack(spacing: 0) { + Text("This version is too large to compare. Showing its content instead.") + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 6) + Divider() + VersionHistoryContentView( + content: detail.content, + databaseType: databaseType, + exportFileName: exportFileName, + onOpenInEditor: onOpenInEditor + ) + } + case .differs(let pairs): + ScrollView { + TextDiffView( + pairs: pairs, + beforeLabel: VersionHistoryFormatting.shortLabel(for: comparison.older), + afterLabel: VersionHistoryFormatting.shortLabel(for: comparison.newer), + layout: layout, + textFont: .system(.body, design: .monospaced) + ) + .padding(12) + } + .accessibilityIdentifier("version-history-diff") + } + } else { + UnavailableStateView( + String(localized: "No Earlier Version"), + systemImage: "clock", + description: Text("There is nothing earlier to compare this version with.") + ) + } + } +} diff --git a/TablePro/Views/VersionHistory/VersionHistoryContentView.swift b/TablePro/Views/VersionHistory/VersionHistoryContentView.swift new file mode 100644 index 0000000000..63cfcd81e7 --- /dev/null +++ b/TablePro/Views/VersionHistory/VersionHistoryContentView.swift @@ -0,0 +1,22 @@ +// +// VersionHistoryContentView.swift +// TablePro +// + +import SwiftUI + +internal struct VersionHistoryContentView: View { + let content: String + let databaseType: DatabaseType + let exportFileName: String + let onOpenInEditor: (String) -> Void + + var body: some View { + ObjectSourceView( + source: content, + databaseType: databaseType, + exportFileName: exportFileName, + onOpenInEditor: { onOpenInEditor(content) } + ) + } +} diff --git a/TablePro/Views/VersionHistory/VersionHistoryDetailHeader.swift b/TablePro/Views/VersionHistory/VersionHistoryDetailHeader.swift new file mode 100644 index 0000000000..616e25d36c --- /dev/null +++ b/TablePro/Views/VersionHistory/VersionHistoryDetailHeader.swift @@ -0,0 +1,62 @@ +// +// VersionHistoryDetailHeader.swift +// TablePro +// + +import SwiftUI + +internal struct VersionHistoryDetailHeader: View { + @ObservedObject var viewModel: VersionHistoryViewModel + + var body: some View { + HStack(spacing: 12) { + if let entry = viewModel.selectedEntry { + VStack(alignment: .leading, spacing: 2) { + Text(VersionHistoryFormatting.title(for: entry)) + .font(.headline) + .lineLimit(1) + .truncationMode(.tail) + .textSelection(.enabled) + let subtitle = VersionHistoryFormatting.subtitle(for: entry) + if !subtitle.isEmpty { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .textSelection(.enabled) + } + } + } + Spacer(minLength: 8) + Picker(String(localized: "Show"), selection: $viewModel.displayMode) { + Text("Changes").tag(VersionHistoryViewModel.DisplayMode.changes) + Text("Content").tag(VersionHistoryViewModel.DisplayMode.content) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + .accessibilityIdentifier("version-history-display-mode") + if viewModel.displayMode == .changes { + Picker(String(localized: "Layout"), selection: $viewModel.diffLayout) { + Text("Split").tag(TextDiffLayout.split) + Text("Unified").tag(TextDiffLayout.unified) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + } + Button(String(localized: "Restore This Version"), action: restoreSelection) + .disabled(!viewModel.canRestoreSelection) + .help(String(localized: "Replace the current text with this version")) + .accessibilityIdentifier("version-history-restore") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(nsColor: .controlBackgroundColor)) + } + + private func restoreSelection() { + guard let entry = viewModel.selectedEntry else { return } + Task { await viewModel.restore(entry) } + } +} diff --git a/TablePro/Views/VersionHistory/VersionHistoryDetailPane.swift b/TablePro/Views/VersionHistory/VersionHistoryDetailPane.swift new file mode 100644 index 0000000000..8c1bd58f88 --- /dev/null +++ b/TablePro/Views/VersionHistory/VersionHistoryDetailPane.swift @@ -0,0 +1,57 @@ +// +// VersionHistoryDetailPane.swift +// TablePro +// + +import SwiftUI + +internal struct VersionHistoryDetailPane: View { + @ObservedObject var viewModel: VersionHistoryViewModel + let databaseType: DatabaseType + let exportFileName: String + let onOpenInEditor: (String) -> Void + + var body: some View { + VStack(spacing: 0) { + VersionHistoryDetailHeader(viewModel: viewModel) + Divider() + detail + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private var detail: some View { + switch viewModel.detailState { + case .empty: + UnavailableStateView(String(localized: "No Version Selected"), systemImage: "clock.arrow.circlepath") + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed(let message): + UnavailableStateView { + Label(String(localized: "Version Unavailable"), systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } + case .loaded(let loaded): + switch viewModel.displayMode { + case .changes: + VersionHistoryChangesView( + detail: loaded, + layout: viewModel.diffLayout, + databaseType: databaseType, + exportFileName: exportFileName, + onOpenInEditor: onOpenInEditor + ) + case .content: + VersionHistoryContentView( + content: loaded.content, + databaseType: databaseType, + exportFileName: exportFileName, + onOpenInEditor: onOpenInEditor + ) + } + } + } +} diff --git a/TablePro/Views/VersionHistory/VersionHistoryFactory.swift b/TablePro/Views/VersionHistory/VersionHistoryFactory.swift new file mode 100644 index 0000000000..cd08b4b162 --- /dev/null +++ b/TablePro/Views/VersionHistory/VersionHistoryFactory.swift @@ -0,0 +1,49 @@ +// +// VersionHistoryFactory.swift +// TablePro +// + +import Combine +import Foundation + +@MainActor +internal enum VersionHistoryFactory { + static func makeViewModel(for subject: VersionHistorySubject) -> VersionHistoryViewModel { + let viewModel: VersionHistoryViewModel + switch subject { + case .savedQuery(let id): + viewModel = VersionHistoryViewModel( + subject: subject, + provider: SavedQueryVersionHistoryProvider(favoriteId: id, manager: .shared), + refreshSignal: AppEvents.shared.sqlFavoritesDidUpdate.map { _ in () }.eraseToAnyPublisher() + ) + case .linkedFile(let url): + viewModel = VersionHistoryViewModel( + subject: subject, + provider: LinkedFileVersionHistoryProvider(fileURL: url), + refreshSignal: AppEvents.shared.linkedSQLFoldersDidUpdate.map { _ in () } + .merge(with: LinkedFolderGitStatusStore.shared.$snapshots.dropFirst().map { _ in () }) + .eraseToAnyPublisher() + ) + viewModel.onRestored = { + MainContentCoordinator.reloadUnmodifiedFileTabs(at: url) + LinkedFolderGitStatusStore.shared.scheduleRefresh(after: .zero) + } + viewModel.confirmReplacingUncommittedChanges = { + await AlertHelper.confirmDestructive( + title: String(format: String(localized: "Restore this version of \"%@\"?"), url.lastPathComponent), + message: String(localized: "The file has uncommitted changes. Restoring this version replaces them, and you can't undo this action."), + confirmButton: String(localized: "Restore") + ) + } + } + viewModel.reportRestoreFailure = { message in + AlertHelper.showErrorSheet( + title: String(localized: "Couldn't Restore Version"), + message: message, + window: nil + ) + } + return viewModel + } +} diff --git a/TablePro/Views/VersionHistory/VersionHistoryFormatting.swift b/TablePro/Views/VersionHistory/VersionHistoryFormatting.swift new file mode 100644 index 0000000000..7aadf83fdf --- /dev/null +++ b/TablePro/Views/VersionHistory/VersionHistoryFormatting.swift @@ -0,0 +1,80 @@ +// +// VersionHistoryFormatting.swift +// TablePro +// + +import Foundation + +internal enum VersionHistoryFormatting { + static func title(for entry: VersionHistoryEntry) -> String { + if entry.isCurrent { + return String(localized: "Current Version") + } + if let summary = entry.summary, !summary.isBlank { + return summary + } + return dateText(for: entry) ?? String(localized: "Earlier Version") + } + + static func subtitle(for entry: VersionHistoryEntry) -> String { + var parts: [String] = [] + if entry.isCurrent, entry.hasUncommittedChanges { + parts.append(String(localized: "Uncommitted changes")) + } + if let author = entry.author, !author.isBlank { + parts.append(author) + } + let showsDateInTitle = !entry.isCurrent && (entry.summary ?? "").isBlank + if !showsDateInTitle, let date = dateText(for: entry) { + parts.append(date) + } + if let revision = entry.reference.shortRevision { + parts.append(revision) + } + return parts.joined(separator: " · ") + } + + static func rowSubtitle(for entry: VersionHistoryEntry) -> String { + var parts: [String] = [] + if entry.isCurrent, entry.hasUncommittedChanges { + parts.append(String(localized: "Uncommitted changes")) + } + if let author = entry.author, !author.isBlank { + parts.append(author) + } + let showsDateInTitle = !entry.isCurrent && (entry.summary ?? "").isBlank + if !showsDateInTitle, let date = entry.date { + parts.append(date.formatted(date: .abbreviated, time: .omitted)) + } + return parts.joined(separator: " · ") + } + + static func shortLabel(for entry: VersionHistoryEntry) -> String { + if entry.isCurrent { + return String(localized: "Current Version") + } + if let revision = entry.reference.shortRevision { + return revision + } + return dateText(for: entry) ?? String(localized: "Earlier Version") + } + + static func accessibilityLabel(for entry: VersionHistoryEntry) -> String { + let subtitle = subtitle(for: entry) + guard !subtitle.isEmpty else { return title(for: entry) } + return String(format: String(localized: "%@, %@"), title(for: entry), subtitle) + } + + static func noticeText(_ notice: VersionHistoryNotice) -> String { + switch notice { + case .keepsLatestVersions(let count): + return String(format: String(localized: "Keeps the last %d versions on this Mac."), count) + case .showsLatestCommits(let count): + return String(format: String(localized: "Showing the %d most recent commits."), count) + } + } + + private static func dateText(for entry: VersionHistoryEntry) -> String? { + entry.date?.formatted(date: .abbreviated, time: .shortened) + } +} diff --git a/TablePro/Views/VersionHistory/VersionHistoryListPane.swift b/TablePro/Views/VersionHistory/VersionHistoryListPane.swift new file mode 100644 index 0000000000..0074899d40 --- /dev/null +++ b/TablePro/Views/VersionHistory/VersionHistoryListPane.swift @@ -0,0 +1,62 @@ +// +// VersionHistoryListPane.swift +// TablePro +// + +import SwiftUI + +internal struct VersionHistoryListPane: View { + @ObservedObject var viewModel: VersionHistoryViewModel + + var body: some View { + FieldDrivenList( + sections: [FieldDrivenListSection(id: "versions", items: viewModel.page.entries)], + selection: selectionBinding, + rowHeight: 44, + menuItems: { ids in menuItems(for: ids) }, + acceptsFocus: true, + accessibilityIdentifier: "version-history-list", + row: { entry in VersionHistoryRowView(entry: entry) } + ) + .safeAreaInset(edge: .bottom, spacing: 0) { + if let notice = viewModel.page.notice { + VStack(spacing: 0) { + Divider() + Text(VersionHistoryFormatting.noticeText(notice)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + .background(Color(nsColor: .controlBackgroundColor)) + } + } + } + + private var selectionBinding: Binding> { + Binding( + get: { viewModel.selection.map { [$0] } ?? [] }, + set: { viewModel.selection = $0.first } + ) + } + + private func menuItems(for ids: Set) -> [FieldDrivenMenuItem] { + guard let id = ids.first, + let entry = viewModel.page.entries.first(where: { $0.reference == id }), + !entry.isCurrent + else { return [] } + return [ + FieldDrivenMenuItem( + title: String(localized: "Restore This Version"), + isEnabled: !viewModel.isRestoring + ) { + restore(entry) + } + ] + } + + private func restore(_ entry: VersionHistoryEntry) { + Task { await viewModel.restore(entry) } + } +} diff --git a/TablePro/Views/VersionHistory/VersionHistoryRowView.swift b/TablePro/Views/VersionHistory/VersionHistoryRowView.swift new file mode 100644 index 0000000000..68053490af --- /dev/null +++ b/TablePro/Views/VersionHistory/VersionHistoryRowView.swift @@ -0,0 +1,37 @@ +// +// VersionHistoryRowView.swift +// TablePro +// + +import SwiftUI + +internal struct VersionHistoryRowView: View { + let entry: VersionHistoryEntry + + var body: some View { + HStack(spacing: 8) { + Image(systemName: entry.isCurrent ? "doc.text" : "clock.arrow.circlepath") + .foregroundStyle(.secondary) + .frame(width: 16) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(VersionHistoryFormatting.title(for: entry)) + .bold(entry.isCurrent) + .lineLimit(1) + .truncationMode(.tail) + let subtitle = VersionHistoryFormatting.rowSubtitle(for: entry) + if !subtitle.isEmpty { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 6) + .accessibilityElement(children: .ignore) + .accessibilityLabel(VersionHistoryFormatting.accessibilityLabel(for: entry)) + } +} diff --git a/TablePro/Views/VersionHistory/VersionHistoryTabView.swift b/TablePro/Views/VersionHistory/VersionHistoryTabView.swift new file mode 100644 index 0000000000..c0f1ec1289 --- /dev/null +++ b/TablePro/Views/VersionHistory/VersionHistoryTabView.swift @@ -0,0 +1,75 @@ +// +// VersionHistoryTabView.swift +// TablePro +// + +import Combine +import SwiftUI + +internal struct VersionHistoryTabView: View { + let tabId: UUID + let databaseType: DatabaseType + let exportFileName: String + let onOpenInEditor: (String) -> Void + + @StateObject private var viewModel: VersionHistoryViewModel + + init( + tabId: UUID, + subject: VersionHistorySubject, + databaseType: DatabaseType, + exportFileName: String, + onOpenInEditor: @escaping (String) -> Void + ) { + self.tabId = tabId + self.databaseType = databaseType + self.exportFileName = exportFileName + self.onOpenInEditor = onOpenInEditor + _viewModel = StateObject(wrappedValue: VersionHistoryFactory.makeViewModel(for: subject)) + } + + var body: some View { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + .task { await viewModel.loadList() } + .onReceive(AppEvents.shared.versionHistoryRefreshRequested) { requestedTabId in + guard requestedTabId == tabId else { return } + viewModel.reload() + } + } + + @ViewBuilder + private var content: some View { + switch viewModel.listState { + case .failed(let message): + UnavailableStateView { + Label(String(localized: "History Unavailable"), systemImage: "clock.arrow.circlepath") + } description: { + Text(message) + } actions: { + Button(String(localized: "Try Again")) { viewModel.reload() } + } + case .loading where viewModel.page.entries.isEmpty: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + default: + AutosavingSplitView( + autosaveName: "com.TablePro.versionHistory.listDetail", + primaryMinimum: 220, + secondaryMinimum: 360, + primaryThicknessFraction: 0.3, + primaryAutomaticMaximum: 360 + ) { + VersionHistoryListPane(viewModel: viewModel) + } secondary: { + VersionHistoryDetailPane( + viewModel: viewModel, + databaseType: databaseType, + exportFileName: exportFileName, + onOpenInEditor: onOpenInEditor + ) + } + } + } +} diff --git a/TableProTests/Core/Git/GitIntegrationTests.swift b/TableProTests/Core/Git/GitIntegrationTests.swift new file mode 100644 index 0000000000..050db63c03 --- /dev/null +++ b/TableProTests/Core/Git/GitIntegrationTests.swift @@ -0,0 +1,268 @@ +// +// GitIntegrationTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +private struct ScratchRepository { + let root: URL + let client: GitClient + + init(client: GitClient) throws { + self.client = client + root = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-git-tests") + .appendingPathComponent(UUID().uuidString) + .resolvingSymlinksInPath() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } + + func url(_ relativePath: String) -> URL { + root.appendingPathComponent(relativePath) + } + + func write(_ text: String, to relativePath: String) throws { + let url = url(relativePath) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(text.utf8).write(to: url) + } + + func read(_ relativePath: String) throws -> String { + try String(contentsOf: url(relativePath), encoding: .utf8) + } + + @discardableResult + func git(_ arguments: String...) async throws -> GitProcessResult { + let identity = [ + "-c", "user.name=Ann Author", "-c", "user.email=ann@example.com", + "-c", "commit.gpgsign=false", "-c", "init.defaultBranch=main", + ] + let result = try await client.runner.run(GitCommand(arguments: identity + arguments, workingDirectory: root)) + guard result.succeeded else { throw GitCommandFailure(exitCode: result.exitCode, message: result.errorMessage) } + return result + } + + func indexIdentity() throws -> [FileAttributeKey: Any] { + let attributes = try FileManager.default.attributesOfItem(atPath: url(".git/index").path) + return [ + .systemFileNumber: attributes[.systemFileNumber] ?? 0, + .modificationDate: attributes[.modificationDate] ?? Date.distantPast, + ] + } +} + +@Suite("Git against a real repository", .enabled(if: GitClient.make() != nil), .serialized) +struct GitIntegrationTests { + private let client: GitClient + + init() throws { + client = try #require(GitClient.make()) + } + + @Test("Status from a linked subfolder maps paths into the folder and never writes the index") + func statusFromSubfolder() async throws { + let repo = try ScratchRepository(client: client) + defer { repo.remove() } + try await repo.git("init", "-q") + try repo.write("SELECT 1;\n", to: "sql queries/a b.sql") + try repo.write("SELECT 2;\n", to: "sql queries/clean.sql") + try await repo.git("add", "-A") + try await repo.git("commit", "-qm", "first") + try repo.write("SELECT 10;\n", to: "sql queries/a b.sql") + try repo.write("new\n", to: "sql queries/日本.sql") + try repo.write("elsewhere\n", to: "other/x.sql") + try await Task.sleep(for: .milliseconds(1_100)) + let before = try repo.indexIdentity() + + let folder = repo.url("sql queries") + let info = try #require(try await client.repositoryInfo(in: folder)) + let records = try await client.status(in: folder) + let statuses = LinkedFolderGitStatusStore.folderRelativeStatuses(records, prefix: info.prefix) + + let tracked = LinkedFolderGitStatusStore.folderRelativePaths( + try await client.trackedFiles(in: folder), + prefix: info.prefix + ) + #expect(tracked == ["a b.sql", "clean.sql"]) + #expect(info.prefix == "sql queries/") + #expect(statuses["a b.sql"]?.canDiscardChanges == true) + #expect(statuses["日本.sql"]?.isUntracked == true) + #expect(statuses["clean.sql"] == nil) + #expect(statuses.count == 2) + let after = try repo.indexIdentity() + #expect(before[.systemFileNumber] as? Int == after[.systemFileNumber] as? Int) + #expect(before[.modificationDate] as? Date == after[.modificationDate] as? Date) + } + + @Test("A folder outside any repository is not one") + func notARepository() async throws { + let repo = try ScratchRepository(client: client) + defer { repo.remove() } + try repo.write("SELECT 1;\n", to: "q.sql") + + #expect(try await client.repositoryInfo(in: repo.root) == nil) + await #expect(throws: VersionHistoryError.notInRepository) { + try await LinkedFileVersionHistoryProvider(fileURL: repo.url("q.sql")).loadHistory() + } + } + + @Test("History follows a rename, reads each version, and restores one into the current file") + func historyAcrossRename() async throws { + let repo = try ScratchRepository(client: client) + defer { repo.remove() } + try await repo.git("init", "-q") + try repo.write("v1\n", to: "q/old name.sql") + try await repo.git("add", "-A") + try await repo.git("commit", "-qm", "add") + try repo.write("v2\n", to: "q/old name.sql") + try await repo.git("commit", "-qam", "second") + try await repo.git("mv", "q/old name.sql", "q/new name.sql") + try await repo.git("commit", "-qm", "rename") + try repo.write("v3\n", to: "q/new name.sql") + try await repo.git("commit", "-qam", "third") + try repo.write("working\n", to: "q/new name.sql") + + let provider = LinkedFileVersionHistoryProvider(fileURL: repo.url("q/new name.sql")) + let page = try await provider.loadHistory() + + #expect(page.entries.first?.isCurrent == true) + #expect(page.entries.first?.hasUncommittedChanges == true) + #expect(page.entries.dropFirst().compactMap(\.summary) == ["third", "rename", "second", "add"]) + #expect(page.notice == nil) + let oldest = try #require(page.entries.last) + #expect(try await provider.content(of: oldest.reference) == "v1\n") + #expect(try await provider.content(of: .current) == "working\n") + + let plan = try await provider.prepareRestore(oldest.reference) + #expect(plan.replacesUncommittedChanges) + try await plan.apply() + + #expect(try repo.read("q/new name.sql") == "v1\n") + #expect(FileManager.default.fileExists(atPath: repo.url("q/old name.sql").path) == false) + } + + @Test("Discard puts back the staged text, keeps the stage, and leaves an unstaged-only file at its commit") + func discardKeepsStagedChanges() async throws { + let repo = try ScratchRepository(client: client) + defer { repo.remove() } + try await repo.git("init", "-q") + try repo.write("committed\n", to: "staged.sql") + try repo.write("committed\n", to: "plain.sql") + try await repo.git("add", "-A") + try await repo.git("commit", "-qm", "first") + try repo.write("staged\n", to: "staged.sql") + try await repo.git("add", "staged.sql") + try repo.write("unstaged\n", to: "staged.sql") + try repo.write("unstaged\n", to: "plain.sql") + + try await LinkedFileVersionHistoryProvider(fileURL: repo.url("staged.sql")).prepareDiscard().apply() + try await LinkedFileVersionHistoryProvider(fileURL: repo.url("plain.sql")).prepareDiscard().apply() + + #expect(try repo.read("staged.sql") == "staged\n") + #expect(try repo.read("plain.sql") == "committed\n") + let records = try await client.status(in: repo.root) + let staged = try #require(records.first { $0.path == "staged.sql" }) + #expect(staged.status == GitFileStatus(staged: .modified, unstaged: .unmodified)) + #expect(records.contains { $0.path == "plain.sql" } == false) + } + + @Test("A repository with no commits shows only the current version") + func unbornRepository() async throws { + let repo = try ScratchRepository(client: client) + defer { repo.remove() } + try await repo.git("init", "-q") + try repo.write("SELECT 1;\n", to: "q.sql") + + let page = try await LinkedFileVersionHistoryProvider(fileURL: repo.url("q.sql")).loadHistory() + + #expect(page.entries.count == 1) + #expect(page.entries.first?.isCurrent == true) + #expect(page.entries.first?.hasUncommittedChanges == true) + } + + @Test("A deleted file reports that it is gone") + func missingFile() async throws { + let repo = try ScratchRepository(client: client) + defer { repo.remove() } + try await repo.git("init", "-q") + + await #expect(throws: VersionHistoryError.subjectNotFound) { + try await LinkedFileVersionHistoryProvider(fileURL: repo.url("gone.sql")).loadHistory() + } + } + + @Test("A write that finds the file changed since it was prepared replaces nothing") + func restoreRefusesAFileThatChanged() async throws { + let repo = try ScratchRepository(client: client) + defer { repo.remove() } + try await repo.git("init", "-q") + try repo.write("v1\n", to: "q.sql") + try await repo.git("add", "-A") + try await repo.git("commit", "-qm", "first") + try repo.write("v2\n", to: "q.sql") + try await repo.git("commit", "-qam", "second") + let provider = LinkedFileVersionHistoryProvider(fileURL: repo.url("q.sql")) + let first = try #require(try await provider.loadHistory().entries.last) + + let plan = try await provider.prepareRestore(first.reference) + #expect(!plan.replacesUncommittedChanges) + try repo.write("edited meanwhile\n", to: "q.sql") + + await #expect(throws: VersionHistoryError.fileChangedBeforeWriting) { try await plan.apply() } + #expect(try repo.read("q.sql") == "edited meanwhile\n") + } + + @Test("A file git is told to ignore changes to still counts as changed when its bytes differ") + func assumeUnchangedStillAsks() async throws { + let repo = try ScratchRepository(client: client) + defer { repo.remove() } + try await repo.git("init", "-q") + try repo.write("v1\n", to: "q.sql") + try await repo.git("add", "-A") + try await repo.git("commit", "-qm", "first") + try await repo.git("update-index", "--assume-unchanged", "q.sql") + try repo.write("local edit\n", to: "q.sql") + let provider = LinkedFileVersionHistoryProvider(fileURL: repo.url("q.sql")) + let first = try #require(try await provider.loadHistory().entries.last) + + let plan = try await provider.prepareRestore(first.reference) + + #expect(plan.replacesUncommittedChanges) + } + + @Test("A Git LFS pointer is never written over the file") + func largeFileStoragePointerIsRefused() { + let pointer = Data("version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 12\n".utf8) + #expect(throws: VersionHistoryError.storedInLargeFileStorage) { + try LinkedFileVersionHistoryProvider.rejectLargeFileStoragePointer(pointer) + } + #expect(throws: Never.self) { + try LinkedFileVersionHistoryProvider.rejectLargeFileStoragePointer(Data("SELECT 1;\n".utf8)) + } + } + + @Test("Discard writes nothing when the staged version changed after it was prepared") + func discardRefusesAChangedIndex() async throws { + let repo = try ScratchRepository(client: client) + defer { repo.remove() } + try await repo.git("init", "-q") + try repo.write("committed\n", to: "q.sql") + try await repo.git("add", "-A") + try await repo.git("commit", "-qm", "first") + try repo.write("edited\n", to: "q.sql") + let plan = try await LinkedFileVersionHistoryProvider(fileURL: repo.url("q.sql")).prepareDiscard() + + try await repo.git("add", "q.sql") + + await #expect(throws: VersionHistoryError.fileChangedBeforeWriting) { try await plan.apply() } + #expect(try repo.read("q.sql") == "edited\n") + } +} diff --git a/TableProTests/Core/Git/GitOutputParserTests.swift b/TableProTests/Core/Git/GitOutputParserTests.swift new file mode 100644 index 0000000000..88a06f4bce --- /dev/null +++ b/TableProTests/Core/Git/GitOutputParserTests.swift @@ -0,0 +1,302 @@ +// +// GitOutputParserTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Git output parsers") +struct GitOutputParserTests { + private func bytes(_ string: String) -> Data { + Data(string.utf8) + } + + @Test("Porcelain v2 records keep spaces and non-ASCII in paths, and a rename carries its original path") + func statusRecords() { + let output = bytes( + "1 .M N... 100644 100644 100644 aaaa bbbb sql queries/a b.sql\0" + + "1 MM N... 100644 100644 100644 aaaa bbbb sql queries/staged.sql\0" + + "1 A. N... 000000 100644 100644 0000 bbbb sql queries/added.sql\0" + + "2 R. N... 100644 100644 100644 aaaa aaaa R100 sql queries/new name.sql\0sql queries/old name.sql\0" + + "u UU N... 100644 100644 100644 100644 aaaa bbbb cccc sql queries/conflict.sql\0" + + "? sql queries/日本.sql\0" + ) + + let records = GitStatusParser.parse(output) + + #expect(records.map(\.path) == [ + "sql queries/a b.sql", + "sql queries/staged.sql", + "sql queries/added.sql", + "sql queries/new name.sql", + "sql queries/conflict.sql", + "sql queries/日本.sql", + ]) + #expect(records[0].status == GitFileStatus(staged: .unmodified, unstaged: .modified)) + #expect(records[1].status == GitFileStatus(staged: .modified, unstaged: .modified)) + #expect(records[2].status == GitFileStatus(staged: .added, unstaged: .unmodified)) + #expect(records[3].originalPath == "sql queries/old name.sql") + #expect(records[3].status.staged == .renamed) + #expect(records[4].status.isConflicted) + #expect(records[5].status.isUntracked) + } + + @Test("Every unmerged record is a conflict, including add/add where XY has no U") + func addAddConflict() throws { + let output = bytes("u AA N... 000000 100644 100644 100644 0000 6178 7898 q/c.sql\0") + let record = try #require(GitStatusParser.parse(output).first) + #expect(record.status.isConflicted) + #expect(record.status.badge == .conflicted) + #expect(!record.status.canDiscardChanges) + } + + @Test("An empty status is no records") + func emptyStatus() { + #expect(GitStatusParser.parse(Data()).isEmpty) + } + + @Test("A followed log reports each commit with the path the file had at that commit") + func logFollowsRenames() throws { + let output = bytes( + "\u{1E}4e3138764045879d706b709d7f4dd42897109548\u{1F}Ann Author\u{1F}2026-09-23T10:09:04+07:00" + + "\u{1F}subject with \u{1F}? no; tab\there\0\nM\0sub dir/new name.sql\0" + + "\u{1E}1189ca3714ed5cee0a4b5885a64adb10d8bcb830\u{1F}Ann Author\u{1F}2026-09-23T10:09:04+07:00" + + "\u{1F}rename it\0\nR100\0sub dir/old name.sql\0sub dir/new name.sql\0" + + "\u{1E}9362855bae5a38735f19ddcfc763bba0cf5a2df2\u{1F}Ann Author\u{1F}2026-09-23T10:09:03+07:00\u{1F}second\0\nM\0sub dir/old name.sql\0" + + "\u{1E}d5fc769941d0c335980f7be7b6df3a2bcefdd9fd\u{1F}Ann Author\u{1F}2026-09-23T10:09:03+07:00\u{1F}first: add\0\nA\0sub dir/old name.sql\0" + ) + + let commits = GitLogParser.parse(output) + + #expect(commits.map { String($0.hash.prefix(4)) } == ["4e31", "1189", "9362", "d5fc"]) + #expect(commits.map(\.path) == [ + "sub dir/new name.sql", + "sub dir/new name.sql", + "sub dir/old name.sql", + "sub dir/old name.sql", + ]) + #expect(commits[0].subject == "subject with \u{1F}? no; tab\there") + #expect(commits[3].subject == "first: add") + #expect(commits[0].author == "Ann Author") + let date = try #require(commits[0].date) + #expect(date == Date(timeIntervalSince1970: 1_790_132_944)) + } + + @Test("A commit with no file entry keeps its header and leaves the path unknown") + func logCommitWithoutPath() { + let output = bytes("\u{1E}abcd\(String(repeating: "0", count: 36))\u{1F}Ann\u{1F}2026-09-23T10:09:04+07:00\u{1F}merge\0") + let commits = GitLogParser.parse(output) + #expect(commits.count == 1) + #expect(commits[0].path == nil) + } + + @Test("A commit that deleted the file has no version to show and is skipped") + func logSkipsDeletions() { + let deleted = "b".padding(toLength: 40, withPad: "b", startingAt: 0) + let added = "c".padding(toLength: 40, withPad: "c", startingAt: 0) + let output = bytes( + "\u{1E}\(deleted)\u{1F}Ann\u{1F}2026-09-23T10:09:04+07:00\u{1F}drop it\0\nD\0q.sql\0" + + "\u{1E}\(added)\u{1F}Ann\u{1F}2026-09-22T10:09:04+07:00\u{1F}add it\0\nA\0q.sql\0" + ) + #expect(GitLogParser.parse(output).map(\.hash) == [added]) + } + + @Test("A header whose hash is not a commit id is dropped, so it can never reach a cat-file argument") + func logRejectsNonHashHeaders() { + let output = bytes("\u{1E}--batch-check\u{1F}Ann\u{1F}2026-09-23T10:09:04+07:00\u{1F}x\0\nM\0q.sql\0") + #expect(GitLogParser.parse(output).isEmpty) + #expect(GitLogParser.isCommitHash(Substring(String(repeating: "a", count: 64)))) + #expect(!GitLogParser.isCommitHash("HEAD")) + } + + @Test("rev-parse output maps to the top level, the git directory and the prefix, which is empty at the root") + func repositoryInfo() throws { + let nested = try #require(GitRepositoryInfoParser.parse(bytes("/tmp/repo\n/tmp/repo/.git\nsql queries/\n"))) + #expect(nested.topLevel.path == "/tmp/repo") + #expect(nested.gitDirectory.path == "/tmp/repo/.git") + #expect(nested.prefix == "sql queries/") + + let root = try #require(GitRepositoryInfoParser.parse(bytes("/tmp/repo\n/tmp/repo/.git\n\n"))) + #expect(root.prefix.isEmpty) + + #expect(GitRepositoryInfoParser.parse(Data()) == nil) + } + + @Test("A file with no status is clean only when the index tracks it") + func trackedVersusIgnored() throws { + let snapshot = LinkedFolderGitSnapshot( + repository: try #require(GitRepositoryInfoParser.parse(bytes("/r\n/r/.git\n\n"))), + head: nil, + statuses: ["new.sql": .untracked], + trackedPaths: ["clean.sql"] + ) + #expect(snapshot.state(forRelativePath: "clean.sql") == .clean) + #expect(snapshot.state(forRelativePath: "new.sql") == .changed(.untracked)) + #expect(snapshot.state(forRelativePath: "ignored.sql") == nil) + #expect(LinkedFolderGitStatusStore.folderRelativePaths(["q/a.sql", "other/b.sql"], prefix: "q/") == ["a.sql"]) + } + + @Test("Statuses are keyed by the path inside the linked folder, and files outside it are dropped") + func folderRelativeStatuses() { + let records = [ + GitStatusRecord(path: "sql queries/a.sql", originalPath: nil, status: .untracked), + GitStatusRecord(path: "sql queries/nested/b.sql", originalPath: nil, status: GitFileStatus(staged: .unmodified, unstaged: .modified)), + GitStatusRecord(path: "other/c.sql", originalPath: nil, status: .untracked), + ] + + let statuses = LinkedFolderGitStatusStore.folderRelativeStatuses(records, prefix: "sql queries/") + + #expect(Set(statuses.keys) == ["a.sql", "nested/b.sql"]) + #expect(LinkedFolderGitStatusStore.folderRelativeStatuses(records, prefix: "").count == 3) + } +} + +@Suite("GitFileStatus") +struct GitFileStatusTests { + @Test("Badge letters follow the change, with conflict and untracked first") + func badges() { + #expect(GitFileStatus(staged: .unmodified, unstaged: .modified).badge == .modified) + #expect(GitFileStatus(staged: .modified, unstaged: .unmodified).badge == .modified) + #expect(GitFileStatus(staged: .added, unstaged: .modified).badge == .added) + #expect(GitFileStatus(staged: .renamed, unstaged: .unmodified).badge == .renamed) + #expect(GitFileStatus(staged: .unmerged, unstaged: .unmerged).badge == .conflicted) + #expect(GitFileStatus.untracked.badge == .untracked) + #expect(GitFileStatus.untracked.badge.letter == "U") + } + + @Test("Discard is offered only when the working copy differs from the index") + func discardGating() { + #expect(GitFileStatus(staged: .unmodified, unstaged: .modified).canDiscardChanges) + #expect(GitFileStatus(staged: .modified, unstaged: .modified).canDiscardChanges) + #expect(GitFileStatus(staged: .added, unstaged: .modified).canDiscardChanges) + #expect(!GitFileStatus(staged: .modified, unstaged: .unmodified).canDiscardChanges) + #expect(!GitFileStatus(staged: .added, unstaged: .unmodified).canDiscardChanges) + #expect(!GitFileStatus.untracked.canDiscardChanges) + #expect(!GitFileStatus(staged: .unmerged, unstaged: .unmerged).canDiscardChanges) + } + + @Test("History is offered only for a file that exists in a commit under this name") + func committedHistory() { + #expect(GitFileStatus(staged: .unmodified, unstaged: .modified).hasCommittedHistory) + #expect(GitFileStatus(staged: .modified, unstaged: .unmodified).hasCommittedHistory) + #expect(!GitFileStatus(staged: .added, unstaged: .unmodified).hasCommittedHistory) + #expect(!GitFileStatus(staged: .renamed, unstaged: .unmodified).hasCommittedHistory) + #expect(!GitFileStatus.untracked.hasCommittedHistory) + #expect(LinkedFileGitState.clean.hasCommittedHistory) + #expect(!LinkedFileGitState.clean.canDiscardChanges) + } + + @Test("The spoken status says whether the change is staged") + func accessibilityDescription() { + #expect(GitFileStatus(staged: .modified, unstaged: .unmodified).accessibilityDescription + == String(format: String(localized: "%@, staged"), GitFileStatus.Badge.modified.label)) + #expect(GitFileStatus(staged: .modified, unstaged: .modified).accessibilityDescription + == String(format: String(localized: "%@, partly staged"), GitFileStatus.Badge.modified.label)) + #expect(GitFileStatus(staged: .unmodified, unstaged: .modified).accessibilityDescription + == GitFileStatus.Badge.modified.label) + } +} + +@Suite("Git executable and command hardening") +struct GitCommandHardeningTests { + @Test("The locator never offers the installer shim, and reads the developer directory without running anything") + func locatorSkipsShim() { + let locator = GitExecutableLocator( + isExecutable: { _ in true }, + developerDirectory: { "/Applications/Xcode.app/Contents/Developer" } + ) + let candidates = locator.candidates() + #expect(!candidates.contains("/usr/bin/git")) + #expect(candidates.contains("/Applications/Xcode.app/Contents/Developer/usr/bin/git")) + #expect(locator.locate()?.path == "/opt/homebrew/bin/git") + } + + @Test("A developer directory at the volume root cannot smuggle the shim back in") + func locatorRejectsShimViaDeveloperDirectory() { + let locator = GitExecutableLocator(isExecutable: { _ in true }, developerDirectory: { "/" }) + #expect(!locator.candidates().contains("/usr/bin/git")) + } + + @Test("No git installed means no git, not the shim") + func locatorWithoutGit() { + let locator = GitExecutableLocator(isExecutable: { $0 == "/usr/bin/git" }, developerDirectory: { nil }) + #expect(locator.locate() == nil) + } + + @Test("Every command carries the flags that stop a repository from running its own programs or taking locks") + func everyCommandIsHardened() { + let file = URL(fileURLWithPath: "/tmp/repo/a.sql") + let directory = file.deletingLastPathComponent() + let commands: [GitCommand] = [ + .repositoryInfo(in: directory), + .verifyHead(in: directory), + .status(in: directory), + .fileHistory(of: file, limit: 10), + .blob(revision: "HEAD", path: "a.sql", in: directory), + .trackedFiles(in: directory), + ] + for command in commands { + let arguments = command.processArguments + #expect(arguments.starts(with: GitCommand.hardeningArguments)) + #expect(arguments.contains("core.fsmonitor=false")) + #expect(arguments.contains("core.hooksPath=/dev/null")) + #expect(arguments.contains("--no-optional-locks")) + #expect(arguments.contains("--literal-pathspecs")) + #expect(!arguments.contains("--filters")) + #expect(!arguments.contains("checkout-index")) + } + #expect(GitCommand.blob(revision: "HEAD", path: "a.sql", in: directory).arguments.contains("--end-of-options")) + } + + @Test("A file history never asks git to verify signatures or colour its output") + func historyArguments() { + let command = GitCommand.fileHistory(of: URL(fileURLWithPath: "/tmp/repo/sub dir/a b.sql"), limit: 201) + #expect(command.arguments.contains("--follow")) + #expect(command.arguments.contains("--no-show-signature")) + #expect(command.arguments.contains("--no-color")) + #expect(command.arguments.contains("--max-count=201")) + #expect(command.arguments.last == "a b.sql") + #expect(command.workingDirectory.path == "/tmp/repo/sub dir") + } + + @Test("Inherited repository variables are dropped and the lock and prompt overrides win") + func environment() { + let environment = GitCommand.environment(base: [ + "GIT_DIR": "/elsewhere/.git", + "GIT_WORK_TREE": "/elsewhere", + "GIT_OPTIONAL_LOCKS": "1", + "PATH": "/usr/bin", + ]) + #expect(environment["GIT_DIR"] == nil) + #expect(environment["GIT_WORK_TREE"] == nil) + #expect(environment["GIT_OPTIONAL_LOCKS"] == "0") + #expect(environment["GIT_TERMINAL_PROMPT"] == "0") + #expect(environment["PATH"] == "/usr/bin") + } + + @Test("Folder watcher events inside .git do not count as SQL file changes") + func repositoryMetadataEvents() { + #expect(SQLFolderWatcher.touchesOnlyRepositoryMetadata(["/repo/.git/index", "/repo/.git/refs/heads/main"])) + #expect(!SQLFolderWatcher.touchesOnlyRepositoryMetadata(["/repo/.git/index", "/repo/queries/a.sql"])) + #expect(!SQLFolderWatcher.touchesOnlyRepositoryMetadata(["/repo/.gitignore"])) + #expect(!SQLFolderWatcher.touchesOnlyRepositoryMetadata([])) + } +} + +@Suite("FileTextLoader.decode") +struct FileTextLoaderDecodeTests { + @Test("Byte order marks pick the encoding, UTF-32 before the UTF-16 prefix it shares") + func byteOrderMarks() throws { + let text = "SELECT 'é';" + let utf32 = try #require(text.data(using: .utf32LittleEndian)) + let utf16 = try #require(text.data(using: .utf16LittleEndian)) + #expect(FileTextLoader.decode(Data([0xFF, 0xFE, 0x00, 0x00]) + utf32) == text) + #expect(FileTextLoader.decode(Data([0xFF, 0xFE]) + utf16) == text) + #expect(FileTextLoader.decode(Data([0xEF, 0xBB, 0xBF]) + Data(text.utf8)) == text) + #expect(FileTextLoader.decode(Data(text.utf8)) == text) + #expect(FileTextLoader.decode(Data([0x53, 0xE9])) == "Sé") + } +} diff --git a/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift b/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift index 288fd5303d..11e2caa11d 100644 --- a/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift +++ b/TableProTests/Core/Scripting/ScriptingDictionaryTests.swift @@ -328,7 +328,7 @@ struct ScriptingDictionaryTests { } for kind in [ TabType.query, .table, .createTable, .erDiagram, - .serverDashboard, .usersRoles, .insights, .objectSource + .serverDashboard, .usersRoles, .insights, .objectSource, .versionHistory ] { #expect(declared.contains(ScriptEnumerations.code(for: kind)), "no enumerator for \(kind)") } diff --git a/TableProTests/Core/Services/PersistedTabRoundTripTests.swift b/TableProTests/Core/Services/PersistedTabRoundTripTests.swift index 40049b9539..ebed65762d 100644 --- a/TableProTests/Core/Services/PersistedTabRoundTripTests.swift +++ b/TableProTests/Core/Services/PersistedTabRoundTripTests.swift @@ -537,4 +537,34 @@ struct PersistedTabRoundTripTests { #expect(!json.contains("CREATE")) #expect(json.contains("orders")) } + + @Test("A history tab round-trips the saved query or file it shows") + func versionHistoryTabRoundTripsItsSubject() throws { + let favoriteId = UUID() + let subjects: [VersionHistorySubject] = [ + .savedQuery(id: favoriteId), + .linkedFile(url: URL(fileURLWithPath: "/Users/me/queries/sub dir/orders.sql")), + ] + for subject in subjects { + let tab = PersistedTab( + id: UUID(), + title: "History: orders.sql", + query: "", + tabType: .versionHistory, + tableName: nil, + versionHistorySubject: subject + ) + let decoded = try JSONDecoder().decode(PersistedTab.self, from: try JSONEncoder().encode(tab)) + #expect(decoded.tabType == .versionHistory) + #expect(decoded.versionHistorySubject == subject) + #expect(decoded.title == "History: orders.sql") + } + } + + @Test("A tab written before history tabs existed decodes with no subject") + func olderTabHasNoHistorySubject() throws { + let json = #"{"id":"\#(UUID().uuidString)","title":"Query 1","query":"SELECT 1","tabType":{"query":{}}}"# + let decoded = try JSONDecoder().decode(PersistedTab.self, from: Data(json.utf8)) + #expect(decoded.versionHistorySubject == nil) + } } diff --git a/TableProTests/Core/Storage/SQLFavoriteVersionTests.swift b/TableProTests/Core/Storage/SQLFavoriteVersionTests.swift new file mode 100644 index 0000000000..da4b87c6fb --- /dev/null +++ b/TableProTests/Core/Storage/SQLFavoriteVersionTests.swift @@ -0,0 +1,226 @@ +// +// SQLFavoriteVersionTests.swift +// TableProTests +// + +import Foundation +import SQLite3 +import TableProSyncTransport +import Testing + +@testable import TablePro + +@Suite("SQLFavorite versions") +struct SQLFavoriteVersionTests { + private let storage: SQLFavoriteStorage + private let defaults: UserDefaults + private let tracker: SyncChangeTracker + + init() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("sql_favorites_versions_\(UUID().uuidString).db") + try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + storage = SQLFavoriteStorage(databaseURL: url, removeDatabaseOnDeinit: true) + defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.SQLFavoriteVersions.\(UUID().uuidString)")) + tracker = SyncChangeTracker(metadataStorage: SyncMetadataStorage(userDefaults: defaults)) + } + + private func makeFavorite(name: String = "Revenue", query: String = "SELECT 1", updatedAt: Date = Date(timeIntervalSince1970: 100)) -> SQLFavorite { + SQLFavorite(name: name, query: query, createdAt: updatedAt, updatedAt: updatedAt) + } + + @Test("Changing the query records the text it replaced, with the time that text was saved") + func changingQueryRecordsPreviousText() async throws { + var favorite = makeFavorite(query: "SELECT 1") + #expect(await storage.addFavorite(favorite)) + + favorite.query = "SELECT 2" + favorite.updatedAt = Date(timeIntervalSince1970: 200) + #expect(await storage.updateFavorite(favorite).succeeded) + + let versions = await storage.fetchVersions(favoriteId: favorite.id) + let version = try #require(versions.first) + #expect(versions.count == 1) + #expect(version.query == "SELECT 1") + #expect(version.name == "Revenue") + #expect(version.savedAt == Date(timeIntervalSince1970: 100)) + } + + @Test("A write that leaves the query text alone records nothing") + func unchangedQueryRecordsNothing() async { + var favorite = makeFavorite() + #expect(await storage.addFavorite(favorite)) + + favorite.name = "Renamed" + favorite.keyword = "rev" + favorite.updatedAt = Date(timeIntervalSince1970: 300) + #expect(await storage.updateFavorite(favorite).succeeded) + + #expect(await storage.fetchVersions(favoriteId: favorite.id).isEmpty) + } + + @Test("A sync upsert that changes the text records the local text it replaced") + func syncUpsertRecordsPreviousText() async { + var favorite = makeFavorite(query: "SELECT local") + #expect(await storage.addFavorite(favorite)) + + favorite.query = "SELECT remote" + #expect(await storage.upsertFavorite(favorite).succeeded) + #expect(await storage.upsertFavorite(favorite).succeeded) + + let versions = await storage.fetchVersions(favoriteId: favorite.id) + #expect(versions.map(\.query) == ["SELECT local"]) + } + + @Test("Each query keeps only its most recent versions, newest first, without touching other queries") + func pruningIsPerFavorite() async { + var favorite = makeFavorite(query: "SELECT 0") + let other = makeFavorite(name: "Other", query: "SELECT other") + #expect(await storage.addFavorite(favorite)) + #expect(await storage.addFavorite(other)) + + var otherEdit = other + otherEdit.query = "SELECT other 2" + #expect(await storage.updateFavorite(otherEdit).succeeded) + + let limit = SQLFavoriteStorage.retainedVersionCount + for index in 1...(limit + 5) { + favorite.query = "SELECT \(index)" + #expect(await storage.updateFavorite(favorite).succeeded) + } + + let versions = await storage.fetchVersions(favoriteId: favorite.id) + #expect(versions.count == limit) + #expect(versions.first?.query == "SELECT \(limit + 4)") + #expect(versions.last?.query == "SELECT 5") + #expect(await storage.fetchVersions(favoriteId: other.id).map(\.query) == ["SELECT other"]) + } + + @Test("Deleting a query deletes its versions") + func deletingFavoriteDeletesVersions() async { + var favorite = makeFavorite() + #expect(await storage.addFavorite(favorite)) + favorite.query = "SELECT 2" + #expect(await storage.updateFavorite(favorite).succeeded) + #expect(await storage.fetchVersions(favoriteId: favorite.id).count == 1) + + #expect(await storage.deleteFavorite(id: favorite.id)) + + #expect(await storage.fetchVersions(favoriteId: favorite.id).isEmpty) + } + + @Test("Restoring writes the old text, records the text it replaced, and marks the query for sync") + func restoreRecordsReplacedTextAndMarksDirty() async throws { + let manager = SQLFavoriteManager(storage: storage, syncTracker: tracker) + var favorite = makeFavorite(query: "SELECT old") + #expect(await storage.addFavorite(favorite)) + favorite.query = "SELECT new" + #expect(await storage.updateFavorite(favorite).succeeded) + let old = try #require(await manager.fetchVersions(favoriteId: favorite.id).first) + + #expect(await manager.restore(old)) + + let restored = try #require(await manager.fetchFavorite(id: favorite.id)) + #expect(restored.query == "SELECT old") + #expect(restored.name == "Revenue") + #expect(await manager.fetchVersions(favoriteId: favorite.id).map(\.query) == ["SELECT new", "SELECT old"]) + #expect(tracker.dirtyRecords(for: .favorite).contains(favorite.id.uuidString)) + } + + @Test("Restoring a version of a deleted query fails") + func restoreOfDeletedFavoriteFails() async throws { + let manager = SQLFavoriteManager(storage: storage, syncTracker: tracker) + var favorite = makeFavorite(query: "SELECT old") + #expect(await storage.addFavorite(favorite)) + favorite.query = "SELECT new" + #expect(await storage.updateFavorite(favorite).succeeded) + let old = try #require(await manager.fetchVersions(favoriteId: favorite.id).first) + #expect(await storage.deleteFavorite(id: favorite.id)) + + #expect(await manager.restore(old) == false) + } + + @Test("Reopening the database keeps the versions and does not duplicate the triggers") + func reopeningKeepsVersions() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("sql_favorites_reopen_\(UUID().uuidString).db") + defer { + for suffix in ["", "-wal", "-shm"] { + try? FileManager.default.removeItem(atPath: url.path + suffix) + } + } + var favorite = makeFavorite(query: "SELECT 1") + do { + let first = SQLFavoriteStorage(databaseURL: url) + #expect(await first.addFavorite(favorite)) + favorite.query = "SELECT 2" + #expect(await first.updateFavorite(favorite).succeeded) + } + + let reopened = SQLFavoriteStorage(databaseURL: url) + favorite.query = "SELECT 3" + #expect(await reopened.updateFavorite(favorite).succeeded) + + #expect(await reopened.fetchVersions(favoriteId: favorite.id).map(\.query) == ["SELECT 2", "SELECT 1"]) + } + + @Test("A database upgraded from the first schema records versions on its first launch") + func upgradeFromFirstSchemaKeepsVersionTriggers() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("sql_favorites_v1_\(UUID().uuidString).db") + defer { + for suffix in ["", "-wal", "-shm"] { + try? FileManager.default.removeItem(atPath: url.path + suffix) + } + } + let id = UUID() + var handle: OpaquePointer? + #expect(sqlite3_open(url.path, &handle) == SQLITE_OK) + let seed = """ + CREATE TABLE favorites (id TEXT PRIMARY KEY, name TEXT NOT NULL, query TEXT NOT NULL, keyword TEXT, + folder_id TEXT, connection_id TEXT, sort_order INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, updated_at REAL NOT NULL); + CREATE TABLE folders (id TEXT PRIMARY KEY, name TEXT NOT NULL, parent_id TEXT, connection_id TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL, updated_at REAL NOT NULL); + INSERT INTO favorites VALUES ('\(id.uuidString)', 'Revenue', 'SELECT 1', NULL, NULL, NULL, 0, 1, 1); + PRAGMA user_version = 1; + """ + #expect(sqlite3_exec(handle, seed, nil, nil, nil) == SQLITE_OK) + sqlite3_close(handle) + + let upgraded = SQLFavoriteStorage(databaseURL: url) + var favorite = try #require(await upgraded.fetchFavorite(id: id)) + favorite.name = "Renamed" + favorite.updatedAt = Date(timeIntervalSince1970: 5) + #expect(await upgraded.updateFavorite(favorite).succeeded) + favorite.query = "SELECT 2" + favorite.updatedAt = Date(timeIntervalSince1970: 9) + #expect(await upgraded.updateFavorite(favorite).succeeded) + + let version = try #require(await upgraded.fetchVersions(favoriteId: id).first) + #expect(version.query == "SELECT 1") + #expect(version.savedAt == Date(timeIntervalSince1970: 1)) + } + + @Test("A rename or keyword change does not move the saved time of the SQL it did not touch") + func metadataEditsKeepTheQuerySaveTime() async throws { + var favorite = makeFavorite(query: "SELECT 1", updatedAt: Date(timeIntervalSince1970: 100)) + #expect(await storage.addFavorite(favorite)) + + favorite.name = "Renamed" + favorite.updatedAt = Date(timeIntervalSince1970: 200) + #expect(await storage.updateFavorite(favorite).succeeded) + #expect(await storage.querySavedAt(favoriteId: favorite.id) == Date(timeIntervalSince1970: 100)) + + favorite.query = "SELECT 2" + favorite.updatedAt = Date(timeIntervalSince1970: 300) + #expect(await storage.upsertFavorite(favorite).succeeded) + + let version = try #require(await storage.fetchVersions(favoriteId: favorite.id).first) + #expect(version.savedAt == Date(timeIntervalSince1970: 100)) + #expect(await storage.querySavedAt(favoriteId: favorite.id) == Date(timeIntervalSince1970: 300)) + } +} diff --git a/TableProTests/Services/WindowTitleResolverTests.swift b/TableProTests/Services/WindowTitleResolverTests.swift index 881e0b9cdf..4291d1d39c 100644 --- a/TableProTests/Services/WindowTitleResolverTests.swift +++ b/TableProTests/Services/WindowTitleResolverTests.swift @@ -38,6 +38,28 @@ struct WindowTitleResolverPayloadTitleTests { #expect(title == String(localized: "Create Table")) } + @Test("A history payload takes its tab title, and a blank one falls back to History") + func versionHistoryLabel() { + let named = EditorTabPayload( + connectionId: UUID(), + tabType: .versionHistory, + versionHistorySubject: .savedQuery(id: UUID()), + tabTitle: "History: Revenue" + ) + let blank = EditorTabPayload( + connectionId: UUID(), + tabType: .versionHistory, + versionHistorySubject: .savedQuery(id: UUID()), + tabTitle: " " + ) + #expect(WindowTitleResolver.resolveTitle( + payload: named, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) == "History: Revenue") + #expect(WindowTitleResolver.resolveTitle( + payload: blank, databaseType: .postgresql, queryLanguageName: "PostgreSQL" + ) == String(localized: "History")) + } + @Test("Explicit tabTitle wins for query payloads") func explicitTabTitleWins() { let payload = EditorTabPayload( diff --git a/TableProTests/ViewModels/VersionHistoryViewModelTests.swift b/TableProTests/ViewModels/VersionHistoryViewModelTests.swift new file mode 100644 index 0000000000..186774bb11 --- /dev/null +++ b/TableProTests/ViewModels/VersionHistoryViewModelTests.swift @@ -0,0 +1,280 @@ +// +// VersionHistoryViewModelTests.swift +// TableProTests +// + +import Combine +import Foundation +import Testing + +@testable import TablePro + +private actor FakeVersionHistoryProvider: VersionHistoryProvider { + var page: VersionHistoryPage + var contents: [VersionHistoryReference: String] + var loadError: VersionHistoryError? + var isDirtyOnDisk: Bool + private(set) var contentRequests: [VersionHistoryReference] = [] + private(set) var restored: [VersionHistoryReference] = [] + + init(page: VersionHistoryPage, contents: [VersionHistoryReference: String]) { + self.page = page + self.contents = contents + isDirtyOnDisk = page.current?.hasUncommittedChanges ?? false + } + + func setDirtyOnDisk(_ isDirty: Bool) { + isDirtyOnDisk = isDirty + } + + func prepareRestore(_ reference: VersionHistoryReference) async throws -> VersionRestorePlan { + let isDirty = isDirtyOnDisk + return VersionRestorePlan(replacesUncommittedChanges: isDirty) { [self] in + await self.recordRestore(reference) + } + } + + private func recordRestore(_ reference: VersionHistoryReference) { + restored.append(reference) + if let text = contents[reference] { + contents[.current] = text + } + } + + func setLoadError(_ error: VersionHistoryError?) { + loadError = error + } + + func setCurrent(_ text: String) { + contents[.current] = text + } + + func loadHistory() async throws -> VersionHistoryPage { + if let loadError { throw loadError } + return page + } + + func content(of reference: VersionHistoryReference) async throws -> String { + contentRequests.append(reference) + guard let content = contents[reference] else { throw VersionHistoryError.versionNotFound } + return content + } +} + +@MainActor +@Suite("VersionHistoryViewModel") +struct VersionHistoryViewModelTests { + private static let past = VersionHistoryReference.savedQueryVersion(id: 7) + private static let older = VersionHistoryReference.savedQueryVersion(id: 3) + + private func makeProvider(uncommitted: Bool = false) -> FakeVersionHistoryProvider { + FakeVersionHistoryProvider( + page: VersionHistoryPage(entries: [ + VersionHistoryEntry(reference: .current, date: Date(timeIntervalSince1970: 300), hasUncommittedChanges: uncommitted), + VersionHistoryEntry(reference: Self.past, date: Date(timeIntervalSince1970: 200)), + VersionHistoryEntry(reference: Self.older, date: Date(timeIntervalSince1970: 100)), + ]), + contents: [ + .current: "SELECT 3\n", + Self.past: "SELECT 2\n", + Self.older: "SELECT 3\n", + ] + ) + } + + private func makeViewModel(_ provider: FakeVersionHistoryProvider) -> VersionHistoryViewModel { + VersionHistoryViewModel( + subject: .savedQuery(id: UUID()), + provider: provider, + refreshSignal: Empty().eraseToAnyPublisher() + ) + } + + private func loadedDetail(_ viewModel: VersionHistoryViewModel) async throws -> VersionHistoryDetail { + await viewModel.detailTask?.value + guard case .loaded(let detail) = viewModel.detailState else { + Issue.record("Detail did not load: \(viewModel.detailState)") + throw VersionHistoryError.versionNotFound + } + return detail + } + + @Test("Loading selects the current version and compares it with the version before it") + func currentComparesWithPrevious() async throws { + let viewModel = makeViewModel(makeProvider()) + + await viewModel.loadList() + let detail = try await loadedDetail(viewModel) + + #expect(viewModel.listState == .loaded) + #expect(viewModel.selection == .current) + #expect(detail.content == "SELECT 3\n") + let comparison = try #require(detail.comparison) + #expect(comparison.older.reference == Self.past) + #expect(comparison.newer.reference == .current) + guard case .differs(let pairs) = comparison.outcome else { + Issue.record("Expected a difference") + return + } + #expect(pairs.contains { $0.before == "SELECT 2" && $0.after == "SELECT 3" }) + } + + @Test("A past version is compared with the current one, older side first") + func pastComparesWithCurrent() async throws { + let viewModel = makeViewModel(makeProvider()) + await viewModel.loadList() + await viewModel.detailTask?.value + + viewModel.selection = Self.older + let detail = try await loadedDetail(viewModel) + + #expect(detail.content == "SELECT 3\n") + let comparison = try #require(detail.comparison) + #expect(comparison.older.reference == Self.older) + #expect(comparison.newer.reference == .current) + #expect(comparison.outcome == .identical) + } + + @Test("A past version is read once, the current text every time") + func onlyPastContentIsCached() async throws { + let provider = makeProvider() + let viewModel = makeViewModel(provider) + await viewModel.loadList() + await viewModel.detailTask?.value + await provider.setCurrent("SELECT 4\n") + + await viewModel.loadList() + let detail = try await loadedDetail(viewModel) + + #expect(detail.content == "SELECT 4\n") + let requests = await provider.contentRequests + #expect(requests.filter { $0 == .current }.count == 2) + #expect(requests.filter { $0 == Self.past }.count == 1) + } + + @Test("Restoring with nothing unsaved needs no confirmation and selects the current version") + func restoreWithoutUncommittedChanges() async throws { + let provider = makeProvider() + let viewModel = makeViewModel(provider) + var asked = false + var restoredCalls = 0 + viewModel.confirmReplacingUncommittedChanges = { + asked = true + return true + } + viewModel.onRestored = { restoredCalls += 1 } + await viewModel.loadList() + viewModel.selection = Self.past + + await viewModel.restore(try #require(viewModel.selectedEntry)) + + #expect(!asked) + #expect(restoredCalls == 1) + #expect(await provider.restored == [Self.past]) + #expect(viewModel.selection == .current) + #expect(!viewModel.isRestoring) + } + + @Test("Restoring over uncommitted changes asks first, and a refusal changes nothing") + func restoreOverUncommittedChangesAsks() async throws { + let provider = makeProvider(uncommitted: true) + let viewModel = makeViewModel(provider) + viewModel.confirmReplacingUncommittedChanges = { false } + await viewModel.loadList() + let entry = try #require(viewModel.page.entries.first { $0.reference == Self.past }) + + await viewModel.restore(entry) + #expect(await provider.restored.isEmpty) + + viewModel.confirmReplacingUncommittedChanges = { true } + await viewModel.restore(entry) + #expect(await provider.restored == [Self.past]) + } + + @Test("A change made after the list loaded still asks before it is replaced") + func restoreRechecksAtTheMomentOfWriting() async throws { + let provider = makeProvider(uncommitted: false) + let viewModel = makeViewModel(provider) + var asked = false + viewModel.confirmReplacingUncommittedChanges = { + asked = true + return false + } + await viewModel.loadList() + await provider.setDirtyOnDisk(true) + + await viewModel.restore(try #require(viewModel.page.entries.first { $0.reference == Self.past })) + + #expect(asked) + #expect(await provider.restored.isEmpty) + } + + @Test("The current version cannot be restored") + func currentIsNotRestorable() async throws { + let provider = makeProvider() + let viewModel = makeViewModel(provider) + await viewModel.loadList() + + #expect(!viewModel.canRestoreSelection) + await viewModel.restore(try #require(viewModel.page.current)) + #expect(await provider.restored.isEmpty) + } + + @Test("A failed refresh keeps what is shown, but a subject that is gone clears it") + func refreshFailures() async throws { + let provider = makeProvider() + let viewModel = makeViewModel(provider) + await viewModel.loadList() + await viewModel.detailTask?.value + + await provider.setLoadError(.commandFailed("git timed out")) + await viewModel.loadList() + #expect(viewModel.listState == .loaded) + #expect(viewModel.page.entries.count == 3) + + await provider.setLoadError(.subjectNotFound) + await viewModel.loadList() + #expect(viewModel.page.entries.isEmpty) + #expect(viewModel.listState == .failed(VersionHistoryError.subjectNotFound.localizedDescription)) + } +} + +@Suite("VersionComparison") +struct VersionComparisonTests { + @Test("Equal text is identical, different text is a line diff that keeps blank lines") + func outcomes() { + #expect(VersionComparison.compare(baseline: "a\n", current: "a\n") == .identical) + guard case .differs(let pairs) = VersionComparison.compare(baseline: "a\n\nb", current: "a\nb") else { + Issue.record("Expected a difference") + return + } + #expect(pairs.contains { $0.kind == .removed && $0.before == "" }) + } + + @Test("Text that differs only in line endings is identical") + func lineEndingsOnly() { + #expect(VersionComparison.compare(baseline: "a\nb\n", current: "a\r\nb\r\n") == .identical) + } + + @Test("Text past the line limit is not diffed") + func tooLarge() { + let big = Array(repeating: "x", count: VersionComparison.maximumLineCount + 1).joined(separator: "\n") + #expect(VersionComparison.compare(baseline: big, current: big + "\ny") == .tooLarge) + } +} + +@Suite("VersionHistoryPage") +struct VersionHistoryPageTests { + @Test("The current version's baseline is the newest past version, a past version's is the current one") + func baselines() { + let page = VersionHistoryPage(entries: [ + VersionHistoryEntry(reference: .current), + VersionHistoryEntry(reference: .gitRevision(commit: "b", path: "q.sql")), + VersionHistoryEntry(reference: .gitRevision(commit: "a", path: "q.sql")), + ]) + #expect(page.baseline(for: .current)?.reference == .gitRevision(commit: "b", path: "q.sql")) + #expect(page.baseline(for: .gitRevision(commit: "a", path: "q.sql"))?.reference == .current) + #expect(VersionHistoryPage(entries: [VersionHistoryEntry(reference: .current)]).baseline(for: .current) == nil) + #expect(VersionHistoryReference.gitRevision(commit: "0123456789", path: "q.sql").shortRevision == "0123456") + } +} diff --git a/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift b/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift index 6ebe8c8dd4..164cdb2932 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift @@ -8,6 +8,7 @@ // cancellation cleared isExecuting asynchronously. // +import Combine import Foundation import Testing @@ -375,4 +376,18 @@ struct MainContentCoordinatorRefreshTests { #expect(coordinator.refreshPendingTrailing == false) #expect(coordinator.refreshCoalesceTask == nil) } + + @Test("Refresh on a history tab asks that tab to reload its versions") + func refreshOnHistoryTabReloadsHistory() { + let (coordinator, tabManager) = makeCoordinator() + tabManager.addVersionHistoryTab(subject: .savedQuery(id: UUID()), title: "History: Revenue") + let tabId = tabManager.selectedTabId + var requested: [UUID] = [] + let subscription = AppEvents.shared.versionHistoryRefreshRequested.sink { requested.append($0) } + defer { subscription.cancel() } + + coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {}) + + #expect(requested == [tabId].compactMap { $0 }) + } } diff --git a/TableProTests/Views/Sidebar/FavoritesMenuSpecTests.swift b/TableProTests/Views/Sidebar/FavoritesMenuSpecTests.swift index 3ddc4d9875..266876cb4d 100644 --- a/TableProTests/Views/Sidebar/FavoritesMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/FavoritesMenuSpecTests.swift @@ -279,4 +279,64 @@ struct FavoritesMenuSpecTests { #expect(enabled.contains(.setLinkedFolderEnabled(folder, false)) == false) #expect(disabled.contains(.setLinkedFolderEnabled(folder, true))) } + + @Test("A saved query offers its history") + func savedQueryOffersHistory() { + let query = favorite() + let issued = commands(FavoritesMenuSpec.sections(for: context(clicked: .query(.favorite(query))))) + #expect(issued.contains(.showFavoriteHistory(query))) + } + + private func linkedFile() -> LinkedSQLFavorite { + LinkedSQLFavorite( + folderId: UUID(), + fileURL: URL(fileURLWithPath: "/tmp/queries/orders.sql"), + relativePath: "orders.sql", + name: "orders", + mtime: Date(), + fileSize: 10 + ) + } + + private func linkedCommands(_ file: LinkedSQLFavorite, gitState: LinkedFileGitState?) -> [FavoritesMenuCommand] { + let states = gitState.map { [file.id: $0] } ?? [:] + return commands(FavoritesMenuSpec.sections(for: FavoritesMenuContext( + clicked: .query(.linkedFavorite(file)), + linkedFileGitStates: states + ))) + } + + @Test("A linked file outside a repository offers no Git commands") + func linkedFileOutsideRepository() { + let file = linkedFile() + let issued = linkedCommands(file, gitState: nil) + #expect(!issued.contains(.showLinkedFileHistory(file))) + #expect(!issued.contains(.discardLinkedFileChanges(file))) + #expect(issued.contains(.openLinkedFavorite(file))) + } + + @Test("A clean tracked file offers history but nothing to discard") + func cleanTrackedFile() { + let file = linkedFile() + let issued = linkedCommands(file, gitState: .clean) + #expect(issued.contains(.showLinkedFileHistory(file))) + #expect(!issued.contains(.discardLinkedFileChanges(file))) + } + + @Test("A modified file offers history and Discard Changes") + func modifiedFile() { + let file = linkedFile() + let issued = linkedCommands(file, gitState: .changed(GitFileStatus(staged: .unmodified, unstaged: .modified))) + #expect(issued.contains(.showLinkedFileHistory(file))) + #expect(issued.contains(.discardLinkedFileChanges(file))) + } + + @Test("An untracked file offers neither history nor Discard Changes") + func untrackedFile() { + let file = linkedFile() + let issued = linkedCommands(file, gitState: .changed(.untracked)) + #expect(!issued.contains(.showLinkedFileHistory(file))) + #expect(!issued.contains(.discardLinkedFileChanges(file))) + #expect(issued.contains(.trashLinkedFavorite(file))) + } } diff --git a/TableProUITests/SavedQueryHistoryUITests.swift b/TableProUITests/SavedQueryHistoryUITests.swift new file mode 100644 index 0000000000..d8e4699a7b --- /dev/null +++ b/TableProUITests/SavedQueryHistoryUITests.swift @@ -0,0 +1,78 @@ +import AppKit +import XCTest + +/// Issue #2505. Editing a saved query keeps the SQL it replaced, Show History lists it, and +/// Restore This Version puts it back while keeping the text it replaced as another version. +final class SavedQueryHistoryUITests: UITestCase { + func testAnEditedSavedQueryCanBeRestoredFromItsHistory() throws { + let app = try launchWithSampleDatabase() + let window = app.windows.firstMatch + XCTAssertTrue(window.waitToExist(timeout: 30)) + showFavorites(in: app) + + let newFavorite = window.buttons["New Favorite…"] + XCTAssertTrue(newFavorite.waitToExist(timeout: 15), "The empty Favorites tab offers New Favorite…") + newFavorite.click() + fillFavoriteSheet(in: app, name: "Revenue", query: "SELECT 1", confirm: "Add") + + let row = window.outlines.staticTexts["Revenue"] + XCTAssertTrue(row.waitToExist(timeout: 15), "The new saved query appears in the sidebar") + row.rightClick() + let edit = contextMenuItem("Edit…", in: app) + XCTAssertTrue(edit.waitToExist(timeout: 5)) + edit.click() + fillFavoriteSheet(in: app, name: nil, query: "SELECT 2", confirm: "Save") + + XCTAssertTrue(row.waitToExist(timeout: 10)) + row.rightClick() + let showHistory = contextMenuItem("Show History", in: app) + XCTAssertTrue(showHistory.waitToExist(timeout: 5), "A saved query's menu offers Show History") + showHistory.click() + + let versions = window.tables["version-history-list"] + XCTAssertTrue(versions.waitToExist(timeout: 15), "Show History opens the history tab") + XCTAssertTrue( + waitForPredicate(timeout: 10) { versions.tableRows.count == 2 }, + "The current version and the SQL the edit replaced" + ) + + versions.tableRows.element(boundBy: 1).click() + let restore = window.buttons["version-history-restore"] + XCTAssertTrue(waitForPredicate(timeout: 10) { restore.isEnabled }, "An earlier version can be restored") + restore.click() + + XCTAssertTrue( + waitForPredicate(timeout: 10) { versions.tableRows.count == 3 }, + "Restoring keeps the SQL it replaced as another version" + ) + } + + private func fillFavoriteSheet(in app: XCUIApplication, name: String?, query: String, confirm: String) { + let sheet = app.sheets.firstMatch + XCTAssertTrue(sheet.waitToExist(timeout: 10), "The saved query sheet opens") + if let name { + let nameField = sheet.textFields.firstMatch + XCTAssertTrue(nameField.waitToExist(timeout: 5)) + nameField.click() + nameField.typeText(name) + } + let queryEditor = sheet.textViews.firstMatch + XCTAssertTrue(queryEditor.waitToExist(timeout: 5)) + queryEditor.click() + queryEditor.typeKey("a", modifierFlags: .command) + queryEditor.typeText(query) + let confirmButton = sheet.buttons[confirm] + XCTAssertTrue(waitForPredicate(timeout: 5) { confirmButton.isEnabled }) + confirmButton.click() + XCTAssertTrue(waitForPredicate(timeout: 10) { !sheet.exists }, "The sheet closes after \(confirm)") + } + + private func showFavorites(in app: XCUIApplication) { + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitToExist(timeout: 10)) + menuBar.menuBarItems["View"].click() + let showFavorites = menuBar.menuItems["Show Favorites"] + XCTAssertTrue(showFavorites.waitToExist(timeout: 5), "View > Show Favorites must be reachable") + showFavorites.click() + } +} diff --git a/docs/external-api/applescript.mdx b/docs/external-api/applescript.mdx index b703e467a2..bb44002867 100644 --- a/docs/external-api/applescript.mdx +++ b/docs/external-api/applescript.mdx @@ -81,7 +81,7 @@ No credential is reachable from a script, and neither is the account name. Every | Property | Type | | |---|---|---| | `id`, `name` | text | | -| `kind` | enumeration | `query editor`, `table`, `create table`, `diagram`, `server dashboard`, `users and roles`, `query insights`, `object source` | +| `kind` | enumeration | `query editor`, `table`, `create table`, `diagram`, `server dashboard`, `users and roles`, `query insights`, `object source`, `version history` | | `table name`, `database name`, `schema name` | text | | | `query` | text | The SQL of a query tab | | `current result` | query result | The rows the tab is showing | diff --git a/docs/features/connection-window.mdx b/docs/features/connection-window.mdx index 814c058979..9edfb15843 100644 --- a/docs/features/connection-window.mdx +++ b/docs/features/connection-window.mdx @@ -29,11 +29,11 @@ A control the tab cannot use leaves the toolbar rather than standing there dimme | Where you are | Not there | |---|---| | A new table's tab | Refresh. The definition is not on the server yet | -| ER diagram, Server Dashboard, Query Insights, a DDL tab | The commit control. None of the four stages an edit of its own | +| ER diagram, Server Dashboard, Query Insights, a DDL tab, a history tab | The commit control. None of them stages an edit of its own | | [Agent mode](/features/agent-mode) | Refresh and the commit control. The grid and the object browser are behind the conversation | | An engine with nothing to switch between | The database control. On [SQLite](/databases/sqlite) and [DuckDB](/databases/duckdb) the file beside it is the database; on an engine with a single namespace there is no second level to name | -That needs macOS 15. On macOS 13 and 14 the same eight stand in every context and dim where they cannot act, except in two places. Refresh stays enabled on a new table's tab. So does the commit control on an ER diagram, Server Dashboard, Query Insights or DDL tab while a **Truncate** or a **Delete** is staged in the sidebar: that queue belongs to the connection, not to the tab in front of it. +That needs macOS 15. On macOS 13 and 14 the same eight stand in every context and dim where they cannot act, except in two places. Refresh stays enabled on a new table's tab. So does the commit control on an ER diagram, Server Dashboard, Query Insights, DDL or history tab while a **Truncate** or a **Delete** is staged in the sidebar: that queue belongs to the connection, not to the tab in front of it. The shape settles on a tab switch, a mode switch and a connection switch, and at no other moment. A staged edit, a running query or a dropped connection dims a control; it never moves one. diff --git a/docs/features/favorites.mdx b/docs/features/favorites.mdx index 8bc62034ab..a5fd9d0f80 100644 --- a/docs/features/favorites.mdx +++ b/docs/features/favorites.mdx @@ -91,10 +91,20 @@ The cursor lands right after `orders.`, ready for a column name. Only the first ## Working with saved queries -Double-click a favorite, or press `Return`, to insert it into the editor. The right-click menu has **Insert in Editor**, **Run in New Tab**, **Copy Query**, **Edit…**, **Move to**, and **Delete**. Arrow keys move between rows, typing jumps to a name, and `Delete` asks first. +Double-click a favorite, or press `Return`, to insert it into the editor. The right-click menu has **Insert in Editor**, **Run in New Tab**, **Copy Query**, **Edit…**, **Show History**, **Move to**, and **Delete**. Arrow keys move between rows, typing jumps to a name, and `Delete` asks first. Folders come from the same background menu. A folder's own menu has **New Favorite…**, **New Subfolder**, **Rename**, **Global**, and **Delete Folder**; deleting one moves what was inside up to the parent level. A global folder carries a globe, and **Global** changes that folder alone: the queries inside keep the scope they had, so mark them global too if you want them everywhere. Anything kept in a folder a connection cannot see is listed at that connection's top level, whether it is a global query or a global subfolder. Drag a query or a linked file out of the sidebar to drop its SQL into the editor or another app. +## Saved query versions + +Each time a saved query's SQL changes, the text it replaced is kept. That includes an edit arriving from another Mac through iCloud, so a sync that overwrites your change leaves it recoverable. + +Right-click a saved query and choose **Show History**. A tab opens with the versions down the left, newest first. **Changes** compares the selected version with the current SQL; **Content** shows it read-only, with **Copy**, **Export…**, and **Open in Editor**. + +Choose **Restore This Version** to put it back. The SQL it replaces becomes a version too, so a restore is undone the same way. + +Each query keeps its last 50 versions, on this Mac only: versions never sync. Renaming, moving, or changing a keyword records nothing, and deleting a saved query deletes its versions. + ## Team Library On a Team license the Favorites tab gains a **Team Library** section holding what your team published. Double-click one, or press `Return`, to run it in a new tab. @@ -146,8 +156,43 @@ Right-click a row and choose **Edit Metadata…** to change frontmatter without ### Managing linked files -Press `Delete` on a linked file, or right-click and choose **Move File to Trash**: it goes to the macOS Trash and stays recoverable. The rest of that menu is **Open in Editor**, **Edit Metadata…**, **Copy Query**, and **Show in Finder**. +Press `Delete` on a linked file, or right-click and choose **Move File to Trash**: it goes to the macOS Trash and stays recoverable. The rest of that menu is **Open in Editor**, **Edit Metadata…**, **Copy Query**, and **Show in Finder**, plus the [Git commands](#git-status-and-history) inside a repository. A folder's root row carries **Show in Finder**, **Copy Path**, **Disable**, **Reload**, **Add Another SQL Folder…**, and **Remove from Sidebar**. Removing unlinks the folder and leaves the files where they are. Linked folder paths are the one part of Favorites that does not sync, so each Mac links its own copy. + +### Git status and history + +When a linked folder sits inside a Git working copy, a changed file carries a letter at the end of its row. Hover it to see whether the change is staged. + +| Letter | Meaning | +|--------|---------| +| M | Modified | +| A | Added to the index, not yet committed | +| R | Renamed in the index | +| U | Untracked | +| ! | Merge conflict | + +A file with no letter matches its last commit. Letters refresh when a file changes, when the repository changes, and when you switch back to TablePro. + + + Sidebar with M and U letters beside linked files, next to a history tab listing three commits and a split diff + Sidebar with M and U letters beside linked files, next to a history tab listing three commits and a split diff + + +Right-click a file for the Git commands: + +- **Show History** opens a tab listing the commits that changed the file, following it across renames, up to the 200 most recent. The top row is the file on disk; select it to see what changed since the last commit. +- **Restore This Version**, in that tab, writes the selected commit's text into the file. It asks first when the file has uncommitted changes, because those are lost. +- **Discard Changes…** puts the file back to its staged version, or to its last commit when nothing is staged. It cannot be undone. + +Both write the text exactly as Git stores it, without line-ending conversion or filters. A file stored with Git LFS cannot be restored or discarded here; use your Git client. If the file, or for Discard its staged version, changes while you confirm, nothing is written. + +Stage, commit, and push in your Git client. The sidebar reads the repository and never touches the index. + +Git comes from Xcode, the Command Line Tools, or Homebrew. When none is installed the letters and commands stay hidden, and the Command Line Tools installer never opens. + + +Link only repositories you trust. Reading a file's status runs the repository's own content filters, exactly as `git status` in Terminal does. + diff --git a/docs/images/linked-file-history-dark.png b/docs/images/linked-file-history-dark.png new file mode 100644 index 0000000000..c4b891c373 Binary files /dev/null and b/docs/images/linked-file-history-dark.png differ diff --git a/docs/images/linked-file-history.png b/docs/images/linked-file-history.png new file mode 100644 index 0000000000..1fe6b2399f Binary files /dev/null and b/docs/images/linked-file-history.png differ