diff --git a/CHANGELOG.md b/CHANGELOG.md index 711099064..80256521b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Inline dropdown picker when editing ENUM and SET columns, covering MySQL, MariaDB, PostgreSQL, ClickHouse, DuckDB, and MongoDB JSON-schema enums (#1283) - Filter rows show an enum dropdown for `=` and `!=` operators on enum columns (#1283) +- CSV/TSV inspector: open files from Finder or File > Open, edit cells, multi-condition filter (Cmd+F), multi-column sort (shift-click headers), add/remove/rename columns with type override, copy/paste rows as TSV, undo/redo, auto-reload on external changes. Large files stream from disk without loading into memory. (#1259) +- iOS: SQL Server (MSSQL) connections via FreeTDS over TDS 7.4. Uses the shared `SSLConfiguration` model from connection settings. Supports connect, query, streaming results, schema browsing (tables, columns, indexes, foreign keys), database and schema switching, and explicit transactions. +- iOS: data browser, search, filter, and pagination now render correct SQL Server syntax (bracket-quoted identifiers, `OFFSET ... ROWS FETCH NEXT ... ROWS ONLY` pagination, `SELECT TOP 1` for cell value fetch). +- iOS: Settings > Sync now shows last sync time, a Sync Now button, and a Refresh from iCloud action that re-downloads every connection, group, and tag when items are missing on this device but visible on another. ### Changed diff --git a/Plugins/CSVInspectorPlugin/CSVDialect.swift b/Plugins/CSVInspectorPlugin/CSVDialect.swift new file mode 100644 index 000000000..c90529b17 --- /dev/null +++ b/Plugins/CSVInspectorPlugin/CSVDialect.swift @@ -0,0 +1,154 @@ +import Foundation + +struct CSVDialect: Equatable, Sendable { + enum LineEnding: Equatable, Sendable { + case crlf + case lf + case cr + + var bytes: [UInt8] { + switch self { + case .crlf: return [0x0D, 0x0A] + case .lf: return [0x0A] + case .cr: return [0x0D] + } + } + } + + var delimiter: UInt8 + var quoteChar: UInt8 + var encoding: String.Encoding + var lineEnding: LineEnding + var hasBom: Bool + + init( + delimiter: UInt8, + quoteChar: UInt8 = 0x22, + encoding: String.Encoding = .utf8, + lineEnding: LineEnding = .lf, + hasBom: Bool = false + ) { + self.delimiter = delimiter + self.quoteChar = quoteChar + self.encoding = encoding + self.lineEnding = lineEnding + self.hasBom = hasBom + } + + static let csv = CSVDialect(delimiter: 0x2C) + static let tsv = CSVDialect(delimiter: 0x09) + + private static let detectionScanLimit = 65_536 + + static func detect(from data: Data) -> CSVDialect { + var hasBom = false + var encoding: String.Encoding = .utf8 + var bomLength = 0 + let start = data.startIndex + + if data.count >= 3, + data[start] == 0xEF, data[start + 1] == 0xBB, data[start + 2] == 0xBF { + hasBom = true + encoding = .utf8 + bomLength = 3 + } else if data.count >= 2, data[start] == 0xFE, data[start + 1] == 0xFF { + hasBom = true + encoding = .utf16BigEndian + bomLength = 2 + } else if data.count >= 2, data[start] == 0xFF, data[start + 1] == 0xFE { + hasBom = true + encoding = .utf16LittleEndian + bomLength = 2 + } + + let body = data.dropFirst(bomLength) + if !hasBom { + encoding = probeEncoding(body) + } + + let sample = Array(body.prefix(detectionScanLimit)) + let delimiter = detectDelimiter(sample) + let lineEnding = detectLineEnding(sample) + + return CSVDialect( + delimiter: delimiter, + encoding: encoding, + lineEnding: lineEnding, + hasBom: hasBom + ) + } + + private static func probeEncoding(_ body: Data) -> String.Encoding { + var probe = body.prefix(262_144) + while let last = probe.last, (last & 0xC0) == 0x80 { + probe = probe.dropLast() + } + if let last = probe.last, last >= 0xC0 { + probe = probe.dropLast() + } + if String(data: Data(probe), encoding: .utf8) != nil { + return .utf8 + } + return .windowsCP1252 + } + + private static func detectDelimiter(_ bytes: [UInt8]) -> UInt8 { + var counts: [UInt8: Int] = [0x2C: 0, 0x09: 0, 0x3B: 0, 0x7C: 0] + var insideQuotes = false + var i = 0 + while i < bytes.count { + let byte = bytes[i] + if byte == 0x22 { + if insideQuotes, i + 1 < bytes.count, bytes[i + 1] == 0x22 { + i += 2 + continue + } + insideQuotes.toggle() + i += 1 + continue + } + if !insideQuotes, counts[byte] != nil { + counts[byte, default: 0] += 1 + } + i += 1 + } + return counts.max(by: { $0.value < $1.value })?.key ?? 0x2C + } + + private static func detectLineEnding(_ bytes: [UInt8]) -> LineEnding { + var insideQuotes = false + var i = 0 + while i < bytes.count { + let byte = bytes[i] + if byte == 0x22 { + if insideQuotes, i + 1 < bytes.count, bytes[i + 1] == 0x22 { + i += 2 + continue + } + insideQuotes.toggle() + i += 1 + continue + } + if !insideQuotes { + if byte == 0x0D { + return (i + 1 < bytes.count && bytes[i + 1] == 0x0A) ? .crlf : .cr + } + if byte == 0x0A { + return .lf + } + } + i += 1 + } + return .lf + } + + var bomBytes: [UInt8] { + guard hasBom else { return [] } + switch encoding { + case .utf8: return [0xEF, 0xBB, 0xBF] + case .utf16BigEndian: return [0xFE, 0xFF] + case .utf16LittleEndian: return [0xFF, 0xFE] + default: return [] + } + } +} diff --git a/Plugins/CSVInspectorPlugin/CSVDocument.swift b/Plugins/CSVInspectorPlugin/CSVDocument.swift new file mode 100644 index 000000000..36ada45db --- /dev/null +++ b/Plugins/CSVInspectorPlugin/CSVDocument.swift @@ -0,0 +1,334 @@ +import AppKit +import TableProPluginKit +import os + +public final class CSVDocument: NSDocument, InspectorDocument { + static let logger = Logger(subsystem: "com.TablePro", category: "CSVInspector") + + private static let typeInferenceSampleSize = 200 + + private(set) var store = CSVRowStore(data: Data(), dialect: .csv) + private(set) var dialect: CSVDialect = .csv + private(set) var inferredTypes: [InspectorColumnType] = [] + var typeOverrides: [Int: InspectorColumnType] = [:] + + public var onChange: (() -> Void)? + + private var lastInternalWriteTime: Date? + private var lastReadModificationDate: Date? + private var isPromptingExternalChange = false + + override public class var autosavesInPlace: Bool { false } + + override public class func canConcurrentlyReadDocuments(ofType typeName: String) -> Bool { true } + + override public class var readableTypes: [String] { + ["public.comma-separated-values-text", "public.tab-separated-values-text"] + } + + override public class var writableTypes: [String] { + ["public.comma-separated-values-text", "public.tab-separated-values-text"] + } + + override public func writableTypes(for saveOperation: NSDocument.SaveOperationType) -> [String] { + Self.writableTypes + } + + override public func fileNameExtension( + forType typeName: String, + saveOperation: NSDocument.SaveOperationType + ) -> String? { + typeName == "public.tab-separated-values-text" ? "tsv" : "csv" + } + + override public func makeWindowControllers() { + guard let factory = InspectorWindowFactory.make else { + Self.logger.error("CSVDocument.makeWindowControllers - InspectorWindowFactory.make is nil") + return + } + guard let windowController = factory(self) else { + Self.logger.error("CSVDocument.makeWindowControllers - factory returned nil") + return + } + addWindowController(windowController) + } + + override public func read(from url: URL, ofType typeName: String) throws { + let data = try Data(contentsOf: url, options: .mappedIfSafe) + var detected = CSVDialect.detect(from: data) + if typeName == "public.tab-separated-values-text" { + detected.delimiter = 0x09 + } + dialect = detected + store = CSVRowStore(data: data, dialect: detected) + let sample = store.pageRows(offset: 0, limit: Self.typeInferenceSampleSize) + inferredTypes = CSVTypeInferrer.inferColumns(rows: sample, columnCount: store.columnCount) + typeOverrides = [:] + let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) + lastReadModificationDate = attrs?[.modificationDate] as? Date + } + + override public func revert(toContentsOf url: URL, ofType typeName: String) throws { + try super.revert(toContentsOf: url, ofType: typeName) + NotificationCenter.default.post(name: .inspectorDocumentDidRevert, object: self) + } + + override public func write(to url: URL, ofType typeName: String) throws { + try CSVWriter(dialect: dialect).write(store, to: url) + lastInternalWriteTime = Date() + } + + override public func presentedItemDidChange() { + Task { @MainActor [weak self] in + guard let self else { return } + guard let url = self.fileURL else { return } + let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) + let currentMtime = attrs?[.modificationDate] as? Date + if let current = currentMtime, let last = self.lastReadModificationDate, current == last { + Self.logger.debug("presentedItemDidChange: mtime unchanged, skip") + return + } + if let last = self.lastInternalWriteTime, Date().timeIntervalSince(last) < 2.0 { + return + } + if self.isPromptingExternalChange { + return + } + if self.isDocumentEdited { + self.promptExternalChangeReload(url: url) + } else { + self.tryRevert(from: url) + } + } + } + + @MainActor + private func tryRevert(from url: URL) { + do { + try revert(toContentsOf: url, ofType: fileType ?? "public.comma-separated-values-text") + } catch { + Self.logger.error("Auto-revert failed: \(error.localizedDescription, privacy: .public)") + } + } + + @MainActor + private func promptExternalChangeReload(url: URL) { + guard let window = windowControllers.first?.window else { return } + isPromptingExternalChange = true + let alert = NSAlert() + alert.messageText = String(localized: "File modified externally") + alert.informativeText = String(localized: "Another app changed this file. Discard your unsaved changes and reload?") + alert.addButton(withTitle: String(localized: "Reload")) + alert.addButton(withTitle: String(localized: "Keep Changes")) + alert.alertStyle = .warning + alert.beginSheetModal(for: window) { [weak self] response in + guard let self else { return } + self.isPromptingExternalChange = false + if response == .alertFirstButtonReturn { + self.tryRevert(from: url) + } + } + } + + override public func updateChangeCount(_ change: NSDocument.ChangeType) { + super.updateChangeCount(change) + let edited = isDocumentEdited + for controller in windowControllers { + controller.window?.isDocumentEdited = edited + controller.synchronizeWindowTitleWithDocumentName() + } + } + + // MARK: - InspectorDocument + + public var rowCount: Int { store.rowCount } + public var columnNames: [String] { store.columnNames } + + public func value(row: Int, column: Int) -> String { + store.value(row: row, column: column) + } + + public func pageRows(offset: Int, limit: Int) -> [[String]] { + store.pageRows(offset: offset, limit: limit) + } + + public func snapshot() -> any InspectorDataSnapshot { + store.snapshot() + } + + public func displayedType(forColumn index: Int) -> InspectorColumnType { + if let override = typeOverrides[index] { return override } + guard index >= 0, index < inferredTypes.count else { return .text } + return inferredTypes[index] + } + + public func setTypeOverride(_ type: InspectorColumnType?, forColumn index: Int) { + let previous = typeOverrides[index] + if let type { + typeOverrides[index] = type + } else { + typeOverrides.removeValue(forKey: index) + } + guard previous != type else { return } + registerUndo { document in + document.setTypeOverride(previous, forColumn: index) + } + onChange?() + } + + public func setCell(row: Int, column: Int, to newValue: String) { + let previous = store.value(row: row, column: column) + guard previous != newValue else { return } + store.setValue(newValue, row: row, column: column) + registerUndo { document in + document.setCell(row: row, column: column, to: previous) + } + onChange?() + } + + public func appendRow() { + let index = store.appendRow(values: []) + registerUndo { document in + document.removeRow(at: index, suppressUndo: false) + } + onChange?() + } + + public func insertRow(at index: Int) { + store.insertRow([], at: index) + registerUndo { document in + document.removeRow(at: index, suppressUndo: false) + } + onChange?() + } + + public func removeRow(at index: Int) { + removeRow(at: index, suppressUndo: false) + } + + private func removeRow(at index: Int, suppressUndo: Bool) { + guard let removed = store.removeRow(at: index) else { return } + if !suppressUndo { + registerUndo { document in + document.reinsertRow(removed, at: index) + } + } + onChange?() + } + + private func reinsertRow(_ values: [String], at index: Int) { + store.insertRow(values, at: index) + registerUndo { document in + document.removeRow(at: index, suppressUndo: false) + } + onChange?() + } + + public func removeRows(at indices: IndexSet) { + let removed = store.removeRows(at: indices) + guard !removed.isEmpty else { return } + registerUndo { document in + document.reinsertRows(removed) + } + onChange?() + } + + private func reinsertRows(_ rows: [(index: Int, cells: [String])]) { + for entry in rows.sorted(by: { $0.index < $1.index }) { + store.insertRow(entry.cells, at: entry.index) + } + let originalIndices = IndexSet(rows.map(\.index)) + registerUndo { document in + document.removeRows(at: originalIndices) + } + onChange?() + } + + public func appendColumn(name: String) { + let index = store.columnCount + store.appendColumn(name: name) + inferredTypes.append(.text) + registerUndo { document in + document.removeColumn(at: index, suppressUndo: false) + } + onChange?() + } + + public func insertColumn(at index: Int, name: String) { + store.insertColumn(at: index, name: name, values: []) + inferredTypes.insert(.text, at: min(max(index, 0), inferredTypes.count)) + shiftTypeOverrides(insertingAt: index) + registerUndo { document in + document.removeColumn(at: index, suppressUndo: false) + } + onChange?() + } + + public func removeColumn(at index: Int) { + removeColumn(at: index, suppressUndo: false) + } + + private func removeColumn(at index: Int, suppressUndo: Bool) { + guard let removed = store.removeColumn(at: index) else { return } + let removedType = (index < inferredTypes.count) ? inferredTypes.remove(at: index) : .text + let removedOverride = typeOverrides[index] + shiftTypeOverrides(removingAt: index) + if !suppressUndo { + registerUndo { document in + document.reinsertColumn( + name: removed.name, + values: removed.values, + inferredType: removedType, + override: removedOverride, + at: index + ) + } + } + onChange?() + } + + private func reinsertColumn( + name: String, + values: [String], + inferredType: InspectorColumnType, + override: InspectorColumnType?, + at index: Int + ) { + store.insertColumn(at: index, name: name, values: values) + inferredTypes.insert(inferredType, at: min(max(index, 0), inferredTypes.count)) + shiftTypeOverrides(insertingAt: index) + if let override { + typeOverrides[index] = override + } + registerUndo { document in + document.removeColumn(at: index, suppressUndo: false) + } + onChange?() + } + + public func renameColumn(at index: Int, to name: String) { + guard let previous = store.renameColumn(at: index, to: name), previous != name else { return } + registerUndo { document in + document.renameColumn(at: index, to: previous) + } + onChange?() + } + + private func registerUndo(_ action: @escaping (CSVDocument) -> Void) { + undoManager?.registerUndo(withTarget: self, handler: action) + } + + private func shiftTypeOverrides(insertingAt index: Int) { + typeOverrides = typeOverrides.reduce(into: [Int: InspectorColumnType]()) { result, entry in + result[entry.key >= index ? entry.key + 1 : entry.key] = entry.value + } + } + + private func shiftTypeOverrides(removingAt index: Int) { + var shifted: [Int: InspectorColumnType] = [:] + for (key, value) in typeOverrides where key != index { + shifted[key > index ? key - 1 : key] = value + } + typeOverrides = shifted + } +} diff --git a/Plugins/CSVInspectorPlugin/CSVInspectorPlugin.swift b/Plugins/CSVInspectorPlugin/CSVInspectorPlugin.swift new file mode 100644 index 000000000..99155d5b4 --- /dev/null +++ b/Plugins/CSVInspectorPlugin/CSVInspectorPlugin.swift @@ -0,0 +1,27 @@ +import Foundation +import TableProPluginKit + +public final class CSVInspectorPlugin: NSObject, TableProPlugin, DocumentInspectorPlugin { + public static var pluginName: String { "CSV Inspector" } + public static var pluginVersion: String { "1.0.0" } + public static var pluginDescription: String { "View and edit CSV and TSV files natively." } + public static var capabilities: [PluginCapability] { [.documentInspector] } + public static var dependencies: [String] { [] } + + public static var inspectorId: String { "csv" } + public static var displayName: String { "CSV Inspector" } + public static var supportedUTIs: [String] { + [ + "public.comma-separated-values-text", + "public.tab-separated-values-text" + ] + } + public static var supportedFileExtensions: [String] { ["csv", "tsv"] } + public static var canEdit: Bool { true } + public static var iconName: String { "tablecells" } + public static var documentClass: AnyClass { CSVDocument.self } + + public override required init() { + super.init() + } +} diff --git a/Plugins/CSVInspectorPlugin/CSVRowStore.swift b/Plugins/CSVInspectorPlugin/CSVRowStore.swift new file mode 100644 index 000000000..e33845a7a --- /dev/null +++ b/Plugins/CSVInspectorPlugin/CSVRowStore.swift @@ -0,0 +1,326 @@ +import Foundation +import TableProPluginKit + +final class CSVRowStore { + enum RowRef: Sendable { + case original(Range) + case materialized([String]) + } + + enum RowSource { + case rawBytes(Range) + case cells([String]) + } + + enum ColumnTransform: Sendable { + case insert(index: Int, value: String) + case remove(index: Int) + } + + struct Snapshot: InspectorDataSnapshot { + let data: Data + let parser: CSVStreamingParser + let rows: [RowRef] + let columnTransforms: [ColumnTransform] + + var rowCount: Int { rows.count } + + func cells(at row: Int) -> [String] { + guard row >= 0, row < rows.count else { return [] } + switch rows[row] { + case .materialized(let cells): + return cells + case .original(let range): + let parsed = data.withUnsafeBytes { raw -> [String] in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return [] } + return parser.parseRow(UnsafeBufferPointer(start: base, count: raw.count), range: range) + } + return CSVRowStore.applyColumnTransforms(parsed, transforms: columnTransforms) + } + } + + func field(at row: Int, column: Int) -> String { + guard row >= 0, row < rows.count, column >= 0 else { return "" } + if !columnTransforms.isEmpty { + let cells = self.cells(at: row) + return column < cells.count ? cells[column] : "" + } + switch rows[row] { + case .materialized(let cells): + return column < cells.count ? cells[column] : "" + case .original(let range): + return data.withUnsafeBytes { raw -> String in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return "" } + return parser.field(UnsafeBufferPointer(start: base, count: raw.count), range: range, column: column) + } + } + } + } + + let data: Data + private let parser: CSVStreamingParser + private(set) var columnNames: [String] + private var headerRef: RowRef + private var logicalRows: [RowRef] + private var columnTransforms: [ColumnTransform] = [] + + private var cache: [Int: [String]] = [:] + private var cacheOrder: [Int] = [] + private let cacheCapacity = 4000 + + init(data: Data, dialect: CSVDialect) { + let streamingParser = CSVStreamingParser(dialect: dialect) + var ranges = data.withUnsafeBytes { raw -> [Range] in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return [] } + return streamingParser.indexRows(UnsafeBufferPointer(start: base, count: raw.count)) + } + + var resolvedColumnNames: [String] = [] + var resolvedHeaderRef: RowRef = .materialized([]) + if let first = ranges.first { + let headerCells = data.withUnsafeBytes { raw -> [String] in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return [] } + return streamingParser.parseRow(UnsafeBufferPointer(start: base, count: raw.count), range: first) + } + if Self.isLikelyHeader(headerCells) { + resolvedColumnNames = headerCells + resolvedHeaderRef = .original(first) + ranges.removeFirst() + } else { + let synthetic = (0.. String { + let cells = self.cells(forRow: row) + guard column >= 0, column < cells.count else { return "" } + return cells[column] + } + + func cells(forRow row: Int) -> [String] { + guard row >= 0, row < logicalRows.count else { return [] } + switch logicalRows[row] { + case .materialized(let cells): + return cells + case .original(let range): + return applyColumnTransforms(cachedRawCells(in: range)) + } + } + + func rowSource(at row: Int) -> RowSource { + guard row >= 0, row < logicalRows.count else { return .cells([]) } + switch logicalRows[row] { + case .original(let range) where columnTransforms.isEmpty: + return .rawBytes(range) + case .original, .materialized: + return .cells(cells(forRow: row)) + } + } + + func pageRows(offset: Int, limit: Int) -> [[String]] { + let lower = max(offset, 0) + let upper = min(lower + max(limit, 0), logicalRows.count) + guard lower < upper else { return [] } + return (lower.. Snapshot { + Snapshot(data: data, parser: parser, rows: logicalRows, columnTransforms: columnTransforms) + } + + @discardableResult + func setValue(_ value: String, row: Int, column: Int) -> String? { + guard row >= 0, row < logicalRows.count, column >= 0 else { return nil } + var current = cells(forRow: row) + while current.count <= column { + current.append("") + } + let previous = current[column] + current[column] = value + logicalRows[row] = .materialized(current) + return previous + } + + @discardableResult + func appendRow(values: [String]) -> Int { + logicalRows.append(.materialized(padRow(values))) + return logicalRows.count - 1 + } + + func insertRow(_ values: [String], at index: Int) { + let clamped = min(max(index, 0), logicalRows.count) + logicalRows.insert(.materialized(padRow(values)), at: clamped) + } + + @discardableResult + func removeRow(at index: Int) -> [String]? { + guard index >= 0, index < logicalRows.count else { return nil } + let removed = cells(forRow: index) + logicalRows.remove(at: index) + return removed + } + + @discardableResult + func removeRows(at indexSet: IndexSet) -> [(index: Int, cells: [String])] { + guard !indexSet.isEmpty else { return [] } + var removed: [(index: Int, cells: [String])] = [] + removed.reserveCapacity(indexSet.count) + var retained: [RowRef] = [] + retained.reserveCapacity(max(0, logicalRows.count - indexSet.count)) + for (i, row) in logicalRows.enumerated() { + if indexSet.contains(i) { + let captured: [String] + switch row { + case .materialized(let cells): + captured = cells + case .original(let range): + let parsed = data.withUnsafeBytes { raw -> [String] in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return [] } + return parser.parseRow(UnsafeBufferPointer(start: base, count: raw.count), range: range) + } + captured = Self.applyColumnTransforms(parsed, transforms: columnTransforms) + } + removed.append((i, captured)) + } else { + retained.append(row) + } + } + logicalRows = retained + return removed + } + + func appendColumn(name: String) { + insertColumn(at: columnNames.count, name: name, values: []) + } + + func insertColumn(at index: Int, name: String, values: [String] = []) { + let clamped = min(max(index, 0), columnNames.count) + columnNames.insert(name, at: clamped) + columnTransforms.append(.insert(index: clamped, value: "")) + for row in logicalRows.indices { + if case .materialized(var cells) = logicalRows[row] { + let value = clamped < values.count ? values[clamped] : "" + cells.insert(value, at: min(clamped, cells.count)) + logicalRows[row] = .materialized(cells) + } + } + if !values.isEmpty { + for row in logicalRows.indices where row < values.count { + setValue(values[row], row: row, column: clamped) + } + } + } + + @discardableResult + func removeColumn(at index: Int) -> (name: String, values: [String])? { + guard index >= 0, index < columnNames.count else { return nil } + let name = columnNames.remove(at: index) + var captured: [String] = [] + captured.reserveCapacity(logicalRows.count) + for row in logicalRows.indices { + let cells = self.cells(forRow: row) + captured.append(index < cells.count ? cells[index] : "") + } + columnTransforms.append(.remove(index: index)) + for row in logicalRows.indices { + if case .materialized(var cells) = logicalRows[row], index < cells.count { + cells.remove(at: index) + logicalRows[row] = .materialized(cells) + } + } + return (name, captured) + } + + @discardableResult + func renameColumn(at index: Int, to name: String) -> String? { + guard index >= 0, index < columnNames.count else { return nil } + let previous = columnNames[index] + columnNames[index] = name + if case .original(let range) = headerRef { + headerRef = .materialized(applyColumnTransforms(rawCells(in: range))) + } + if case .materialized(var cells) = headerRef { + while cells.count <= index { cells.append("") } + cells[index] = name + headerRef = .materialized(cells) + } + return previous + } + + private func applyColumnTransforms(_ cells: [String]) -> [String] { + Self.applyColumnTransforms(cells, transforms: columnTransforms) + } + + static func applyColumnTransforms(_ cells: [String], transforms: [ColumnTransform]) -> [String] { + guard !transforms.isEmpty else { return cells } + var result = cells + for transform in transforms { + switch transform { + case .insert(let index, let value): + result.insert(value, at: min(max(index, 0), result.count)) + case .remove(let index): + if index >= 0, index < result.count { + result.remove(at: index) + } + } + } + return result + } + + private func cachedRawCells(in range: Range) -> [String] { + if let cached = cache[range.lowerBound] { + return cached + } + let parsed = rawCells(in: range) + cache[range.lowerBound] = parsed + cacheOrder.append(range.lowerBound) + if cacheOrder.count > cacheCapacity { + let evicted = cacheOrder.removeFirst() + cache.removeValue(forKey: evicted) + } + return parsed + } + + private func rawCells(in range: Range) -> [String] { + data.withUnsafeBytes { raw -> [String] in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return [] } + return parser.parseRow(UnsafeBufferPointer(start: base, count: raw.count), range: range) + } + } + + private func padRow(_ values: [String]) -> [String] { + if values.count == columnNames.count { return values } + if values.count < columnNames.count { + return values + Array(repeating: "", count: columnNames.count - values.count) + } + return Array(values.prefix(columnNames.count)) + } + + private static func isLikelyHeader(_ cells: [String]) -> Bool { + guard !cells.isEmpty else { return false } + let nonNumeric = cells.filter { !$0.isEmpty && Double($0) == nil }.count + return nonNumeric * 2 >= cells.count + } +} diff --git a/Plugins/CSVInspectorPlugin/CSVStreamingParser.swift b/Plugins/CSVInspectorPlugin/CSVStreamingParser.swift new file mode 100644 index 000000000..2cd21053b --- /dev/null +++ b/Plugins/CSVInspectorPlugin/CSVStreamingParser.swift @@ -0,0 +1,176 @@ +import Foundation + +struct CSVStreamingParser: Sendable { + let dialect: CSVDialect + + func indexRows(_ bytes: UnsafeBufferPointer) -> [Range] { + var ranges: [Range] = [] + let quote = dialect.quoteChar + let delimiter = dialect.delimiter + let count = bytes.count + var i = bomSkip(in: bytes) + var rowStart = i + var insideQuotes = false + var atFieldStart = true + + while i < count { + let byte = bytes[i] + if insideQuotes { + if byte == quote { + if i + 1 < count, bytes[i + 1] == quote { + i += 2 + continue + } + insideQuotes = false + } + i += 1 + continue + } + if byte == quote, atFieldStart { + insideQuotes = true + atFieldStart = false + i += 1 + continue + } + if byte == delimiter { + atFieldStart = true + i += 1 + continue + } + if byte == 0x0A { + i += 1 + ranges.append(rowStart.., range: Range) -> [String] { + var fields: [String] = [] + var field: [UInt8] = [] + let quote = dialect.quoteChar + let delimiter = dialect.delimiter + var insideQuotes = false + var i = range.lowerBound + let end = min(range.upperBound, bytes.count) + + while i < end { + let byte = bytes[i] + if insideQuotes { + if byte == quote { + if i + 1 < end, bytes[i + 1] == quote { + field.append(quote) + i += 2 + continue + } + insideQuotes = false + i += 1 + continue + } + field.append(byte) + i += 1 + continue + } + if byte == quote, field.isEmpty { + insideQuotes = true + i += 1 + continue + } + if byte == delimiter { + fields.append(decode(field)) + field.removeAll(keepingCapacity: true) + i += 1 + continue + } + if byte == 0x0A || byte == 0x0D { + break + } + field.append(byte) + i += 1 + } + fields.append(decode(field)) + return fields + } + + func field(_ bytes: UnsafeBufferPointer, range: Range, column: Int) -> String { + guard column >= 0 else { return "" } + let quote = dialect.quoteChar + let delimiter = dialect.delimiter + var insideQuotes = false + var i = range.lowerBound + let end = min(range.upperBound, bytes.count) + var currentColumn = 0 + var fieldStarted = false + var field: [UInt8] = [] + + while i < end { + let byte = bytes[i] + if insideQuotes { + if byte == quote { + if i + 1 < end, bytes[i + 1] == quote { + if currentColumn == column { field.append(quote) } + i += 2 + continue + } + insideQuotes = false + i += 1 + continue + } + if currentColumn == column { field.append(byte) } + i += 1 + continue + } + if byte == quote, !fieldStarted { + insideQuotes = true + fieldStarted = true + i += 1 + continue + } + if byte == delimiter { + if currentColumn == column { return decode(field) } + currentColumn += 1 + fieldStarted = false + i += 1 + continue + } + if byte == 0x0A || byte == 0x0D { + break + } + if currentColumn == column { field.append(byte) } + fieldStarted = true + i += 1 + } + return currentColumn == column ? decode(field) : "" + } + + private func decode(_ bytes: [UInt8]) -> String { + if bytes.isEmpty { return "" } + return String(bytes: bytes, encoding: dialect.encoding) + ?? String(decoding: bytes, as: UTF8.self) + } + + private func bomSkip(in bytes: UnsafeBufferPointer) -> Int { + guard dialect.hasBom else { return 0 } + switch dialect.encoding { + case .utf8: return min(3, bytes.count) + case .utf16BigEndian, .utf16LittleEndian: return min(2, bytes.count) + default: return 0 + } + } +} diff --git a/Plugins/CSVInspectorPlugin/CSVTypeInferrer.swift b/Plugins/CSVInspectorPlugin/CSVTypeInferrer.swift new file mode 100644 index 000000000..3d963824f --- /dev/null +++ b/Plugins/CSVInspectorPlugin/CSVTypeInferrer.swift @@ -0,0 +1,61 @@ +import Foundation +import TableProPluginKit + +struct CSVTypeInferrer { + typealias InferredType = InspectorColumnType + + static let sampleSize = 200 + + private static let booleanLiterals: Set = [ + "true", "false", "yes", "no", "t", "f", "y", "n" + ] + + private static let isoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + private static let dateOnlyFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate] + return formatter + }() + + static func infer(column values: [String]) -> InferredType { + var sample: [String] = [] + sample.reserveCapacity(min(values.count, sampleSize)) + for value in values where !value.isEmpty { + sample.append(value) + if sample.count >= sampleSize { break } + } + guard !sample.isEmpty else { return .text } + + if sample.allSatisfy({ Int($0) != nil }) { return .integer } + if sample.allSatisfy({ Double($0) != nil }) { return .real } + if sample.allSatisfy({ booleanLiterals.contains($0.lowercased()) }) { return .boolean } + if sample.allSatisfy({ isDate($0) }) { return .date } + return .text + } + + static func inferColumns(rows: [[String]], columnCount: Int) -> [InferredType] { + var result: [InferredType] = [] + result.reserveCapacity(columnCount) + for col in 0..= sampleSize { break } + } + result.append(infer(column: columnSample)) + } + return result + } + + private static func isDate(_ value: String) -> Bool { + if isoFormatter.date(from: value) != nil { return true } + if dateOnlyFormatter.date(from: value) != nil { return true } + return false + } +} diff --git a/Plugins/CSVInspectorPlugin/CSVWriter.swift b/Plugins/CSVInspectorPlugin/CSVWriter.swift new file mode 100644 index 000000000..d781fe46d --- /dev/null +++ b/Plugins/CSVInspectorPlugin/CSVWriter.swift @@ -0,0 +1,117 @@ +import Foundation + +struct CSVWriter { + enum WriteError: Error, LocalizedError { + case encodingFailed + case writeFailed(underlying: Error?) + + var errorDescription: String? { + switch self { + case .encodingFailed: + return String(localized: "Could not encode CSV content") + case .writeFailed(let underlying): + if let underlying { + return String(format: String(localized: "Failed to write CSV file: %@"), underlying.localizedDescription) + } + return String(localized: "Failed to write CSV file") + } + } + } + + private static let flushThreshold = 1 << 20 + + let dialect: CSVDialect + + func write(_ store: CSVRowStore, to url: URL) throws { + let directory = url.deletingLastPathComponent() + let tempURL = directory.appendingPathComponent(".\(url.lastPathComponent).tmp.\(UUID().uuidString)") + guard FileManager.default.createFile(atPath: tempURL.path, contents: nil) else { + throw WriteError.writeFailed(underlying: nil) + } + + do { + let handle = try FileHandle(forWritingTo: tempURL) + defer { try? handle.close() } + + var buffer = Data() + buffer.reserveCapacity(Self.flushThreshold + 4096) + buffer.append(contentsOf: dialect.bomBytes) + + append(store.headerSource, from: store, into: &buffer) + if buffer.count >= Self.flushThreshold { + try handle.write(contentsOf: buffer) + buffer.removeAll(keepingCapacity: true) + } + + for row in 0..= Self.flushThreshold { + try handle.write(contentsOf: buffer) + buffer.removeAll(keepingCapacity: true) + } + } + if !buffer.isEmpty { + try handle.write(contentsOf: buffer) + } + } catch { + try? FileManager.default.removeItem(at: tempURL) + throw WriteError.writeFailed(underlying: error) + } + + do { + _ = try FileManager.default.replaceItemAt(url, withItemAt: tempURL) + } catch { + try? FileManager.default.removeItem(at: tempURL) + throw WriteError.writeFailed(underlying: error) + } + } + + func encodeRow(_ cells: [String]) -> String { + let delimiterScalar = UnicodeScalar(dialect.delimiter) + let quoteScalar = UnicodeScalar(dialect.quoteChar) + let delimiter = String(delimiterScalar) + let quote = String(quoteScalar) + let doubledQuote = quote + quote + + var line = "" + for (index, field) in cells.enumerated() { + if index > 0 { + line += delimiter + } + line += Self.encodeField( + field, + delimiterScalar: delimiterScalar, + quoteScalar: quoteScalar, + quote: quote, + doubledQuote: doubledQuote + ) + } + return line + } + + private func append(_ source: CSVRowStore.RowSource, from store: CSVRowStore, into buffer: inout Data) { + switch source { + case .rawBytes(let range): + buffer.append(store.data[range]) + case .cells(let cells): + if let line = encodeRow(cells).data(using: dialect.encoding) { + buffer.append(line) + } + buffer.append(contentsOf: dialect.lineEnding.bytes) + } + } + + private static func encodeField( + _ field: String, + delimiterScalar: UnicodeScalar, + quoteScalar: UnicodeScalar, + quote: String, + doubledQuote: String + ) -> String { + let needsQuoting = field.unicodeScalars.contains { scalar in + scalar == delimiterScalar || scalar == quoteScalar || scalar == "\n" || scalar == "\r" + } + guard needsQuoting else { return field } + return quote + field.replacingOccurrences(of: quote, with: doubledQuote) + quote + } +} diff --git a/Plugins/CSVInspectorPlugin/Info.plist b/Plugins/CSVInspectorPlugin/Info.plist new file mode 100644 index 000000000..253fdc12c --- /dev/null +++ b/Plugins/CSVInspectorPlugin/Info.plist @@ -0,0 +1,22 @@ + + + + + TableProInspectorKitVersion + 1 + TableProProvidesInspectorIds + + csv + + TableProInspectorFileExtensions + + csv + tsv + + TableProInspectorUTIs + + public.comma-separated-values-text + public.tab-separated-values-text + + + diff --git a/Plugins/TableProPluginKit/DocumentInspectorPlugin.swift b/Plugins/TableProPluginKit/DocumentInspectorPlugin.swift new file mode 100644 index 000000000..5625328e7 --- /dev/null +++ b/Plugins/TableProPluginKit/DocumentInspectorPlugin.swift @@ -0,0 +1,61 @@ +import AppKit +import Foundation + +public enum InspectorWindowFactory { + @MainActor public static var make: ((NSDocument) -> NSWindowController?)? +} + +public protocol DocumentInspectorPlugin: TableProPlugin { + static var inspectorId: String { get } + static var displayName: String { get } + static var supportedUTIs: [String] { get } + static var supportedFileExtensions: [String] { get } + static var canEdit: Bool { get } + static var iconName: String { get } + + static var documentClass: AnyClass { get } +} + +public extension DocumentInspectorPlugin { + static var canEdit: Bool { true } + static var iconName: String { "doc.text" } +} + +public enum InspectorColumnType: String, Sendable, Equatable, CaseIterable { + case text + case integer + case real + case boolean + case date +} + +public extension Notification.Name { + static let inspectorDocumentDidRevert = Notification.Name("com.TablePro.InspectorDocumentDidRevert") +} + +public protocol InspectorDataSnapshot: Sendable { + var rowCount: Int { get } + func cells(at row: Int) -> [String] + func field(at row: Int, column: Int) -> String +} + +@MainActor +public protocol InspectorDocument: AnyObject { + var rowCount: Int { get } + var columnNames: [String] { get } + func value(row: Int, column: Int) -> String + func pageRows(offset: Int, limit: Int) -> [[String]] + func snapshot() -> any InspectorDataSnapshot + func displayedType(forColumn index: Int) -> InspectorColumnType + func setCell(row: Int, column: Int, to value: String) + func appendRow() + func insertRow(at index: Int) + func removeRow(at index: Int) + func removeRows(at indices: IndexSet) + func appendColumn(name: String) + func insertColumn(at index: Int, name: String) + func removeColumn(at index: Int) + func renameColumn(at index: Int, to name: String) + func setTypeOverride(_ type: InspectorColumnType?, forColumn index: Int) + var onChange: (() -> Void)? { get set } +} diff --git a/Plugins/TableProPluginKit/PluginCapability.swift b/Plugins/TableProPluginKit/PluginCapability.swift index 371ff0a75..ab8efffbe 100644 --- a/Plugins/TableProPluginKit/PluginCapability.swift +++ b/Plugins/TableProPluginKit/PluginCapability.swift @@ -4,4 +4,5 @@ public enum PluginCapability: Int, Codable, Sendable { case databaseDriver case exportFormat case importFormat + case documentInspector } diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 9eed1f44c..65a1e0f88 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -41,6 +41,8 @@ 5A86F000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A86F000100000000 /* SQLImport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A87A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5ABBED742FB55E1400A78382 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5ABBED802FB55E1400A78382 /* CSVInspectorPlugin.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5ABQR00100000000000000A1 /* BigQueryAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABQR00200000000000000A1 /* BigQueryAuth.swift */; }; 5ABQR00100000000000000A2 /* BigQueryConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABQR00200000000000000A2 /* BigQueryConnection.swift */; }; 5ABQR00100000000000000A3 /* BigQueryPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABQR00200000000000000A3 /* BigQueryPlugin.swift */; }; @@ -187,6 +189,13 @@ remoteGlobalIDString = 5A86F000000000000; remoteInfo = SQLImport; }; + 5ABBED7E2FB55E1400A78382 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5ABBED712FB55E1400A78382; + remoteInfo = CSVInspectorPlugin; + }; 5ABCC5AB2F43856700EAF3FC /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -248,6 +257,7 @@ 5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins (12 items) */, 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins (12 items) */, 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins (12 items) */, + 5ABBED802FB55E1400A78382 /* CSVInspectorPlugin.tableplugin in Copy Plug-Ins (12 items) */, ); name = "Copy Plug-Ins (12 items)"; runOnlyForDeploymentPostprocessing = 0; @@ -286,6 +296,7 @@ 5A86E000100000000 /* MQLExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MQLExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A86F000100000000 /* SQLImport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SQLImport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A87A000100000000 /* CassandraDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CassandraDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; + 5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CSVInspectorPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABQR00200000000000000A1 /* BigQueryAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigQueryAuth.swift; sourceTree = ""; }; 5ABQR00200000000000000A2 /* BigQueryConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigQueryConnection.swift; sourceTree = ""; }; @@ -468,6 +479,13 @@ ); target = 5A87A000000000000 /* CassandraDriver */; }; + 5ABBED7B2FB55E1500A78382 /* Exceptions for "Plugins/CSVInspectorPlugin" folder in "CSVInspectorPlugin" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5ABBED712FB55E1400A78382 /* CSVInspectorPlugin */; + }; 5AE4F4802F6BC0640097AC5B /* Exceptions for "Plugins/CloudflareD1DriverPlugin" folder in "CloudflareD1DriverPlugin" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -646,6 +664,14 @@ path = Plugins/CassandraDriverPlugin; sourceTree = ""; }; + 5ABBED7C2FB55E1400A78382 /* Plugins/CSVInspectorPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 5ABBED7B2FB55E1500A78382 /* Exceptions for "Plugins/CSVInspectorPlugin" folder in "CSVInspectorPlugin" target */, + ); + path = Plugins/CSVInspectorPlugin; + sourceTree = ""; + }; 5ABCC5A82F43856700EAF3FC /* TableProTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = TableProTests; @@ -829,6 +855,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5ABBED732FB55E1400A78382 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5ABBED742FB55E1400A78382 /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5ABCC5A42F43856700EAF3FC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -900,6 +934,7 @@ 5A869000500000000 /* Plugins/DuckDBDriverPlugin */, 5A87A000500000000 /* Plugins/CassandraDriverPlugin */, 5A86A000500000000 /* Plugins/CSVExportPlugin */, + 5ABBED7C2FB55E1400A78382 /* Plugins/CSVInspectorPlugin */, 5A86B000500000000 /* Plugins/JSONExportPlugin */, 5A86C000500000000 /* Plugins/SQLExportPlugin */, 5A86D000500000000 /* Plugins/XLSXExportPlugin */, @@ -941,6 +976,7 @@ 5AE4F4742F6BC0640097AC5B /* CloudflareD1DriverPlugin.tableplugin */, 5A3BE6F82F97DA8100611C1F /* LibSQLDriverPlugin.tableplugin */, 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */, + 5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */, ); name = Products; sourceTree = ""; @@ -1037,6 +1073,7 @@ 5A86D000C00000000 /* PBXTargetDependency */, 5A86E000C00000000 /* PBXTargetDependency */, 5A86F000C00000000 /* PBXTargetDependency */, + 5ABBED7F2FB55E1400A78382 /* PBXTargetDependency */, 5ADDB00000000000000000C1 /* PBXTargetDependency */, 5ABQR00000000000000000C1 /* PBXTargetDependency */, ); @@ -1449,6 +1486,26 @@ productReference = 5A87A000100000000 /* CassandraDriver.tableplugin */; productType = "com.apple.product-type.bundle"; }; + 5ABBED712FB55E1400A78382 /* CSVInspectorPlugin */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5ABBED762FB55E1400A78382 /* Build configuration list for PBXNativeTarget "CSVInspectorPlugin" */; + buildPhases = ( + 5ABBED722FB55E1400A78382 /* Sources */, + 5ABBED732FB55E1400A78382 /* Frameworks */, + 5ABBED752FB55E1400A78382 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5ABBED7C2FB55E1400A78382 /* Plugins/CSVInspectorPlugin */, + ); + name = CSVInspectorPlugin; + productName = CSVInspectorPlugin; + productReference = 5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; 5ABCC5A62F43856700EAF3FC /* TableProTests */ = { isa = PBXNativeTarget; buildConfigurationList = 5ABCC5AD2F43856700EAF3FC /* Build configuration list for PBXNativeTarget "TableProTests" */; @@ -1678,6 +1735,7 @@ 5ABQR00600000000000000B0 /* BigQueryDriverPlugin */, 5A3BE6F72F97DA8100611C1F /* LibSQLDriverPlugin */, 5A32BBFF2F9D5F1300BAEB5F /* mcp-server */, + 5ABBED712FB55E1400A78382 /* CSVInspectorPlugin */, ); }; /* End PBXProject section */ @@ -1816,6 +1874,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5ABBED752FB55E1400A78382 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5ABCC5A52F43856700EAF3FC /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1994,6 +2059,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5ABBED722FB55E1400A78382 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5ABCC5A32F43856700EAF3FC /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2133,6 +2205,11 @@ target = 5A86F000000000000 /* SQLImport */; targetProxy = 5A86F000B00000000 /* PBXContainerItemProxy */; }; + 5ABBED7F2FB55E1400A78382 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5ABBED712FB55E1400A78382 /* CSVInspectorPlugin */; + targetProxy = 5ABBED7E2FB55E1400A78382 /* PBXContainerItemProxy */; + }; 5ABCC5AC2F43856700EAF3FC /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A1091C62EF17EDC0055EA7C /* TablePro */; @@ -3537,6 +3614,52 @@ }; name = Release; }; + 5ABBED772FB55E1400A78382 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/CSVInspectorPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).CSVInspectorPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.CSVInspectorPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; + 5ABBED782FB55E1400A78382 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/CSVInspectorPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).CSVInspectorPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.CSVInspectorPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; 5ABCC5AE2F43856700EAF3FC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3976,6 +4099,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5ABBED762FB55E1400A78382 /* Build configuration list for PBXNativeTarget "CSVInspectorPlugin" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5ABBED772FB55E1400A78382 /* Debug */, + 5ABBED782FB55E1400A78382 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5ABCC5AD2F43856700EAF3FC /* Build configuration list for PBXNativeTarget "TableProTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index a3e5eeb55..5e1054d68 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -18,7 +18,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - URL & File Open + func applicationWillFinishLaunching(_ notification: Notification) { + _ = InspectorDocumentController() + } + func application(_ application: NSApplication, open urls: [URL]) { + Logger(subsystem: "com.TablePro", category: "CSVInspector") + .debug("AppDelegate.application(_:open:) urls=\(urls.map(\.lastPathComponent).joined(separator: ","), privacy: .public)") AppLaunchCoordinator.shared.handleOpenURLs(urls) } @@ -187,14 +193,18 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc func windowWillClose(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } + let csvLogger = Logger(subsystem: "com.TablePro", category: "CSVInspector") if AppLaunchCoordinator.isMainWindow(window) { let remaining = NSApp.windows.filter { $0 !== window && AppLaunchCoordinator.isMainWindow($0) && $0.isVisible }.count + csvLogger.debug("AppDelegate.windowWillClose - main window '\(window.identifier?.rawValue ?? "nil", privacy: .public)' closing, remaining main windows=\(remaining, privacy: .public)") if remaining == 0 { AppEvents.shared.mainWindowWillClose.send(()) WindowOpener.shared.openWelcome() } + } else { + csvLogger.debug("AppDelegate.windowWillClose - non-main window '\(window.identifier?.rawValue ?? "nil", privacy: .public)' closing") } } diff --git a/TablePro/Core/Plugins/InspectorDocumentController.swift b/TablePro/Core/Plugins/InspectorDocumentController.swift new file mode 100644 index 000000000..6b65bdad3 --- /dev/null +++ b/TablePro/Core/Plugins/InspectorDocumentController.swift @@ -0,0 +1,45 @@ +// +// InspectorDocumentController.swift +// TablePro +// + +import AppKit +import os +import TableProPluginKit + +@MainActor +final class InspectorDocumentController: NSDocumentController { + private static let logger = Logger(subsystem: "com.TablePro", category: "CSVInspector") + + override init() { + super.init() + InspectorWindowFactory.make = { nsDocument in + guard let inspector = nsDocument as? any InspectorDocument else { + Self.logger.error("InspectorWindowFactory - document is not an InspectorDocument (\(String(describing: Swift.type(of: nsDocument)), privacy: .public))") + return nil + } + return InspectorWindowController(nsDocument: nsDocument, inspectorDocument: inspector) + } + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + override func documentClass(forType typeName: String) -> AnyClass? { + if let inspector = PluginManager.shared.inspectorPlugin(forUTI: typeName) { + return Swift.type(of: inspector).documentClass + } + return super.documentClass(forType: typeName) + } + + override func typeForContents(of url: URL) throws -> String { + let ext = url.pathExtension.lowercased() + if PluginManager.shared.allInspectorFileExtensions.contains(ext), + let plugin = PluginManager.shared.inspectorPlugin(forFileExtension: ext), + let uti = Swift.type(of: plugin).supportedUTIs.first { + return uti + } + return try super.typeForContents(of: url) + } +} diff --git a/TablePro/Core/Plugins/PluginManager+Inspector.swift b/TablePro/Core/Plugins/PluginManager+Inspector.swift new file mode 100644 index 000000000..9e93848e0 --- /dev/null +++ b/TablePro/Core/Plugins/PluginManager+Inspector.swift @@ -0,0 +1,66 @@ +// +// PluginManager+Inspector.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension PluginManager { + func inspectorPlugin(forId id: String) -> (any DocumentInspectorPlugin)? { + if let plugin = inspectorPlugins[id] { return plugin } + activateInspector(id: id) + return inspectorPlugins[id] + } + + func inspectorPlugin(forFileExtension ext: String) -> (any DocumentInspectorPlugin)? { + let needle = ext.lowercased() + if let match = activatedInspector(matchingFileExtension: needle) { + return match + } + guard let url = lazyInspectorFileExtensions[needle] else { return nil } + activateLazyBundle(at: url) + return activatedInspector(matchingFileExtension: needle) + } + + func inspectorPlugin(forUTI uti: String) -> (any DocumentInspectorPlugin)? { + if let match = activatedInspector(matchingUTI: uti) { + return match + } + guard let url = lazyInspectorUTIs[uti] else { return nil } + activateLazyBundle(at: url) + return activatedInspector(matchingUTI: uti) + } + + var allInspectorFileExtensions: Set { + var result = Set(lazyInspectorFileExtensions.keys) + for plugin in inspectorPlugins.values { + for ext in type(of: plugin).supportedFileExtensions { + result.insert(ext.lowercased()) + } + } + return result + } + + var allInspectorUTIs: Set { + var result = Set(lazyInspectorUTIs.keys) + for plugin in inspectorPlugins.values { + for uti in type(of: plugin).supportedUTIs { + result.insert(uti) + } + } + return result + } + + private func activatedInspector(matchingFileExtension ext: String) -> (any DocumentInspectorPlugin)? { + inspectorPlugins.values.first { plugin in + type(of: plugin).supportedFileExtensions.contains { $0.lowercased() == ext } + } + } + + private func activatedInspector(matchingUTI uti: String) -> (any DocumentInspectorPlugin)? { + inspectorPlugins.values.first { plugin in + type(of: plugin).supportedUTIs.contains(uti) + } + } +} diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index adb9bbdb5..ca29847a3 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -18,7 +18,7 @@ extension PluginManager { if let driver = instance as? any DriverPlugin { if !declared.contains(.databaseDriver) { - Self.logger.warning("Plugin '\(pluginId)' conforms to DriverPlugin but does not declare .databaseDriver capability — registering anyway") + Self.logger.warning("Plugin '\(pluginId)' conforms to DriverPlugin but does not declare .databaseDriver capability - registering anyway") } do { try validateDriverDescriptor(type(of: driver), pluginId: pluginId) @@ -57,7 +57,7 @@ extension PluginManager { if let exportPlugin = instance as? any ExportFormatPlugin { if !declared.contains(.exportFormat) { - Self.logger.warning("Plugin '\(pluginId)' conforms to ExportFormatPlugin but does not declare .exportFormat capability — registering anyway") + Self.logger.warning("Plugin '\(pluginId)' conforms to ExportFormatPlugin but does not declare .exportFormat capability - registering anyway") } let formatId = type(of: exportPlugin).formatId exportPlugins[formatId] = exportPlugin @@ -67,7 +67,7 @@ extension PluginManager { if let importPlugin = instance as? any ImportFormatPlugin { if !declared.contains(.importFormat) { - Self.logger.warning("Plugin '\(pluginId)' conforms to ImportFormatPlugin but does not declare .importFormat capability — registering anyway") + Self.logger.warning("Plugin '\(pluginId)' conforms to ImportFormatPlugin but does not declare .importFormat capability - registering anyway") } let formatId = type(of: importPlugin).formatId importPlugins[formatId] = importPlugin @@ -75,6 +75,16 @@ extension PluginManager { registeredAny = true } + if let inspectorPlugin = instance as? any DocumentInspectorPlugin { + if !declared.contains(.documentInspector) { + Self.logger.warning("Plugin '\(pluginId)' conforms to DocumentInspectorPlugin but does not declare .documentInspector capability - registering anyway") + } + let inspectorId = type(of: inspectorPlugin).inspectorId + inspectorPlugins[inspectorId] = inspectorPlugin + Self.logger.debug("Registered inspector plugin '\(pluginId)' for id '\(inspectorId)'") + registeredAny = true + } + if registeredAny { pluginInstances[pluginId] = instance } @@ -85,6 +95,7 @@ extension PluginManager { let isDriver = pluginType is any DriverPlugin.Type let isExporter = pluginType is any ExportFormatPlugin.Type let isImporter = pluginType is any ImportFormatPlugin.Type + let isInspector = pluginType is any DocumentInspectorPlugin.Type if declared.contains(.databaseDriver) && !isDriver { Self.logger.warning("Plugin '\(pluginId)' declares .databaseDriver but does not conform to DriverPlugin") @@ -95,6 +106,9 @@ extension PluginManager { if declared.contains(.importFormat) && !isImporter { Self.logger.warning("Plugin '\(pluginId)' declares .importFormat but does not conform to ImportFormatPlugin") } + if declared.contains(.documentInspector) && !isInspector { + Self.logger.warning("Plugin '\(pluginId)' declares .documentInspector but does not conform to DocumentInspectorPlugin") + } } // MARK: - Descriptor Validation diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 4aab940b1..6b29e120b 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -14,6 +14,7 @@ import TableProPluginKit final class PluginManager { static let shared = PluginManager() static let currentPluginKitVersion = 13 + static let currentInspectorKitVersion = 1 private static let disabledPluginsKey = "com.TablePro.disabledPlugins" private static let legacyDisabledPluginsKey = "disabledPlugins" @@ -77,6 +78,8 @@ final class PluginManager { internal(set) var importPlugins: [String: any ImportFormatPlugin] = [:] + internal(set) var inspectorPlugins: [String: any DocumentInspectorPlugin] = [:] + internal(set) var pluginInstances: [String: any TableProPlugin] = [:] var disabledPluginIds: Set { @@ -91,6 +94,9 @@ final class PluginManager { @ObservationIgnored private(set) var lazyDriverURLs: [String: URL] = [:] @ObservationIgnored private var lazyExportURLs: [String: URL] = [:] @ObservationIgnored private var lazyImportURLs: [String: URL] = [:] + @ObservationIgnored internal var lazyInspectorURLs: [String: URL] = [:] + @ObservationIgnored internal var lazyInspectorFileExtensions: [String: URL] = [:] + @ObservationIgnored internal var lazyInspectorUTIs: [String: URL] = [:] @ObservationIgnored private var activatedBundleIds: Set = [] var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:] @@ -264,6 +270,7 @@ final class PluginManager { if !manifest.providedDatabaseTypeIds.isEmpty { capabilities.append(.databaseDriver) } if !manifest.providedExportFormatIds.isEmpty { capabilities.append(.exportFormat) } if !manifest.providedImportFormatIds.isEmpty { capabilities.append(.importFormat) } + if !manifest.providedInspectorIds.isEmpty { capabilities.append(.documentInspector) } let info = bundle.infoDictionary ?? [:] let version = (info["CFBundleShortVersionString"] as? String) ?? "0.0.0" @@ -300,7 +307,16 @@ final class PluginManager { for formatId in manifest.providedImportFormatIds { lazyImportURLs[formatId] = url } - Self.logger.debug("Registered lazy plugin '\(bundleId)': drivers=\(manifest.providedDatabaseTypeIds), exports=\(manifest.providedExportFormatIds), imports=\(manifest.providedImportFormatIds)") + for inspectorId in manifest.providedInspectorIds { + lazyInspectorURLs[inspectorId] = url + } + for ext in manifest.providedInspectorFileExtensions { + lazyInspectorFileExtensions[ext.lowercased()] = url + } + for uti in manifest.providedInspectorUTIs { + lazyInspectorUTIs[uti] = url + } + Self.logger.debug("Registered lazy plugin '\(bundleId)': drivers=\(manifest.providedDatabaseTypeIds), exports=\(manifest.providedExportFormatIds), imports=\(manifest.providedImportFormatIds), inspectors=\(manifest.providedInspectorIds)") } func activateDriver(databaseTypeId typeId: String) { @@ -321,6 +337,12 @@ final class PluginManager { activateLazyBundle(at: url) } + func activateInspector(id: String) { + guard inspectorPlugins[id] == nil else { return } + guard let url = lazyInspectorURLs[id] else { return } + activateLazyBundle(at: url) + } + func allLazyExportFormatIds() -> [String] { Array(lazyExportURLs.keys) } @@ -329,7 +351,11 @@ final class PluginManager { Array(lazyImportURLs.keys) } - private func activateLazyBundle(at url: URL) { + func allLazyInspectorIds() -> [String] { + Array(lazyInspectorURLs.keys) + } + + func activateLazyBundle(at url: URL) { guard let bundle = Bundle(url: url) else { return } let bundleId = bundle.bundleIdentifier ?? url.lastPathComponent guard !activatedBundleIds.contains(bundleId) else { return } @@ -368,28 +394,52 @@ final class PluginManager { source: PluginSource ) throws { let infoPlist = bundle.infoDictionary ?? [:] - let pluginKitVersion = infoPlist["TableProPluginKitVersion"] as? Int ?? 0 + let declaredPluginKit = infoPlist["TableProPluginKitVersion"] as? Int + let declaredInspectorKit = infoPlist["TableProInspectorKitVersion"] as? Int - if pluginKitVersion > currentPluginKitVersion { - throw PluginError.incompatibleVersion( - required: pluginKitVersion, - current: currentPluginKitVersion + if declaredPluginKit == nil && declaredInspectorKit == nil { + throw PluginError.pluginOutdated( + pluginVersion: 0, + requiredVersion: currentPluginKitVersion ) } + if let version = declaredPluginKit { + if version > currentPluginKitVersion { + throw PluginError.incompatibleVersion( + required: version, + current: currentPluginKitVersion + ) + } + if version < currentPluginKitVersion { + throw PluginError.pluginOutdated( + pluginVersion: version, + requiredVersion: currentPluginKitVersion + ) + } + } + + if let version = declaredInspectorKit { + if version > currentInspectorKitVersion { + throw PluginError.incompatibleVersion( + required: version, + current: currentInspectorKitVersion + ) + } + if version < currentInspectorKitVersion { + throw PluginError.pluginOutdated( + pluginVersion: version, + requiredVersion: currentInspectorKitVersion + ) + } + } + if let minAppVersion = infoPlist["TableProMinAppVersion"] as? String { let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" if appVersion.compare(minAppVersion, options: .numeric) == .orderedAscending { throw PluginError.appVersionTooOld(minimumRequired: minAppVersion, currentApp: appVersion) } } - - if pluginKitVersion < currentPluginKitVersion { - throw PluginError.pluginOutdated( - pluginVersion: pluginKitVersion, - requiredVersion: currentPluginKitVersion - ) - } } nonisolated private static func validateAndLoadBundle( @@ -433,7 +483,9 @@ final class PluginManager { let bundleId = bundle.bundleIdentifier ?? url.lastPathComponent let rawDriverType = principalClass as? any DriverPlugin.Type + let rawInspectorType = principalClass as? any DocumentInspectorPlugin.Type let pluginKitVersion = bundle.infoDictionary?["TableProPluginKitVersion"] as? Int ?? 0 + let inspectorKitVersion = bundle.infoDictionary?["TableProInspectorKitVersion"] as? Int ?? 0 if rawDriverType != nil, source == .userInstalled, pluginKitVersion != Self.currentPluginKitVersion { assertionFailure( "DriverPlugin '\(bundleId)' has TableProPluginKitVersion \(pluginKitVersion) but current is \(Self.currentPluginKitVersion); ABI mismatch would crash on static property access" @@ -449,6 +501,21 @@ final class PluginManager { )) return nil } + if rawInspectorType != nil, source == .userInstalled, inspectorKitVersion != Self.currentInspectorKitVersion { + assertionFailure( + "DocumentInspectorPlugin '\(bundleId)' has TableProInspectorKitVersion \(inspectorKitVersion) but current is \(Self.currentInspectorKitVersion); ABI mismatch would crash on static property access" + ) + Self.logger.error("Plugin '\(bundleId)' DocumentInspectorPlugin ABI mismatch: plist=\(inspectorKitVersion) current=\(Self.currentInspectorKitVersion). Rejecting to prevent crash.") + rejectedPlugins.append(RejectedPlugin( + url: url, + bundleId: bundleId, + registryId: Self.readRegistryMetadata(for: url)?.pluginId, + name: principalClass.pluginName, + reason: String(localized: "Incompatible plugin version"), + isOutdated: inspectorKitVersion < Self.currentInspectorKitVersion + )) + return nil + } let disabled = disabledPluginIds let driverType = rawDriverType @@ -766,5 +833,10 @@ final class PluginManager { let formatId = importClass.formatId importPlugins = importPlugins.filter { key, _ in key != formatId } } + + if let inspectorClass = entry.bundle.principalClass as? any DocumentInspectorPlugin.Type { + let inspectorId = inspectorClass.inspectorId + inspectorPlugins = inspectorPlugins.filter { key, _ in key != inspectorId } + } } } diff --git a/TablePro/Core/Plugins/PluginManifest.swift b/TablePro/Core/Plugins/PluginManifest.swift index 667406026..d88469baa 100644 --- a/TablePro/Core/Plugins/PluginManifest.swift +++ b/TablePro/Core/Plugins/PluginManifest.swift @@ -10,11 +10,15 @@ internal struct PluginManifest { let providedDatabaseTypeIds: [String] let providedExportFormatIds: [String] let providedImportFormatIds: [String] + let providedInspectorIds: [String] + let providedInspectorFileExtensions: [String] + let providedInspectorUTIs: [String] var supportsLazyLoad: Bool { !providedDatabaseTypeIds.isEmpty || !providedExportFormatIds.isEmpty || !providedImportFormatIds.isEmpty + || !providedInspectorIds.isEmpty } init?(bundle: Bundle) { @@ -24,5 +28,8 @@ internal struct PluginManifest { providedDatabaseTypeIds = info["TableProProvidesDatabaseTypeIds"] as? [String] ?? [] providedExportFormatIds = info["TableProProvidesExportFormatIds"] as? [String] ?? [] providedImportFormatIds = info["TableProProvidesImportFormatIds"] as? [String] ?? [] + providedInspectorIds = info["TableProProvidesInspectorIds"] as? [String] ?? [] + providedInspectorFileExtensions = info["TableProInspectorFileExtensions"] as? [String] ?? [] + providedInspectorUTIs = info["TableProInspectorUTIs"] as? [String] ?? [] } } diff --git a/TablePro/Core/Services/ColumnType.swift b/TablePro/Core/Services/ColumnType.swift index 87af963d7..ff60de31e 100644 --- a/TablePro/Core/Services/ColumnType.swift +++ b/TablePro/Core/Services/ColumnType.swift @@ -173,5 +173,4 @@ enum ColumnType: Equatable { return nil } } - } diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntent.swift b/TablePro/Core/Services/Infrastructure/LaunchIntent.swift index aef5d2f68..8a8d5905d 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchIntent.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchIntent.swift @@ -12,6 +12,7 @@ internal enum LaunchIntent: @unchecked Sendable { case importConnection(ExportableConnection) case openSQLFile(URL) case openDatabaseFile(URL, DatabaseType) + case openInspectorFile(URL) case openConnectionShare(URL) case pairIntegration(PairingRequest) case startMCPServer @@ -26,6 +27,7 @@ internal enum LaunchIntent: @unchecked Sendable { return id case .openDatabaseURL, .openDatabaseFile, + .openInspectorFile, .openSQLFile, .importConnection, .openConnectionShare, diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift index 91c993ddc..189eea214 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift @@ -26,6 +26,10 @@ internal final class LaunchIntentRouter { .openSQLFile: try await TabRouter.shared.route(intent) + case .openInspectorFile(let url): + Self.logger.debug("LaunchIntentRouter.route(.openInspectorFile(\(url.lastPathComponent, privacy: .public)))") + openInspectorDocument(at: url) + case .importConnection(let exportable): WelcomeRouter.shared.routeImport(exportable) @@ -53,6 +57,25 @@ internal final class LaunchIntentRouter { } } + private func openInspectorDocument(at url: URL) { + Self.logger.debug("LaunchIntentRouter.openInspectorDocument - calling NSDocumentController.shared (\(String(describing: Swift.type(of: NSDocumentController.shared)), privacy: .public)).openDocument for \(url.lastPathComponent, privacy: .public)") + NSDocumentController.shared.openDocument(withContentsOf: url, display: true) { document, alreadyOpen, error in + Self.logger.debug("LaunchIntentRouter.openInspectorDocument completion - document=\(document == nil ? "nil" : "present", privacy: .public) alreadyOpen=\(alreadyOpen, privacy: .public) error=\(error?.localizedDescription ?? "nil", privacy: .public)") + if let error { + Self.logger.error("Failed to open inspector document at \(url.lastPathComponent, privacy: .public): \(error.localizedDescription, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Could Not Open File"), + message: error.localizedDescription, + window: NSApp.keyWindow + ) + return + } + if document == nil { + Self.logger.warning("NSDocumentController returned no document for \(url.lastPathComponent, privacy: .public)") + } + } + } + private func installPlugin(_ url: URL) async throws { let entry = try await PluginManager.shared.installPlugin(from: url) Self.logger.info("Installed plugin '\(entry.name, privacy: .public)' from Finder") @@ -68,7 +91,7 @@ internal final class LaunchIntentRouter { title = String(localized: "Plugin Installation Failed") case .openConnection, .openTable, .openQuery, .openDatabaseURL, .openDatabaseFile: title = String(localized: "Connection Failed") - case .openSQLFile: + case .openSQLFile, .openInspectorFile: title = String(localized: "Could Not Open File") case .importConnection, .openConnectionShare, .startMCPServer: title = String(localized: "Action Failed") diff --git a/TablePro/Core/Services/Infrastructure/URLClassifier.swift b/TablePro/Core/Services/Infrastructure/URLClassifier.swift index 114cf13c8..5f204a429 100644 --- a/TablePro/Core/Services/Infrastructure/URLClassifier.swift +++ b/TablePro/Core/Services/Infrastructure/URLClassifier.swift @@ -31,6 +31,9 @@ internal enum URLClassifier { if ext == "sql" { return .success(.openSQLFile(url)) } + if PluginManager.shared.allInspectorFileExtensions.contains(ext) { + return .success(.openInspectorFile(url)) + } if let dbType = PluginManager.shared.allRegisteredFileExtensions[ext] { return .success(.openDatabaseFile(url, dbType)) } diff --git a/TablePro/Core/Sorting/NaturalSortKey.swift b/TablePro/Core/Sorting/NaturalSortKey.swift new file mode 100644 index 000000000..88b2a9e76 --- /dev/null +++ b/TablePro/Core/Sorting/NaturalSortKey.swift @@ -0,0 +1,40 @@ +// +// NaturalSortKey.swift +// TablePro +// + +import Foundation + +func naturalSortKey(_ raw: String) -> String { + let scalars = Array(raw.lowercased().unicodeScalars) + var result = "" + result.reserveCapacity(scalars.count + 8) + var i = 0 + let n = scalars.count + while i < n { + let value = scalars[i].value + if value >= 0x30, value <= 0x39 { + var runEnd = i + while runEnd < n, scalars[runEnd].value >= 0x30, scalars[runEnd].value <= 0x39 { + runEnd += 1 + } + var sigStart = i + while sigStart < runEnd, scalars[sigStart].value == 0x30 { + sigStart += 1 + } + let length = UInt32(runEnd - sigStart) + result.unicodeScalars.append(Unicode.Scalar(UInt8(truncatingIfNeeded: 0x30 + (length / 1_000) % 10))) + result.unicodeScalars.append(Unicode.Scalar(UInt8(truncatingIfNeeded: 0x30 + (length / 100) % 10))) + result.unicodeScalars.append(Unicode.Scalar(UInt8(truncatingIfNeeded: 0x30 + (length / 10) % 10))) + result.unicodeScalars.append(Unicode.Scalar(UInt8(truncatingIfNeeded: 0x30 + length % 10))) + for j in sigStart..com.tablepro.duckdb + + CFBundleTypeExtensions + + csv + + CFBundleTypeName + CSV Document + CFBundleTypeRole + Editor + LSHandlerRank + Default + LSItemContentTypes + + public.comma-separated-values-text + + + + CFBundleTypeExtensions + + tsv + + CFBundleTypeName + TSV Document + CFBundleTypeRole + Editor + LSHandlerRank + Default + LSItemContentTypes + + public.tab-separated-values-text + + UTExportedTypeDeclarations diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index f794ccf4c..9a9b3df1f 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1554,6 +1554,9 @@ }, "%1$@, %2$@" : { "shouldTranslate" : false + }, + "%d columns" : { + }, "%d connected" : { @@ -1723,6 +1726,9 @@ } } } + }, + "%d selected" : { + }, "%d-%d of %@%@ rows" : { "localizations" : { @@ -4292,6 +4298,9 @@ } } } + }, + "Add Column…" : { + }, "Add columns first" : { "extractionState" : "stale", @@ -7763,6 +7772,9 @@ } } } + }, + "Boolean" : { + }, "Border" : { "localizations" : { @@ -9168,6 +9180,9 @@ } } } + }, + "Clear all" : { + }, "Clear All" : { "localizations" : { @@ -10387,7 +10402,6 @@ } }, "Column name" : { - "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -14537,6 +14551,9 @@ } } } + }, + "Date" : { + }, "Date format:" : { "localizations" : { @@ -16340,6 +16357,9 @@ }, "Document" : { + }, + "Document Inspector" : { + }, "Documentation" : { "localizations" : { @@ -16362,6 +16382,9 @@ } } } + }, + "does not equal" : { + }, "Don't Allow" : { "localizations" : { @@ -24745,6 +24768,15 @@ } } } + }, + "Insert Column" : { + + }, + "Insert Column After" : { + + }, + "Insert Column Before" : { + }, "Insert in Editor" : { "localizations" : { @@ -25144,6 +25176,9 @@ } } } + }, + "Integer" : { + }, "Integrations" : { "localizations" : { @@ -30856,6 +30891,9 @@ } } } + }, + "No matching rows" : { + }, "No matching schemas" : { "extractionState" : "stale", @@ -33436,6 +33474,16 @@ } } }, + "Page %d of %d" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Page %1$d of %2$d" + } + } + } + }, "Page Count" : { "localizations" : { "tr" : { @@ -37223,6 +37271,9 @@ } } } + }, + "Real" : { + }, "Reasoning" : { @@ -38203,6 +38254,9 @@ } } } + }, + "Rename Column" : { + }, "Rename Group" : { "localizations" : { @@ -38225,6 +38279,9 @@ } } } + }, + "Rename…" : { + }, "Renew" : { "localizations" : { @@ -38647,6 +38704,9 @@ } } } + }, + "Reset to Inferred" : { + }, "Reset to System Default" : { "localizations" : { @@ -50429,6 +50489,9 @@ } } } + }, + "Updating…" : { + }, "Uptime" : { "localizations" : { diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index b4bd87b5e..cc74d8ae2 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -145,6 +145,10 @@ struct AppMenuCommands: Commands { return nil } + private var keyWindowIsInspector: Bool { + NSApp.keyWindow?.windowController is InspectorWindowController + } + var body: some Commands { // Custom About window + Check for Updates + MCP status CommandGroup(replacing: .appInfo) { @@ -241,22 +245,32 @@ struct AppMenuCommands: Commands { Divider() Button("Save Changes") { - actions?.saveChanges() + if keyWindowIsInspector { + NSApp.sendAction(#selector(InspectorViewController.saveDocument(_:)), to: nil, from: nil) + } else { + actions?.saveChanges() + } } .optionalKeyboardShortcut(shortcut(for: .saveChanges)) - // Match toolbar: also disable when no pending changes — avoids - // a no-op Cmd+S when nothing has been edited. + // Disable only when a connection tab is focused with nothing to + // save. When no SwiftUI content is focused (e.g. a document + // inspector window), stay enabled so the action can route through + // the responder chain. .disabled( - !(actions?.isConnected ?? false) - || actions?.isReadOnly ?? false - || !(actions?.hasPendingChanges ?? false) + keyWindowIsInspector + ? false + : (actions.map { !$0.isConnected || $0.isReadOnly || !$0.hasPendingChanges } ?? false) ) Button(String(localized: "Save As...")) { - actions?.saveFileAs() + if keyWindowIsInspector { + NSApp.sendAction(#selector(InspectorViewController.saveDocumentAs(_:)), to: nil, from: nil) + } else { + actions?.saveFileAs() + } } .optionalKeyboardShortcut(shortcut(for: .saveAs)) - .disabled(!(actions?.isConnected ?? false)) + .disabled(keyWindowIsInspector ? false : (actions.map { !$0.isConnected } ?? false)) Button(actions != nil ? "Close Tab" : "Close") { if let resolved = resolvedCloseTabActions { @@ -420,27 +434,24 @@ struct AppMenuCommands: Commands { // Edit menu - Undo/Redo (smart handling for both text editor and data grid) CommandGroup(replacing: .undoRedo) { Button("Undo") { - // Check if first responder is a text view (SQL editor) - if let firstResponder = NSApp.keyWindow?.firstResponder, - firstResponder is NSTextView || firstResponder is TextView { - // Send undo: (with colon) through responder chain — - // CodeEditTextView.TextView responds to undo: via @objc func undo(_:) + // Inspector windows and text views both handle undo: via the + // AppKit responder chain. Data grid tabs route through actions. + if keyWindowIsInspector || + (NSApp.keyWindow?.firstResponder is NSTextView) || + (NSApp.keyWindow?.firstResponder is TextView) { NSApp.sendAction(#selector(TableProResponderActions.undo(_:)), to: nil, from: nil) } else { - // Data grid undo actions?.undoChange() } } .optionalKeyboardShortcut(shortcut(for: .undo)) Button("Redo") { - // Check if first responder is a text view (SQL editor) - if let firstResponder = NSApp.keyWindow?.firstResponder, - firstResponder is NSTextView || firstResponder is TextView { - // Send redo: (with colon) through responder chain + if keyWindowIsInspector || + (NSApp.keyWindow?.firstResponder is NSTextView) || + (NSApp.keyWindow?.firstResponder is TextView) { NSApp.sendAction(#selector(TableProResponderActions.redo(_:)), to: nil, from: nil) } else { - // Data grid redo actions?.redoChange() } } @@ -455,7 +466,11 @@ struct AppMenuCommands: Commands { Divider() Button(String(localized: "Find...")) { - EditorEventRouter.shared.showFindPanelForKeyWindow() + if keyWindowIsInspector { + NSApp.sendAction(#selector(InspectorViewController.toggleInspectorFilter(_:)), to: nil, from: nil) + } else { + EditorEventRouter.shared.showFindPanelForKeyWindow() + } } .keyboardShortcut("f", modifiers: .command) diff --git a/TablePro/Views/Inspector/InspectorChangeManager.swift b/TablePro/Views/Inspector/InspectorChangeManager.swift new file mode 100644 index 000000000..992460f11 --- /dev/null +++ b/TablePro/Views/Inspector/InspectorChangeManager.swift @@ -0,0 +1,35 @@ +// +// InspectorChangeManager.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +@MainActor +final class InspectorChangeManager: ChangeManaging { + private(set) var reloadVersion: Int = 0 + + var hasChanges: Bool { false } + var canRedo: Bool { false } + var rowChanges: [RowChange] { [] } + var insertedRowIndices: Set { [] } + + func isRowDeleted(_ rowIndex: Int) -> Bool { false } + + func recordCellChange( + rowIndex: Int, + columnIndex: Int, + columnName: String, + oldValue: PluginCellValue, + newValue: PluginCellValue, + originalRow: [PluginCellValue]? + ) {} + + func undoRowDeletion(rowIndex: Int) {} + func undoRowInsertion(rowIndex: Int) {} + + func bumpReload() { + reloadVersion &+= 1 + } +} diff --git a/TablePro/Views/Inspector/InspectorFilterBar.swift b/TablePro/Views/Inspector/InspectorFilterBar.swift new file mode 100644 index 000000000..f20eb97d6 --- /dev/null +++ b/TablePro/Views/Inspector/InspectorFilterBar.swift @@ -0,0 +1,160 @@ +// +// InspectorFilterBar.swift +// TablePro +// + +import SwiftUI + +enum CSVFilterOperator: String, CaseIterable, Identifiable { + case contains + case equals + case notEquals + case startsWith + case endsWith + case isEmpty + case isNotEmpty + + var id: String { rawValue } + + var label: String { + switch self { + case .contains: return String(localized: "contains") + case .equals: return String(localized: "equals") + case .notEquals: return String(localized: "does not equal") + case .startsWith: return String(localized: "starts with") + case .endsWith: return String(localized: "ends with") + case .isEmpty: return String(localized: "is empty") + case .isNotEmpty: return String(localized: "is not empty") + } + } + + var needsValue: Bool { + self != .isEmpty && self != .isNotEmpty + } + + func matches(_ cell: String, value: String) -> Bool { + switch self { + case .contains: return cell.localizedCaseInsensitiveContains(value) + case .equals: return cell.compare(value, options: .caseInsensitive) == .orderedSame + case .notEquals: return cell.compare(value, options: .caseInsensitive) != .orderedSame + case .startsWith: return cell.lowercased().hasPrefix(value.lowercased()) + case .endsWith: return cell.lowercased().hasSuffix(value.lowercased()) + case .isEmpty: return cell.isEmpty + case .isNotEmpty: return !cell.isEmpty + } + } +} + +struct FilterClause: Sendable, Identifiable { + let id: UUID + var column: Int + var op: CSVFilterOperator + var value: String + + static func empty(column: Int = 0) -> FilterClause { + FilterClause(id: UUID(), column: column, op: .contains, value: "") + } +} + +extension FilterClause: Equatable { + static func == (lhs: FilterClause, rhs: FilterClause) -> Bool { + lhs.column == rhs.column && lhs.op == rhs.op && lhs.value == rhs.value + } +} + +struct InspectorFilterBar: View { + @Bindable var state: InspectorViewState + let onChange: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(state.filters) { clause in + clauseRow(for: clause) + } + HStack { + Button { + state.filters.append(FilterClause.empty()) + } label: { + Label(String(localized: "Add filter"), systemImage: "plus.circle") + .font(.caption) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + Spacer() + if state.filters.contains(where: { !$0.value.isEmpty || !$0.op.needsValue }) { + Button { + state.filters = [FilterClause.empty()] + onChange() + } label: { + Text(String(localized: "Clear all")) + .font(.caption) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(nsColor: .windowBackgroundColor)) + } + + private func clauseRow(for clause: FilterClause) -> some View { + let binding = clauseBinding(for: clause) + return HStack(spacing: 8) { + Image(systemName: "line.3.horizontal.decrease") + .font(.caption) + .foregroundStyle(.secondary) + .opacity(state.filters.first?.id == clause.id ? 1 : 0) + + Picker("", selection: binding.column) { + ForEach(Array(state.columnNames.enumerated()), id: \.offset) { index, name in + Text(name).tag(index) + } + } + .labelsHidden() + .frame(maxWidth: 180) + .onChange(of: clause.column) { _, _ in onChange() } + + Picker("", selection: binding.op) { + ForEach(CSVFilterOperator.allCases) { op in + Text(op.label).tag(op) + } + } + .labelsHidden() + .frame(maxWidth: 160) + .onChange(of: clause.op) { _, _ in onChange() } + + if clause.op.needsValue { + TextField(String(localized: "Filter value"), text: binding.value) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 260) + .onChange(of: clause.value) { _, _ in onChange() } + } else { + Color.clear.frame(maxWidth: 260) + } + + Spacer() + + Button { + state.filters.removeAll { $0.id == clause.id } + onChange() + } label: { + Image(systemName: "minus.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(String(localized: "Remove filter")) + } + } + + private func clauseBinding(for clause: FilterClause) -> Binding { + Binding( + get: { state.filters.first(where: { $0.id == clause.id }) ?? clause }, + set: { newValue in + guard let index = state.filters.firstIndex(where: { $0.id == clause.id }) else { return } + state.filters[index] = newValue + } + ) + } +} diff --git a/TablePro/Views/Inspector/InspectorStatusBar.swift b/TablePro/Views/Inspector/InspectorStatusBar.swift new file mode 100644 index 000000000..1c46d8b5e --- /dev/null +++ b/TablePro/Views/Inspector/InspectorStatusBar.swift @@ -0,0 +1,76 @@ +// +// InspectorStatusBar.swift +// TablePro +// + +import SwiftUI + +struct InspectorStatusBar: View { + @Bindable var state: InspectorViewState + let onPreviousPage: () -> Void + let onNextPage: () -> Void + + var body: some View { + HStack(spacing: 10) { + Text(rowSummary) + separator + Text(String(format: String(localized: "%d columns"), state.columnNames.count)) + if !state.selectedRowIndices.isEmpty { + separator + Text(String(format: String(localized: "%d selected"), state.selectedRowIndices.count)) + } + if state.isComputing { + separator + ProgressView() + .controlSize(.small) + Text(String(localized: "Updating…")) + } + Spacer() + if state.pageCount > 1 { + pageControls + } + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Color(nsColor: .windowBackgroundColor)) + } + + private var pageControls: some View { + HStack(spacing: 6) { + Button(action: onPreviousPage) { + Image(systemName: "chevron.left") + } + .buttonStyle(.plain) + .disabled(state.pageOffset == 0) + + Text(String(format: String(localized: "Page %d of %d"), currentPage, state.pageCount)) + + Button(action: onNextPage) { + Image(systemName: "chevron.right") + } + .buttonStyle(.plain) + .disabled(currentPage >= state.pageCount) + } + } + + private var currentPage: Int { + state.pageSize > 0 ? (state.pageOffset / state.pageSize) + 1 : 1 + } + + private var separator: some View { + Text("·").foregroundStyle(.tertiary) + } + + private var rowSummary: String { + if state.visibleRowCount == state.totalRowCount { + return String(format: String(localized: "%d rows"), state.totalRowCount) + } + return String( + format: String(localized: "%d of %d rows"), + state.visibleRowCount, + state.totalRowCount + ) + } +} diff --git a/TablePro/Views/Inspector/InspectorViewController.swift b/TablePro/Views/Inspector/InspectorViewController.swift new file mode 100644 index 000000000..8629a23ba --- /dev/null +++ b/TablePro/Views/Inspector/InspectorViewController.swift @@ -0,0 +1,760 @@ +// +// InspectorViewController.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProPluginKit + +@MainActor +final class InspectorViewController: NSViewController, NSUserInterfaceValidations { + private weak var nsDocument: NSDocument? + private weak var inspectorDocument: (any InspectorDocument)? + private let changeManager: AnyChangeManager + private let inspectorChangeManager: InspectorChangeManager + private let state: InspectorViewState + private let gridDelegate: InspectorGridDelegate + + private var displayToStore: [Int] = [] + private var displayIndices: [Int]? + private var isApplyingGridCellEdit = false + private var gridReloadScheduled = false + private var filterDebounceTask: Task? + private var recomputeTask: Task? + private var lastFilterClauses: [FilterClause] = [] + private var lastSortSpecs: [SortSpec] = [] + private var pendingPostRefresh: PostRefreshAction? + + private enum PostRefreshAction { + case selectClamped(displayRow: Int) + case focusStoreIndex(Int) + } + + init(nsDocument: NSDocument, inspectorDocument: any InspectorDocument) { + self.nsDocument = nsDocument + self.inspectorDocument = inspectorDocument + self.inspectorChangeManager = InspectorChangeManager() + self.changeManager = AnyChangeManager(inspectorChangeManager) + self.state = InspectorViewState() + self.gridDelegate = InspectorGridDelegate() + super.init(nibName: nil, bundle: nil) + gridDelegate.owner = self + state.pageSize = max(1, AppSettingsManager.shared.dataGrid.defaultPageSize) + inspectorDocument.onChange = { [weak self] in + guard let self else { return } + if self.isApplyingGridCellEdit, self.displayIndices == nil { + return + } + self.recomputeDisplay() + } + NotificationCenter.default.addObserver( + forName: .inspectorDocumentDidRevert, + object: nsDocument, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.recomputeDisplay() + } + } + recomputeDisplay() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) not supported") + } + + override func loadView() { + let rootView = InspectorRootView( + state: state, + changeManager: changeManager, + delegate: gridDelegate, + onFilterChanged: { [weak self] in self?.scheduleFilterRecompute() }, + onPreviousPage: { [weak self] in self?.goToPage(offsetBy: -1) }, + onNextPage: { [weak self] in self?.goToPage(offsetBy: 1) } + ) + let hosting = NSHostingView(rootView: rootView) + hosting.translatesAutoresizingMaskIntoConstraints = false + let container = NSView() + container.addSubview(hosting) + NSLayoutConstraint.activate([ + hosting.leadingAnchor.constraint(equalTo: container.leadingAnchor), + hosting.trailingAnchor.constraint(equalTo: container.trailingAnchor), + hosting.topAnchor.constraint(equalTo: container.topAnchor), + hosting.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + view = container + } + + // MARK: - Grid delegate handlers + + fileprivate func handleCellEdit(displayRow: Int, column: Int, newValue: String?) { + guard displayRow >= 0, displayRow < displayToStore.count else { return } + let storeRow = displayToStore[displayRow] + isApplyingGridCellEdit = true + defer { isApplyingGridCellEdit = false } + inspectorDocument?.setCell(row: storeRow, column: column, to: newValue ?? "") + } + + fileprivate func handleAddRow() { + guard let inspectorDocument else { return } + let newRowStoreIndex = inspectorDocument.rowCount + pendingPostRefresh = .focusStoreIndex(newRowStoreIndex) + inspectorDocument.appendRow() + } + + fileprivate func handleCopyRows(_ displayIndices: Set) { + guard let inspectorDocument else { return } + let columnCount = inspectorDocument.columnNames.count + let sortedDisplay = displayIndices.sorted() + var lines: [String] = [] + lines.reserveCapacity(sortedDisplay.count) + for displayRow in sortedDisplay { + guard displayRow >= 0, displayRow < displayToStore.count else { continue } + let storeRow = displayToStore[displayRow] + let cells = (0.. String in + inspectorDocument.value(row: storeRow, column: column) + .replacingOccurrences(of: "\t", with: " ") + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\n", with: " ") + } + lines.append(cells.joined(separator: "\t")) + } + guard !lines.isEmpty else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(lines.joined(separator: "\n"), forType: .string) + } + + fileprivate func handlePasteRows() { + guard let inspectorDocument else { return } + guard let raw = NSPasteboard.general.string(forType: .string), !raw.isEmpty else { return } + let lines = raw.split(omittingEmptySubsequences: false, whereSeparator: { $0 == "\n" || $0 == "\r\n" }) + var rows: [[String]] = [] + rows.reserveCapacity(lines.count) + for line in lines { + let fields = line.split(omittingEmptySubsequences: false, whereSeparator: { $0 == "\t" }) + .map { String($0) } + if fields.count == 1, fields[0].isEmpty { continue } + rows.append(fields) + } + guard !rows.isEmpty else { return } + + let undoManager = nsDocument?.undoManager + undoManager?.beginUndoGrouping() + undoManager?.setActionName(String(localized: "Paste")) + for row in rows { + let newRowIndex = inspectorDocument.rowCount + inspectorDocument.appendRow() + for (column, value) in row.enumerated() { + inspectorDocument.setCell(row: newRowIndex, column: column, to: value) + } + } + undoManager?.endUndoGrouping() + } + + fileprivate func handleDeleteRows(_ displayIndexSet: Set) { + guard let inspectorDocument else { return } + let sortedDisplay = displayIndexSet.sorted() + let storeIndices = sortedDisplay.compactMap { index -> Int? in + guard index >= 0, index < displayToStore.count else { return nil } + return displayToStore[index] + } + guard !storeIndices.isEmpty else { return } + pendingPostRefresh = .selectClamped(displayRow: sortedDisplay.first ?? 0) + inspectorDocument.removeRows(at: IndexSet(storeIndices)) + } + + fileprivate func handleSortChanged(_ newState: SortState) { + state.sortState = newState + recomputeDisplay() + } + + fileprivate func handleUndo() { + nsDocument?.undoManager?.undo() + } + + fileprivate func handleRedo() { + nsDocument?.undoManager?.redo() + } + + private func goToPage(offsetBy delta: Int) { + let newOffset = state.pageOffset + delta * state.pageSize + guard newOffset >= 0, newOffset < max(state.visibleRowCount, 1) else { return } + state.pageOffset = newOffset + state.selectedRowIndices = [] + refreshVisiblePage() + } + + private func applyViewIdentity(filter: [FilterClause], sort: [SortSpec]) { + if filter != lastFilterClauses || sort != lastSortSpecs { + state.selectedRowIndices = [] + lastFilterClauses = filter + lastSortSpecs = sort + } + } + + // MARK: - Responder-chain actions + + @objc func undo(_ sender: Any?) { handleUndo() } + @objc func redo(_ sender: Any?) { handleRedo() } + @objc func saveDocument(_ sender: Any?) { nsDocument?.save(sender) } + @objc func saveDocumentAs(_ sender: Any?) { nsDocument?.saveAs(sender) } + @objc func inspectorAddRow(_ sender: Any?) { handleAddRow() } + @objc func inspectorDeleteSelectedRows(_ sender: Any?) { + handleDeleteRows(state.selectedRowIndices) + } + + @objc func inspectorAddColumn(_ sender: Any?) { + promptForColumnName(title: String(localized: "Add Column"), initial: "") { [weak self] name in + guard let self, let name, !name.isEmpty else { return } + self.inspectorDocument?.appendColumn(name: name) + if self.state.columnLayout.columnOrder != nil { + self.state.columnLayout.columnOrder?.append(name) + } + } + } + + @objc func inspectorRenameColumn(_ sender: Any?) { + guard let menuItem = sender as? NSMenuItem, + let inspector = inspectorDocument, + menuItem.tag >= 0, menuItem.tag < inspector.columnNames.count else { return } + let column = menuItem.tag + let current = inspector.columnNames[column] + promptForColumnName(title: String(localized: "Rename Column"), initial: current) { [weak self] name in + guard let self, let name, !name.isEmpty, name != current else { return } + self.inspectorDocument?.renameColumn(at: column, to: name) + self.renameLayoutKey(from: current, to: name) + } + } + + @objc func inspectorInsertColumnBefore(_ sender: Any?) { + guard let menuItem = sender as? NSMenuItem, + let inspector = inspectorDocument, + menuItem.tag >= 0, menuItem.tag < inspector.columnNames.count else { return } + let anchorIndex = menuItem.tag + let anchorName = inspector.columnNames[anchorIndex] + promptForColumnName(title: String(localized: "Insert Column"), initial: "") { [weak self] name in + guard let self, let name, !name.isEmpty else { return } + self.inspectorDocument?.insertColumn(at: anchorIndex, name: name) + self.insertLayoutKey(name, relativeTo: anchorName, after: false) + } + } + + @objc func inspectorInsertColumnAfter(_ sender: Any?) { + guard let menuItem = sender as? NSMenuItem, + let inspector = inspectorDocument, + menuItem.tag >= 0, menuItem.tag < inspector.columnNames.count else { return } + let anchorIndex = menuItem.tag + let anchorName = inspector.columnNames[anchorIndex] + promptForColumnName(title: String(localized: "Insert Column"), initial: "") { [weak self] name in + guard let self, let name, !name.isEmpty else { return } + self.inspectorDocument?.insertColumn(at: anchorIndex + 1, name: name) + self.insertLayoutKey(name, relativeTo: anchorName, after: true) + } + } + + @objc func inspectorDeleteColumn(_ sender: Any?) { + guard let menuItem = sender as? NSMenuItem, + let inspector = inspectorDocument, + menuItem.tag >= 0, menuItem.tag < inspector.columnNames.count else { return } + let name = inspector.columnNames[menuItem.tag] + inspector.removeColumn(at: menuItem.tag) + removeLayoutKey(name) + } + + @objc func inspectorSetColumnType(_ sender: Any?) { + guard let menuItem = sender as? NSMenuItem, + let assignment = menuItem.representedObject as? ColumnTypeAssignment else { return } + inspectorDocument?.setTypeOverride(assignment.type, forColumn: assignment.column) + } + + private func renameLayoutKey(from oldName: String, to newName: String) { + if state.columnLayout.columnOrder != nil { + state.columnLayout.columnOrder = state.columnLayout.columnOrder?.map { $0 == oldName ? newName : $0 } + } + if let width = state.columnLayout.columnWidths.removeValue(forKey: oldName) { + state.columnLayout.columnWidths[newName] = width + } + if state.columnLayout.hiddenColumns.remove(oldName) != nil { + state.columnLayout.hiddenColumns.insert(newName) + } + } + + private func removeLayoutKey(_ name: String) { + if state.columnLayout.columnOrder != nil { + state.columnLayout.columnOrder = state.columnLayout.columnOrder?.filter { $0 != name } + } + state.columnLayout.columnWidths.removeValue(forKey: name) + state.columnLayout.hiddenColumns.remove(name) + } + + private func insertLayoutKey(_ name: String, relativeTo anchor: String, after: Bool) { + guard var order = state.columnLayout.columnOrder, + let anchorPos = order.firstIndex(of: anchor) else { return } + order.insert(name, at: after ? anchorPos + 1 : anchorPos) + state.columnLayout.columnOrder = order + } + + private func promptForColumnName( + title: String, + initial: String, + completion: @escaping @MainActor (String?) -> Void + ) { + guard let window = view.window else { + completion(nil) + return + } + let alert = NSAlert() + alert.messageText = title + alert.informativeText = String(localized: "Column name") + alert.addButton(withTitle: String(localized: "OK")) + alert.addButton(withTitle: String(localized: "Cancel")) + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24)) + textField.stringValue = initial + textField.usesSingleLineMode = true + alert.accessoryView = textField + alert.beginSheetModal(for: window) { response in + let trimmed = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + completion(response == .alertFirstButtonReturn ? trimmed : nil) + } + DispatchQueue.main.async { + alert.window.makeFirstResponder(textField) + } + } + + @objc func toggleInspectorFilter(_ sender: Any?) { + let wasActive = isFilterActive + state.isFilterVisible.toggle() + if state.isFilterVisible, state.filters.isEmpty { + state.filters = [FilterClause.empty()] + } + if wasActive != isFilterActive { + recomputeDisplay() + } + } + + func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { + switch item.action { + case #selector(undo(_:)): + return nsDocument?.undoManager?.canUndo ?? false + case #selector(redo(_:)): + return nsDocument?.undoManager?.canRedo ?? false + case #selector(saveDocument(_:)), #selector(saveDocumentAs(_:)), + #selector(toggleInspectorFilter(_:)), #selector(inspectorAddRow(_:)): + return nsDocument != nil + case #selector(inspectorDeleteSelectedRows(_:)): + return !state.selectedRowIndices.isEmpty + default: + return true + } + } + + // MARK: - Display computation + + private var isFilterActive: Bool { + state.isFilterVisible && !activeFilters.isEmpty + } + + private var activeFilters: [FilterClause] { + let columnCount = state.columnNames.count + return state.filters.filter { clause in + guard clause.column >= 0, clause.column < columnCount else { return false } + return clause.op.needsValue ? !clause.value.isEmpty : true + } + } + + private func scheduleFilterRecompute() { + filterDebounceTask?.cancel() + filterDebounceTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + self?.recomputeDisplay() + } + } + + private func recomputeDisplay() { + recomputeTask?.cancel() + guard let inspectorDocument else { + state.isComputing = false + displayIndices = nil + applyViewIdentity(filter: [], sort: []) + refreshVisiblePage() + return + } + let columnCount = inspectorDocument.columnNames.count + let filters = activeFilters + let sortSpecs: [SortSpec] = state.sortState.columns.compactMap { column -> SortSpec? in + guard column.columnIndex >= 0, column.columnIndex < columnCount else { return nil } + return SortSpec( + column: column.columnIndex, + ascending: column.direction == .ascending, + numeric: Self.isNumeric(inspectorDocument.displayedType(forColumn: column.columnIndex)) + ) + } + let filterActive = !filters.isEmpty + let sortActive = !sortSpecs.isEmpty + + guard filterActive || sortActive else { + state.isComputing = false + displayIndices = nil + applyViewIdentity(filter: filters, sort: sortSpecs) + refreshVisiblePage() + return + } + + applyViewIdentity(filter: filters, sort: sortSpecs) + let snapshot = inspectorDocument.snapshot() + state.isComputing = true + + recomputeTask = Task.detached(priority: .userInitiated) { [weak self] in + let indices = Self.computeDisplayIndices( + snapshot: snapshot, + filters: filters, + sorts: sortSpecs + ) + if Task.isCancelled { return } + await MainActor.run { + guard !Task.isCancelled, let self else { return } + self.state.isComputing = false + self.displayIndices = indices + self.refreshVisiblePage() + } + } + } + + nonisolated private static func computeDisplayIndices( + snapshot: any InspectorDataSnapshot, + filters: [FilterClause], + sorts: [SortSpec] + ) -> [Int] { + let total = snapshot.rowCount + var indices: [Int] + if !filters.isEmpty { + indices = [] + indices.reserveCapacity(total) + var index = 0 + while index < total { + if index & 0x1FFF == 0, Task.isCancelled { return [] } + var allMatch = true + for clause in filters { + let cell = snapshot.field(at: index, column: clause.column) + if !clause.op.matches(cell, value: clause.value) { + allMatch = false + break + } + } + if allMatch { + indices.append(index) + } + index += 1 + } + } else { + indices = Array(0.. ComparisonResult { + switch (lhs, rhs) { + case let (.double(a), .double(b)): + return a < b ? .orderedAscending : (a > b ? .orderedDescending : .orderedSame) + case let (.text(a), .text(b)): + return a < b ? .orderedAscending : (a > b ? .orderedDescending : .orderedSame) + default: + return .orderedSame + } + } + + + private func refreshVisiblePage() { + guard let inspectorDocument else { return } + let columnNames = inspectorDocument.columnNames + let columnCount = columnNames.count + let visibleTotal = displayIndices?.count ?? inspectorDocument.rowCount + let pageSize = max(state.pageSize, 1) + + if case .focusStoreIndex(let target) = pendingPostRefresh, + let displayRow = locateDisplayRow(forStoreIndex: target) { + let desiredPage = (displayRow / pageSize) * pageSize + if state.pageOffset != desiredPage { + state.pageOffset = desiredPage + } + } + + let maxOffset = visibleTotal == 0 ? 0 : ((visibleTotal - 1) / pageSize) * pageSize + state.pageOffset = min(max(state.pageOffset, 0), maxOffset) + let start = state.pageOffset + let end = min(start + pageSize, visibleTotal) + + var storeIndices: [Int] = [] + var rows = ContiguousArray() + storeIndices.reserveCapacity(end - start) + rows.reserveCapacity(end - start) + + if let displayIndices { + for displayRow in start..= 0 && withinPage < state.tableRows.rows.count) ? withinPage : nil + } + guard let targetIndex else { + state.selectedRowIndices = [] + return + } + state.selectedRowIndices = [targetIndex] + DispatchQueue.main.async { [weak self] in + guard let coordinator = self?.gridDelegate.coordinator, + let tableView = coordinator.tableView else { return } + coordinator.isSyncingSelection = true + tableView.selectRowIndexes(IndexSet(integer: targetIndex), byExtendingSelection: false) + coordinator.isSyncingSelection = false + tableView.scrollRowToVisible(targetIndex) + tableView.window?.makeFirstResponder(tableView) + } + } + + private func locateDisplayRow(forStoreIndex storeIndex: Int) -> Int? { + if let displayIndices { + return displayIndices.firstIndex(of: storeIndex) + } + let total = inspectorDocument?.rowCount ?? 0 + guard storeIndex >= 0, storeIndex < total else { return nil } + return storeIndex + } + + private func scheduleGridReload() { + guard !gridReloadScheduled else { return } + gridReloadScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.gridReloadScheduled = false + self.gridDelegate.coordinator?.applyDelta(.fullReplace) + } + } + + private static func isNumeric(_ type: InspectorColumnType) -> Bool { + type == .integer || type == .real + } + + private static func columnType(for inferred: InspectorColumnType) -> ColumnType { + switch inferred { + case .integer: return .integer(rawType: "INTEGER") + case .real: return .decimal(rawType: "REAL") + case .boolean: return .boolean(rawType: "BOOLEAN") + case .date: return .date(rawType: "DATE") + case .text: return .text(rawType: "TEXT") + } + } +} + +private struct SortSpec: Sendable, Equatable { + let column: Int + let ascending: Bool + let numeric: Bool +} + +private enum SortKey: Sendable { + case double(Double) + case text(String) +} + +@MainActor +@Observable +final class InspectorViewState { + var tableRows = TableRows() + var selectedRowIndices: Set = [] + var sortState = SortState() + var columnLayout = ColumnLayoutState() + var columnNames: [String] = [] + var totalRowCount: Int = 0 + var visibleRowCount: Int = 0 + var pageOffset: Int = 0 + var pageSize: Int = 1_000 + var pageCount: Int = 1 + var isComputing: Bool = false + var isFilterVisible: Bool = false + var filters: [FilterClause] = [] +} + +@MainActor +private final class InspectorGridDelegate: DataGridViewDelegate { + weak var owner: InspectorViewController? + weak var coordinator: TableViewCoordinator? + + func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { + coordinator = tableViewCoordinator + } + + func dataGridDidEditCell(row: Int, column: Int, newValue: String?) { + owner?.handleCellEdit(displayRow: row, column: column, newValue: newValue) + } + + func dataGridDeleteRows(_ indices: Set) { + owner?.handleDeleteRows(indices) + } + + func dataGridAddRow() { + owner?.handleAddRow() + } + + func dataGridCopyRows(_ indices: Set) { + owner?.handleCopyRows(indices) + } + + func dataGridPasteRows() { + owner?.handlePasteRows() + } + + func dataGridSortStateChanged(_ state: SortState) { + owner?.handleSortChanged(state) + } + + func dataGridUndo() { + owner?.handleUndo() + } + + func dataGridRedo() { + owner?.handleRedo() + } +} + +private struct InspectorRootView: View { + @Bindable var state: InspectorViewState + let changeManager: AnyChangeManager + let delegate: any DataGridViewDelegate + let onFilterChanged: () -> Void + let onPreviousPage: () -> Void + let onNextPage: () -> Void + + var body: some View { + VStack(spacing: 0) { + if state.isFilterVisible { + InspectorFilterBar(state: state, onChange: onFilterChanged) + Divider() + } + ZStack { + DataGridView( + tableRowsProvider: { state.tableRows }, + tableRowsMutator: { mutate in mutate(&state.tableRows) }, + paginationOffsetProvider: { state.pageOffset }, + changeManager: changeManager, + isEditable: true, + configuration: configuration, + delegate: delegate, + selectedRowIndices: $state.selectedRowIndices, + sortState: $state.sortState, + columnLayout: $state.columnLayout + ) + if state.tableRows.rows.isEmpty, !state.isComputing { + emptyStateView + } + } + Divider() + InspectorStatusBar( + state: state, + onPreviousPage: onPreviousPage, + onNextPage: onNextPage + ) + } + } + + private var emptyStateView: some View { + VStack(spacing: 8) { + Image(systemName: state.totalRowCount == 0 ? "doc" : "line.3.horizontal.decrease.circle") + .font(.system(size: 36)) + .foregroundStyle(.tertiary) + Text(state.totalRowCount == 0 + ? String(localized: "No rows") + : String(localized: "No matching rows")) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + } + + private var configuration: DataGridConfiguration { + var config = DataGridConfiguration() + config.showRowNumbers = true + return config + } +} diff --git a/TablePro/Views/Inspector/InspectorWindowController.swift b/TablePro/Views/Inspector/InspectorWindowController.swift new file mode 100644 index 000000000..6a2ca4dfc --- /dev/null +++ b/TablePro/Views/Inspector/InspectorWindowController.swift @@ -0,0 +1,256 @@ +// +// InspectorWindowController.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +extension NSToolbarItem.Identifier { + static let inspectorAddRow = NSToolbarItem.Identifier("com.TablePro.inspector.addRow") + static let inspectorDeleteRows = NSToolbarItem.Identifier("com.TablePro.inspector.deleteRows") + static let inspectorToggleFilter = NSToolbarItem.Identifier("com.TablePro.inspector.toggleFilter") + static let inspectorColumns = NSToolbarItem.Identifier("com.TablePro.inspector.columns") +} + +@MainActor +final class ColumnTypeAssignment: NSObject { + let column: Int + let type: InspectorColumnType? + + init(column: Int, type: InspectorColumnType?) { + self.column = column + self.type = type + super.init() + } +} + +@MainActor +final class InspectorWindowController: NSWindowController, NSWindowDelegate, NSToolbarDelegate, NSMenuDelegate { + private weak var documentRef: NSDocument? + + init(nsDocument: NSDocument, inspectorDocument: any InspectorDocument) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 900, height: 600), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.minSize = NSSize(width: 480, height: 320) + window.tabbingIdentifier = "com.TablePro.CSVDocument" + window.tabbingMode = .preferred + window.titleVisibility = .visible + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("main-inspector") + + super.init(window: window) + documentRef = nsDocument + shouldCloseDocument = true + window.delegate = self + + let viewController = InspectorViewController(nsDocument: nsDocument, inspectorDocument: inspectorDocument) + window.contentViewController = viewController + window.setContentSize(NSSize(width: 1_000, height: 640)) + window.center() + if let url = nsDocument.fileURL { + windowFrameAutosaveName = "com.TablePro.CSVInspector.\(url.absoluteString)" + } else { + windowFrameAutosaveName = "com.TablePro.CSVInspector.untitled" + } + + let toolbar = NSToolbar(identifier: "com.TablePro.CSVInspectorToolbar") + toolbar.delegate = self + toolbar.displayMode = .iconOnly + toolbar.allowsUserCustomization = false + toolbar.autosavesConfiguration = false + window.toolbar = toolbar + window.toolbarStyle = .unified + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) not supported") + } + + func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? { + documentRef?.undoManager + } + + func toolbar( + _ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool + ) -> NSToolbarItem? { + switch itemIdentifier { + case .inspectorAddRow: + return makeItem( + identifier: itemIdentifier, + label: String(localized: "Add Row"), + symbol: "plus", + action: #selector(InspectorViewController.inspectorAddRow(_:)) + ) + case .inspectorDeleteRows: + return makeItem( + identifier: itemIdentifier, + label: String(localized: "Delete"), + symbol: "minus", + action: #selector(InspectorViewController.inspectorDeleteSelectedRows(_:)) + ) + case .inspectorToggleFilter: + return makeItem( + identifier: itemIdentifier, + label: String(localized: "Filter"), + symbol: "line.3.horizontal.decrease.circle", + action: #selector(InspectorViewController.toggleInspectorFilter(_:)) + ) + case .inspectorColumns: + let item = NSMenuToolbarItem(itemIdentifier: itemIdentifier) + item.label = String(localized: "Columns") + item.paletteLabel = String(localized: "Columns") + item.toolTip = String(localized: "Columns") + item.image = NSImage(systemSymbolName: "tablecells", accessibilityDescription: String(localized: "Columns")) + item.showsIndicator = true + let menu = NSMenu() + menu.delegate = self + item.menu = menu + return item + default: + return nil + } + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.inspectorAddRow, .inspectorDeleteRows, .inspectorColumns, .flexibleSpace, .inspectorToggleFilter] + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [.inspectorAddRow, .inspectorDeleteRows, .inspectorColumns, .inspectorToggleFilter, .flexibleSpace, .space] + } + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + let addItem = NSMenuItem( + title: String(localized: "Add Column…"), + action: #selector(InspectorViewController.inspectorAddColumn(_:)), + keyEquivalent: "" + ) + addItem.target = nil + menu.addItem(addItem) + + guard let inspector = documentRef as? (any InspectorDocument) else { return } + let columns = inspector.columnNames + guard !columns.isEmpty else { return } + menu.addItem(.separator()) + for (index, name) in columns.enumerated() { + let item = NSMenuItem(title: name, action: nil, keyEquivalent: "") + let type = inspector.displayedType(forColumn: index) + item.image = NSImage(systemSymbolName: Self.typeSymbol(type), accessibilityDescription: nil) + item.submenu = makeColumnSubmenu(columnIndex: index, currentType: type) + menu.addItem(item) + } + } + + private func makeColumnSubmenu(columnIndex: Int, currentType: InspectorColumnType) -> NSMenu { + let submenu = NSMenu() + let rename = NSMenuItem( + title: String(localized: "Rename…"), + action: #selector(InspectorViewController.inspectorRenameColumn(_:)), + keyEquivalent: "" + ) + rename.tag = columnIndex + submenu.addItem(rename) + + let insertBefore = NSMenuItem( + title: String(localized: "Insert Column Before"), + action: #selector(InspectorViewController.inspectorInsertColumnBefore(_:)), + keyEquivalent: "" + ) + insertBefore.tag = columnIndex + submenu.addItem(insertBefore) + + let insertAfter = NSMenuItem( + title: String(localized: "Insert Column After"), + action: #selector(InspectorViewController.inspectorInsertColumnAfter(_:)), + keyEquivalent: "" + ) + insertAfter.tag = columnIndex + submenu.addItem(insertAfter) + + submenu.addItem(.separator()) + + let typeItem = NSMenuItem(title: String(localized: "Type"), action: nil, keyEquivalent: "") + typeItem.submenu = makeTypeSubmenu(columnIndex: columnIndex, currentType: currentType) + submenu.addItem(typeItem) + + submenu.addItem(.separator()) + + let delete = NSMenuItem( + title: String(localized: "Delete"), + action: #selector(InspectorViewController.inspectorDeleteColumn(_:)), + keyEquivalent: "" + ) + delete.tag = columnIndex + submenu.addItem(delete) + return submenu + } + + private func makeTypeSubmenu(columnIndex: Int, currentType: InspectorColumnType) -> NSMenu { + let submenu = NSMenu() + for type in InspectorColumnType.allCases { + let item = NSMenuItem( + title: Self.typeLabel(type), + action: #selector(InspectorViewController.inspectorSetColumnType(_:)), + keyEquivalent: "" + ) + item.representedObject = ColumnTypeAssignment(column: columnIndex, type: type) + item.state = (type == currentType) ? .on : .off + submenu.addItem(item) + } + submenu.addItem(.separator()) + let reset = NSMenuItem( + title: String(localized: "Reset to Inferred"), + action: #selector(InspectorViewController.inspectorSetColumnType(_:)), + keyEquivalent: "" + ) + reset.representedObject = ColumnTypeAssignment(column: columnIndex, type: nil) + submenu.addItem(reset) + return submenu + } + + private static func typeSymbol(_ type: InspectorColumnType) -> String { + switch type { + case .text: return "textformat" + case .integer: return "number" + case .real: return "number.square" + case .boolean: return "checkmark.square" + case .date: return "calendar" + } + } + + private static func typeLabel(_ type: InspectorColumnType) -> String { + switch type { + case .text: return String(localized: "Text") + case .integer: return String(localized: "Integer") + case .real: return String(localized: "Real") + case .boolean: return String(localized: "Boolean") + case .date: return String(localized: "Date") + } + } + + private func makeItem( + identifier: NSToolbarItem.Identifier, + label: String, + symbol: String, + action: Selector + ) -> NSToolbarItem { + let item = NSToolbarItem(itemIdentifier: identifier) + item.label = label + item.paletteLabel = label + item.toolTip = label + item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) + item.action = action + item.target = nil + item.isBordered = true + return item + } +} diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index 67c4a674b..2f4f80c07 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -4,14 +4,18 @@ // import AppKit +import os import TableProPluginKit +private let cellCommitLogger = Logger(subsystem: "com.TablePro", category: "CSVInspector") + extension TableViewCoordinator { func commitCellEdit(row: Int, columnIndex: Int, newValue: String?) { commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: PluginCellValue.fromOptional(newValue)) } func commitTypedCellEdit(row: Int, columnIndex: Int, newValue typedNewValue: PluginCellValue) { + cellCommitLogger.debug("commitTypedCellEdit(row: \(row, privacy: .public), columnIndex: \(columnIndex, privacy: .public)) isCommitting=\(self.isCommittingCellEdit, privacy: .public) delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)") guard !isCommittingCellEdit else { return } guard let tableView else { return } let tableRows = tableRowsProvider() @@ -19,7 +23,10 @@ extension TableViewCoordinator { guard let displayRowValues = displayRow(at: row) else { return } guard columnIndex < displayRowValues.values.count else { return } let oldValue = displayRowValues.values[columnIndex] - guard oldValue != typedNewValue else { return } + guard oldValue != typedNewValue else { + cellCommitLogger.debug("commitTypedCellEdit - value unchanged, guard returned") + return + } isCommittingCellEdit = true defer { isCommittingCellEdit = false } @@ -42,6 +49,7 @@ extension TableViewCoordinator { delta = tableRows.edit(row: storageRow, column: columnIndex, value: typedNewValue) } } + cellCommitLogger.debug("commitTypedCellEdit - about to call delegate.dataGridDidEditCell, delegate=\(self.delegate == nil ? "nil" : "present", privacy: .public)") delegate?.dataGridDidEditCell(row: row, column: columnIndex, newValue: typedNewValue.asText) invalidateDisplayCache() visualIndex.updateRow(row, from: changeManager, sortedIDs: sortedIDs) diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 3e9952c08..1d8d4eaab 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -451,6 +451,7 @@ private extension PluginCapability { case .databaseDriver: String(localized: "Database Driver") case .exportFormat: String(localized: "Export Format") case .importFormat: String(localized: "Import Format") + case .documentInspector: String(localized: "Document Inspector") } } } diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index e1133705e..d5c248dba 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -1880,6 +1880,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = TableProWidgetExtension.entitlements; CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 14; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TableProWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TableProWidget; @@ -1911,6 +1912,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = TableProWidgetExtension.entitlements; CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 14; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = TableProWidget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = TableProWidget; diff --git a/TableProTests/Core/NaturalSortKeyTests.swift b/TableProTests/Core/NaturalSortKeyTests.swift new file mode 100644 index 000000000..759c47b9c --- /dev/null +++ b/TableProTests/Core/NaturalSortKeyTests.swift @@ -0,0 +1,97 @@ +// +// NaturalSortKeyTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("naturalSortKey") +struct NaturalSortKeyTests { + @Test("Pure-digit values sort by magnitude, not lexically") + func pureDigitsSortByMagnitude() { + let values = ["2", "10", "100", "11"] + let sorted = values.map(naturalSortKey).enumerated() + .sorted { $0.element < $1.element } + .map { values[$0.offset] } + #expect(sorted == ["2", "10", "11", "100"]) + } + + @Test("Embedded numbers in text sort naturally") + func embeddedNumbersInText() { + let values = ["Item 10", "Item 2", "Item 100", "Item 20"] + let sorted = values.map(naturalSortKey).enumerated() + .sorted { $0.element < $1.element } + .map { values[$0.offset] } + #expect(sorted == ["Item 2", "Item 10", "Item 20", "Item 100"]) + } + + @Test("Comparison is case-insensitive") + func caseInsensitive() { + #expect(naturalSortKey("ABC") == naturalSortKey("abc")) + #expect(naturalSortKey("Foo") == naturalSortKey("foo")) + } + + @Test("Leading zeros are stripped - '007' and '7' produce same key") + func leadingZerosStripped() { + #expect(naturalSortKey("007") == naturalSortKey("7")) + #expect(naturalSortKey("00042") == naturalSortKey("42")) + } + + @Test("'0' and any non-zero number produce different keys") + func zeroOrdersBeforePositives() { + #expect(naturalSortKey("0") < naturalSortKey("1")) + #expect(naturalSortKey("0") < naturalSortKey("5")) + } + + @Test("Empty string produces empty key") + func emptyKey() { + #expect(naturalSortKey("") == "") + } + + @Test("Pure number sorts before text starting with letter") + func numberBeforeLetter() { + #expect(naturalSortKey("5") < naturalSortKey("abc")) + } + + @Test("Mixed runs sort by leading digit run when prefix equal") + func mixedRuns() { + #expect(naturalSortKey("file9.txt") < naturalSortKey("file10.txt")) + #expect(naturalSortKey("v1.2.3") < naturalSortKey("v1.10.0")) + } +} + +@Suite("FilterClause Equatable") +struct FilterClauseEquatableTests { + @Test("Same content, different id compares equal (spec equality)") + func differentIdSameContent() { + let a = FilterClause(id: UUID(), column: 1, op: .contains, value: "x") + let b = FilterClause(id: UUID(), column: 1, op: .contains, value: "x") + #expect(a == b) + } + + @Test("Different column compares unequal even with same id") + func differentColumn() { + let id = UUID() + let a = FilterClause(id: id, column: 1, op: .contains, value: "x") + let b = FilterClause(id: id, column: 2, op: .contains, value: "x") + #expect(a != b) + } + + @Test("Different operator compares unequal") + func differentOperator() { + let id = UUID() + let a = FilterClause(id: id, column: 1, op: .contains, value: "x") + let b = FilterClause(id: id, column: 1, op: .equals, value: "x") + #expect(a != b) + } + + @Test("Different value compares unequal") + func differentValue() { + let id = UUID() + let a = FilterClause(id: id, column: 1, op: .contains, value: "x") + let b = FilterClause(id: id, column: 1, op: .contains, value: "y") + #expect(a != b) + } +} diff --git a/TableProTests/PluginTestSources/CSVDialect.swift b/TableProTests/PluginTestSources/CSVDialect.swift new file mode 120000 index 000000000..86975805d --- /dev/null +++ b/TableProTests/PluginTestSources/CSVDialect.swift @@ -0,0 +1 @@ +../../Plugins/CSVInspectorPlugin/CSVDialect.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/CSVRowStore.swift b/TableProTests/PluginTestSources/CSVRowStore.swift new file mode 120000 index 000000000..dec47084f --- /dev/null +++ b/TableProTests/PluginTestSources/CSVRowStore.swift @@ -0,0 +1 @@ +../../Plugins/CSVInspectorPlugin/CSVRowStore.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/CSVStreamingParser.swift b/TableProTests/PluginTestSources/CSVStreamingParser.swift new file mode 120000 index 000000000..ade260193 --- /dev/null +++ b/TableProTests/PluginTestSources/CSVStreamingParser.swift @@ -0,0 +1 @@ +../../Plugins/CSVInspectorPlugin/CSVStreamingParser.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/CSVTypeInferrer.swift b/TableProTests/PluginTestSources/CSVTypeInferrer.swift new file mode 120000 index 000000000..1a0f494d7 --- /dev/null +++ b/TableProTests/PluginTestSources/CSVTypeInferrer.swift @@ -0,0 +1 @@ +../../Plugins/CSVInspectorPlugin/CSVTypeInferrer.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/CSVWriter.swift b/TableProTests/PluginTestSources/CSVWriter.swift new file mode 120000 index 000000000..f890168b5 --- /dev/null +++ b/TableProTests/PluginTestSources/CSVWriter.swift @@ -0,0 +1 @@ +../../Plugins/CSVInspectorPlugin/CSVWriter.swift \ No newline at end of file diff --git a/TableProTests/Plugins/CSVInspectorTests.swift b/TableProTests/Plugins/CSVInspectorTests.swift new file mode 100644 index 000000000..934a23b1d --- /dev/null +++ b/TableProTests/Plugins/CSVInspectorTests.swift @@ -0,0 +1,354 @@ +// +// CSVInspectorTests.swift +// TableProTests +// +// Tests for CSVInspectorPlugin (compiled via symlinks from Plugins/CSVInspectorPlugin/). +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("CSVDialect.detect") +struct CSVDialectDetectionTests { + @Test("Detects comma delimiter") + func commaDelimiter() { + let csv = "a,b,c\n1,2,3\n".data(using: .utf8)! + let dialect = CSVDialect.detect(from: csv) + #expect(dialect.delimiter == 0x2C) + } + + @Test("Detects tab delimiter") + func tabDelimiter() { + let tsv = "a\tb\tc\n1\t2\t3\n".data(using: .utf8)! + let dialect = CSVDialect.detect(from: tsv) + #expect(dialect.delimiter == 0x09) + } + + @Test("Detects semicolon delimiter") + func semicolonDelimiter() { + let csv = "a;b;c\n1;2;3\n".data(using: .utf8)! + let dialect = CSVDialect.detect(from: csv) + #expect(dialect.delimiter == 0x3B) + } + + @Test("Detects UTF-8 BOM") + func utf8BOM() { + var data = Data([0xEF, 0xBB, 0xBF]) + data.append("a,b\n1,2\n".data(using: .utf8)!) + let dialect = CSVDialect.detect(from: data) + #expect(dialect.hasBom) + #expect(dialect.encoding == .utf8) + } + + @Test("Detects UTF-16 LE BOM") + func utf16LEBOM() { + let data = Data([0xFF, 0xFE, 0x61, 0x00, 0x2C, 0x00, 0x62, 0x00]) + let dialect = CSVDialect.detect(from: data) + #expect(dialect.hasBom) + #expect(dialect.encoding == .utf16LittleEndian) + } + + @Test("Detects CRLF line ending") + func crlfLineEnding() { + let csv = "a,b\r\n1,2\r\n".data(using: .utf8)! + let dialect = CSVDialect.detect(from: csv) + #expect(dialect.lineEnding == .crlf) + } + + @Test("Detects LF line ending") + func lfLineEnding() { + let csv = "a,b\n1,2\n".data(using: .utf8)! + let dialect = CSVDialect.detect(from: csv) + #expect(dialect.lineEnding == .lf) + } + + @Test("Quote-aware delimiter detection ignores embedded delimiters") + func quoteAwareDelimiter() { + let csv = #""a,b,c";"d,e,f"\n"x";"y"\n"#.replacingOccurrences(of: "\\n", with: "\n").data(using: .utf8)! + let dialect = CSVDialect.detect(from: csv) + #expect(dialect.delimiter == 0x3B) + } + + @Test("Falls back to Windows-1252 on invalid UTF-8") + func windowsCP1252Fallback() { + var data = "name,value\n".data(using: .utf8)! + data.append(Data([0xA3, 0x2C, 0x31, 0x0A])) + let dialect = CSVDialect.detect(from: data) + #expect(dialect.encoding == .windowsCP1252) + } +} + +@Suite("CSVStreamingParser") +struct CSVStreamingParserTests { + private func parse(_ source: String, dialect: CSVDialect = .csv) -> (data: Data, ranges: [Range], parser: CSVStreamingParser) { + let data = source.data(using: .utf8)! + let parser = CSVStreamingParser(dialect: dialect) + let ranges = data.withUnsafeBytes { raw -> [Range] in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return [] } + return parser.indexRows(UnsafeBufferPointer(start: base, count: raw.count)) + } + return (data, ranges, parser) + } + + private func row(_ data: Data, _ parser: CSVStreamingParser, _ range: Range) -> [String] { + data.withUnsafeBytes { raw -> [String] in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return [] } + return parser.parseRow(UnsafeBufferPointer(start: base, count: raw.count), range: range) + } + } + + @Test("Indexes simple three-row CSV") + func simpleThreeRows() { + let (_, ranges, _) = parse("a,b,c\n1,2,3\n4,5,6\n") + #expect(ranges.count == 3) + } + + @Test("Indexes row without trailing newline") + func noTrailingNewline() { + let (_, ranges, _) = parse("a,b\n1,2") + #expect(ranges.count == 2) + } + + @Test("Quoted field with embedded delimiter stays in one row") + func quotedEmbeddedDelimiter() { + let (data, ranges, parser) = parse(#"a,"hello, world",c"# + "\n") + #expect(ranges.count == 1) + let fields = row(data, parser, ranges[0]) + #expect(fields == ["a", "hello, world", "c"]) + } + + @Test("Quoted field with embedded newline stays in one row") + func quotedEmbeddedNewline() { + let (data, ranges, parser) = parse("a,\"line1\nline2\",c\nx,y,z\n") + #expect(ranges.count == 2) + let fields = row(data, parser, ranges[0]) + #expect(fields == ["a", "line1\nline2", "c"]) + } + + @Test("RFC 4180 doubled-quote escape decodes to single quote") + func doubledQuoteEscape() { + let (data, ranges, parser) = parse(#"a,"say ""hi""",c"# + "\n") + let fields = row(data, parser, ranges[0]) + #expect(fields == ["a", #"say "hi""#, "c"]) + } + + @Test("Empty fields preserved") + func emptyFields() { + let (data, ranges, parser) = parse(",,,\n") + let fields = row(data, parser, ranges[0]) + #expect(fields == ["", "", "", ""]) + } + + @Test("field(at:column:) matches parseRow for ASCII data") + func fieldMatchesParseRow() { + let (data, ranges, parser) = parse("alpha,beta,gamma,delta\n") + let full = row(data, parser, ranges[0]) + for column in 0.. String in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return "" } + return parser.field(UnsafeBufferPointer(start: base, count: raw.count), range: ranges[0], column: column) + } + #expect(single == full[column]) + } + } + + @Test("field(at:column:) handles quoted fields with embedded delimiter") + func fieldHandlesQuoted() { + let (data, ranges, parser) = parse(#"a,"x,y,z",c"# + "\n") + let middle = data.withUnsafeBytes { raw -> String in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return "" } + return parser.field(UnsafeBufferPointer(start: base, count: raw.count), range: ranges[0], column: 1) + } + #expect(middle == "x,y,z") + } + + @Test("field(at:column:) returns empty for out-of-range column") + func fieldOutOfRange() { + let (data, ranges, parser) = parse("a,b\n") + let outOfRange = data.withUnsafeBytes { raw -> String in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return "" } + return parser.field(UnsafeBufferPointer(start: base, count: raw.count), range: ranges[0], column: 99) + } + #expect(outOfRange == "") + } +} + +@Suite("CSVRowStore") +struct CSVRowStoreTests { + private func makeStore(_ source: String, dialect: CSVDialect = .csv) -> CSVRowStore { + CSVRowStore(data: source.data(using: .utf8)!, dialect: dialect) + } + + @Test("Detects header row from non-numeric first row") + func detectsHeader() { + let store = makeStore("name,age,city\nAlice,30,Paris\n") + #expect(store.columnNames == ["name", "age", "city"]) + #expect(store.rowCount == 1) + } + + @Test("Synthesizes header when first row is numeric") + func synthesizesHeader() { + let store = makeStore("1,2,3\n4,5,6\n") + #expect(store.columnNames == ["Column 1", "Column 2", "Column 3"]) + #expect(store.rowCount == 2) + } + + @Test("value(row:column:) returns correct cell") + func valueReturnsCell() { + let store = makeStore("a,b,c\n1,2,3\n4,5,6\n") + #expect(store.value(row: 0, column: 0) == "1") + #expect(store.value(row: 0, column: 2) == "3") + #expect(store.value(row: 1, column: 1) == "5") + } + + @Test("setValue updates cell and round-trips via value()") + func setValueRoundTrip() { + let store = makeStore("a,b\n1,2\n3,4\n") + store.setValue("99", row: 1, column: 0) + #expect(store.value(row: 1, column: 0) == "99") + } + + @Test("snapshot.cells(at:) matches store.cells(forRow:)") + func snapshotCellsMatchStore() { + let store = makeStore("a,b,c\n1,2,3\nfoo,bar,baz\n") + let snapshot = store.snapshot() + #expect(snapshot.rowCount == 2) + #expect(snapshot.cells(at: 0) == store.cells(forRow: 0)) + #expect(snapshot.cells(at: 1) == store.cells(forRow: 1)) + } + + @Test("snapshot.field(at:column:) matches snapshot.cells(at:)[column]") + func snapshotFieldMatchesCells() { + let store = makeStore("a,b,c\nalpha,beta,gamma\nx,y,z\n") + let snapshot = store.snapshot() + for row in 0.. URL { + FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).\(ext)") + } + + @Test("Round-trip preserves byte-for-byte for unmodified rows") + func roundTripUnmodified() throws { + let source = "name,age,city\nAlice,30,Paris\nBob,25,London\nCarol,40,Tokyo\n" + let url = tempURL() + try source.data(using: .utf8)!.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + let data = try Data(contentsOf: url, options: .mappedIfSafe) + let dialect = CSVDialect.detect(from: data) + let store = CSVRowStore(data: data, dialect: dialect) + + let outURL = tempURL() + defer { try? FileManager.default.removeItem(at: outURL) } + try CSVWriter(dialect: dialect).write(store, to: outURL) + let written = try Data(contentsOf: outURL) + #expect(written == data) + } + + @Test("Round-trip after edit preserves untouched rows") + func roundTripAfterEdit() throws { + let source = "a,b\n1,2\n3,4\n5,6\n" + let url = tempURL() + try source.data(using: .utf8)!.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + let data = try Data(contentsOf: url, options: .mappedIfSafe) + let dialect = CSVDialect.detect(from: data) + let store = CSVRowStore(data: data, dialect: dialect) + store.setValue("99", row: 1, column: 0) + + let outURL = tempURL() + defer { try? FileManager.default.removeItem(at: outURL) } + try CSVWriter(dialect: dialect).write(store, to: outURL) + + let written = try String(contentsOf: outURL, encoding: .utf8) + #expect(written == "a,b\n1,2\n99,4\n5,6\n") + } + + @Test("Round-trip preserves BOM when present") + func roundTripBOM() throws { + var source = Data([0xEF, 0xBB, 0xBF]) + source.append("a,b\n1,2\n".data(using: .utf8)!) + let url = tempURL() + try source.write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + let data = try Data(contentsOf: url, options: .mappedIfSafe) + let dialect = CSVDialect.detect(from: data) + let store = CSVRowStore(data: data, dialect: dialect) + + let outURL = tempURL() + defer { try? FileManager.default.removeItem(at: outURL) } + try CSVWriter(dialect: dialect).write(store, to: outURL) + + let written = try Data(contentsOf: outURL) + #expect(written.prefix(3) == Data([0xEF, 0xBB, 0xBF])) + } +} diff --git a/docs/docs.json b/docs/docs.json index 0fabeee5d..18784acfe 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -111,6 +111,7 @@ { "group": "Data Management", "pages": [ + "features/csv-inspector", "features/import-export", "features/backup-restore", "features/change-tracking", diff --git a/docs/features/csv-inspector.mdx b/docs/features/csv-inspector.mdx new file mode 100644 index 000000000..acd66cdb2 --- /dev/null +++ b/docs/features/csv-inspector.mdx @@ -0,0 +1,115 @@ +--- +title: CSV Inspector +description: Open, view, and edit CSV and TSV files natively without importing into a database first. +--- + +TablePro opens `.csv` and `.tsv` files directly as documents. No scratch database, no import step. The file is the source of truth; Save writes back to it. + + + CSV Inspector + CSV Inspector + + +## Opening a file + +- Double-click a `.csv` or `.tsv` in Finder. +- Drag a file onto the TablePro Dock icon or an open TablePro window. +- File > Open... and pick a CSV or TSV. +- File > Open Recent lists previously opened CSV documents alongside saved connections. + +## Auto-detection + +When a file opens, TablePro reads the first 8 KB to detect: + +- **Delimiter**: comma, tab, semicolon, or pipe, chosen by which appears most often outside quoted regions. +- **Encoding**: UTF-8 (with or without BOM), UTF-16 BE, UTF-16 LE. Files without a BOM are read as UTF-8 with Latin-1 fallback for stray bytes. +- **Line ending**: CRLF, LF, or CR. Whichever appears first. +- **Header row**: TablePro treats row one as headers when every value is non-numeric and unique. Otherwise it generates `Column 1`, `Column 2`, ... and treats every row as data. + +## Editing + +- Double-click a cell to edit. Tab or Return commits and moves to the next cell. +- The toolbar `Add Row` button appends a new row, scrolls to it, and selects it for editing. +- Select rows and press `Delete` (or the toolbar `−` button) to remove them. The next row takes selection so arrow keys keep working from where you were. +- Cmd+Z and Cmd+Shift+Z undo and redo every change. A bulk delete or a paste is a single undo step. + +## Column operations + + + CSV Inspector Columns Menu + CSV Inspector Columns Menu + + +The toolbar `Columns` button opens a menu listing every column with its current type. Each column has a submenu for: + +- **Rename…** opens a sheet to enter a new name; the column stays in place. +- **Insert Column Before / After** inserts a new column at the chosen position with a name you supply. +- **Type ▸** overrides the inferred type as Text, Integer, Real, Boolean, or Date. **Reset to Inferred** drops the override. +- **Delete** removes the column (undoable). + +## Filter and sort + + + CSV Inspector Filter Bar + CSV Inspector Filter Bar + + +- `Cmd+F` toggles the filter bar. Each row is one condition: column, operator (`contains`, `equals`, `does not equal`, `starts with`, `ends with`, `is empty`, `is not empty`), and value. +- Use `+ Add filter` for additional conditions. All conditions are combined with AND. +- `Clear all` resets to a single empty condition. +- Click a column header to sort by that column. Shift-click another column to add it as a secondary sort key (and so on for tie-breakers). +- Numeric-typed columns sort numerically; text columns sort with natural ordering (so `Item 2` comes before `Item 10`). +- Filter and sort run off the main thread, so the UI stays responsive on large files. An "Updating…" indicator shows in the status bar while a recompute is in flight. + +## Copy and paste + +- `Cmd+C` copies selected rows to the pasteboard as TSV (tab-separated values). +- `Cmd+V` parses TSV from the pasteboard and appends each line as a new row. A whole paste is one undo step. + +## Type inference + +TablePro samples the first 200 non-empty values per column and infers `Integer`, `Real`, `Boolean`, `Date`, or `Text`. The inferred type controls numeric sort and right-alignment. Inferred types do not modify the stored data: every cell is still a string on disk. Override per-column from the `Columns` menu. + +## Saving + +- Cmd+S saves to the original path, preserving the detected delimiter, encoding, line ending, and BOM byte-for-byte. +- Cmd+Shift+S brings up Save As, where you can re-pick the destination. +- File > Revert To > Last Saved restores the last saved version. File > Revert To > Browse All Versions opens the macOS document version browser. +- The window title shows "Edited" until you save. + +## External changes + +If another application writes the file while you have it open in TablePro: + +- If you have no unsaved edits, TablePro reloads the file. +- If you have unsaved edits, TablePro asks whether to keep your version or revert to the version on disk. + +This is the standard macOS document behavior, the same as TextEdit and Pages. + +## Window tabbing + +CSV windows form their own native window tab group (separate from connection windows). Cmd+T inside a CSV window creates a new CSV inspector tab. Drag a CSV tab out of the window to detach it into its own window, or drop one back in to re-attach. diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 165207435..8e07c7c94 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -142,6 +142,24 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Copy as JSON | `Cmd+Option+J` | | Copy as TSV | Available from context menu | +## CSV Inspector + +Inherits the standard `NSDocument` shortcuts. + +| Action | Shortcut | +|--------|----------| +| Save | `Cmd+S` | +| Save As | `Cmd+Shift+S` | +| Revert to Saved | `Cmd+Option+S` | +| Toggle filter bar | `Cmd+F` | +| Copy selected rows as TSV | `Cmd+C` | +| Paste TSV as new rows | `Cmd+V` | +| Add secondary sort column | `Shift+Click` column header | +| Undo (cell edit, delete, paste) | `Cmd+Z` | +| Redo | `Cmd+Shift+Z` | +| New CSV inspector tab | `Cmd+T` (from a CSV window) | +| Close CSV tab | `Cmd+W` | + ## Application ### Windows & Tabs diff --git a/docs/images/csv-inspector-columns-menu-dark.png b/docs/images/csv-inspector-columns-menu-dark.png new file mode 100644 index 000000000..580117ffa Binary files /dev/null and b/docs/images/csv-inspector-columns-menu-dark.png differ diff --git a/docs/images/csv-inspector-columns-menu.png b/docs/images/csv-inspector-columns-menu.png new file mode 100644 index 000000000..f93d906c1 Binary files /dev/null and b/docs/images/csv-inspector-columns-menu.png differ diff --git a/docs/images/csv-inspector-dark.png b/docs/images/csv-inspector-dark.png new file mode 100644 index 000000000..7313de195 Binary files /dev/null and b/docs/images/csv-inspector-dark.png differ diff --git a/docs/images/csv-inspector-filter-dark.png b/docs/images/csv-inspector-filter-dark.png new file mode 100644 index 000000000..27221b997 Binary files /dev/null and b/docs/images/csv-inspector-filter-dark.png differ diff --git a/docs/images/csv-inspector-filter.png b/docs/images/csv-inspector-filter.png new file mode 100644 index 000000000..c84efe7fd Binary files /dev/null and b/docs/images/csv-inspector-filter.png differ diff --git a/docs/images/csv-inspector.png b/docs/images/csv-inspector.png new file mode 100644 index 000000000..a0e4b5873 Binary files /dev/null and b/docs/images/csv-inspector.png differ diff --git a/docs/index.mdx b/docs/index.mdx index 8830b3676..a7ce035cb 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -45,6 +45,7 @@ Native macOS client for every database. Built on SwiftUI and AppKit. Ships under **SQL Editor**: Syntax highlighting, autocomplete, Vim mode, multi-statement execution. **Data Grid**: Inline editing, sorting, filtering, change tracking with undo/redo. +**CSV Inspector**: Open `.csv` and `.tsv` files natively. Edit cells, insert and delete rows and columns, undo/redo, save preserving the original dialect. **Import & Export**: CSV, JSON, SQL, XLSX, MQL. Streaming export for large datasets. **AI Assistant**: Chat, inline suggestions, and Explain/Optimize via GitHub Copilot, Claude, OpenAI, or Ollama. **Terminal**: Built-in database CLI (mysql, psql, redis-cli, mongosh, etc.) with SSH and Docker support.