diff --git a/cli/Sources/Noora/Components/Alert.swift b/cli/Sources/Noora/Components/Alert.swift index 71599d77..5b9f993e 100644 --- a/cli/Sources/Noora/Components/Alert.swift +++ b/cli/Sources/Noora/Components/Alert.swift @@ -24,6 +24,7 @@ struct Alert { let content: Content let logger: Logger? + // swiftlint:disable:next function_body_length func run() { let standardPipeline = switch item { case .error: standardPipelines.error diff --git a/cli/Sources/Noora/Components/MultipleChoicePrompt.swift b/cli/Sources/Noora/Components/MultipleChoicePrompt.swift index 3184d90b..171d6270 100644 --- a/cli/Sources/Noora/Components/MultipleChoicePrompt.swift +++ b/cli/Sources/Noora/Components/MultipleChoicePrompt.swift @@ -18,6 +18,7 @@ public enum MultipleChoiceLimit { case limited(count: Int, errorMessage: String) } +// swiftlint:disable:next type_body_length struct MultipleChoicePrompt { // MARK: - Attributes @@ -242,6 +243,7 @@ struct MultipleChoicePrompt { return startIndex ..< endIndex } + // swiftlint:disable:next function_body_length private func renderOptions( currentOption: (T, String), selectedOptions: [(T, String)], @@ -322,7 +324,7 @@ struct MultipleChoicePrompt { let selected = selectedOptions.contains(where: { $0 == option }) ? "◉" : "○" if option == currentOption { visibleOptions.append( - "\(titleOffset)\("❯".hex(theme.primary)) \(selected) \(option.1)" + "\(titleOffset)\("❯".hexIfColoredTerminal(theme.primary, terminal)) \(selected) \(option.1)" ) } else { visibleOptions.append("\(titleOffset) \(selected) \(option.1)") diff --git a/cli/Sources/Noora/Components/SingleChoicePrompt.swift b/cli/Sources/Noora/Components/SingleChoicePrompt.swift index 717389ac..e2d0a775 100644 --- a/cli/Sources/Noora/Components/SingleChoicePrompt.swift +++ b/cli/Sources/Noora/Components/SingleChoicePrompt.swift @@ -38,6 +38,7 @@ struct SingleChoicePrompt { // MARK: - Private + // swiftlint:disable:next function_body_length private func run(options: [(T, String)]) -> T { if autoselectSingleChoice, options.count == 1 { renderResult(selectedOption: options[0]) @@ -235,7 +236,7 @@ struct SingleChoicePrompt { var visibleOptions = [String]() for (index, option) in filteredOptions.enumerated() where visibleRange ~= index { if option == selectedOption { - visibleOptions.append("\(titleOffset) \("❯".hex(theme.primary)) \(option.1)") + visibleOptions.append("\(titleOffset) \("❯".hexIfColoredTerminal(theme.primary, terminal)) \(option.1)") } else { visibleOptions.append("\(titleOffset) \(option.1)") } diff --git a/cli/Sources/Noora/Components/Table/PaginatedTable.swift b/cli/Sources/Noora/Components/Table/PaginatedTable.swift index 8e7be1b5..0ce9f17a 100644 --- a/cli/Sources/Noora/Components/Table/PaginatedTable.swift +++ b/cli/Sources/Noora/Components/Table/PaginatedTable.swift @@ -66,6 +66,7 @@ struct PaginatedTable { extension PaginatedTable { /// Runs the paginated table with keyboard navigation (static mode) + // swiftlint:disable function_body_length func run() throws { let effectiveTotalPages = data.pageCount(size: pageSize) guard effectiveTotalPages > 0 else { return } @@ -191,6 +192,8 @@ extension PaginatedTable { } } + // swiftlint:enable function_body_length + /// Loads a page asynchronously and renders the result private func loadPageAndRender( page: Int, diff --git a/cli/Sources/Noora/Components/Table/SelectableTable.swift b/cli/Sources/Noora/Components/Table/SelectableTable.swift index 60e64e3f..79dcf47d 100644 --- a/cli/Sources/Noora/Components/Table/SelectableTable.swift +++ b/cli/Sources/Noora/Components/Table/SelectableTable.swift @@ -14,6 +14,7 @@ struct SelectableTable { let logger: Logger? let tableRenderer: TableRenderer + // swiftlint:disable function_body_length /// Runs the interactive table and returns the selected row index func run() throws -> Int { guard terminal.isInteractive else { @@ -107,6 +108,8 @@ struct SelectableTable { return finalResult } + // swiftlint:enable function_body_length + /// Renders the table with selection highlighting private func renderTableWithSelection(selectedIndex: Int, viewport: TableViewport) { // Get visible rows @@ -196,7 +199,7 @@ struct SelectableTable { /// Render a selected row with full-width background highlighting and visible borders private func renderSelectedRow( - _ cells: [TerminalText], + _ cells: TableRow, layout: TableLayout, columns: [TableColumn] ) -> String { diff --git a/cli/Sources/Noora/Components/Table/TableColumn.swift b/cli/Sources/Noora/Components/Table/TableColumn.swift index 22f53e18..e9a1ad96 100644 --- a/cli/Sources/Noora/Components/Table/TableColumn.swift +++ b/cli/Sources/Noora/Components/Table/TableColumn.swift @@ -1,7 +1,7 @@ import Foundation /// Defines a column in a table -public struct TableColumn { +public struct TableColumn: Sendable { /// The title displayed in the header public let title: TerminalText @@ -12,7 +12,7 @@ public struct TableColumn { public let alignment: Alignment /// Width configuration options - public enum Width: Equatable { + public enum Width: Equatable, Sendable { /// Fixed width in characters case fixed(Int) @@ -24,7 +24,7 @@ public struct TableColumn { } /// Text alignment options - public enum Alignment: Equatable { + public enum Alignment: Equatable, Sendable { case left case center case right diff --git a/cli/Sources/Noora/Components/Table/TableData.swift b/cli/Sources/Noora/Components/Table/TableData.swift index 442980a5..41b51f75 100644 --- a/cli/Sources/Noora/Components/Table/TableData.swift +++ b/cli/Sources/Noora/Components/Table/TableData.swift @@ -1,7 +1,7 @@ import Foundation /// Semantic styling options for table content -public enum TableCellStyle { +public enum TableCellStyle: Sendable { case plain(String) case primary(String) case secondary(String) @@ -34,14 +34,121 @@ public enum TableCellStyle { } } -/// A row in a table, containing cells with TerminalText content -public typealias TableRow = [TerminalText] +/// A stable identifier for a table row. +public struct TableRowID: Hashable, Sendable { + private let box: TableRowIDBox + + public init(_ id: some Hashable & Sendable) { + box = TableRowIDBox(id) + } + + public func unwrap(_ type: ID.Type = ID.self) -> ID? { + box.unbox(type) + } + + public static func == (lhs: TableRowID, rhs: TableRowID) -> Bool { + lhs.box == rhs.box + } + + public func hash(into hasher: inout Hasher) { + box.hash(into: &hasher) + } +} + +private struct TableRowIDBox: Hashable, Sendable { + private let hash: @Sendable (inout Hasher) -> Void + private let equals: @Sendable (TableRowIDBox) -> Bool + private let unbox: @Sendable (Any.Type) -> Any? + + init(_ id: ID) { + hash = { hasher in + id.hash(into: &hasher) + } + unbox = { type in + type == ID.self ? id : nil + } + equals = { other in + guard let otherID = other.unbox(ID.self) as? ID else { return false } + return id == otherID + } + } + + func unbox(_ type: ID.Type) -> ID? { + unbox(type) as? ID + } + + static func == (lhs: TableRowIDBox, rhs: TableRowIDBox) -> Bool { + lhs.equals(rhs) + } + + func hash(into hasher: inout Hasher) { + hash(&hasher) + } +} + +/// A row in a table, containing cells with TerminalText content and a stable identifier. +public struct TableRow: Identifiable, RandomAccessCollection, Sendable { + public typealias Element = TerminalText + public typealias Index = Int + + public let id: TableRowID + public var cells: [TerminalText] + + public init(_ cells: [TerminalText], id: TableRowID? = nil) { + self.cells = cells + self.id = id ?? TableRow.defaultID(for: cells) + } + + public init(_ styledCells: [TableCellStyle], id: TableRowID? = nil) { + let resolved = styledCells.map { $0.toTerminalText() } + cells = resolved + self.id = id ?? TableRow.defaultID(for: resolved) + } + + public init(_ cells: [TerminalText], id: some Hashable & Sendable) { + self.cells = cells + self.id = TableRowID(id) + } + + public init(_ styledCells: [TableCellStyle], id: some Hashable & Sendable) { + let resolved = styledCells.map { $0.toTerminalText() } + cells = resolved + self.id = TableRowID(id) + } + + public var startIndex: Int { cells.startIndex } + public var endIndex: Int { cells.endIndex } + + public subscript(position: Int) -> TerminalText { + cells[position] + } + + private static func defaultID(for cells: [TerminalText]) -> TableRowID { + if let firstCell = cells.first { + return TableRowID(firstCell.plain()) + } + return TableRowID(cells.map { $0.plain() }) + } +} /// A row in a table using semantic styling public typealias StyledTableRow = [TableCellStyle] +/// Defines how to build a table cell from a data element. +public struct TerminalRow { + let render: (Element) -> TerminalText + + public init(_ render: @escaping (Element) -> TerminalText) { + self.render = render + } + + public init(styled render: @escaping (Element) -> TableCellStyle) { + self.render = { render($0).toTerminalText() } + } +} + /// Represents the data structure for a table -public struct TableData { +public struct TableData: Sendable { /// Column definitions for the table public let columns: [TableColumn] @@ -57,14 +164,55 @@ public struct TableData { self.rows = rows } + /// Creates a new table data structure + /// - Parameters: + /// - columns: Column definitions + /// - rows: Data rows (each row must have same count as columns) + public init(columns: [TableColumn], rows: [[TerminalText]]) { + self.columns = columns + self.rows = rows.map { TableRow($0) } + } + /// Creates a new table data structure with styled content /// - Parameters: /// - columns: Column definitions /// - rows: Data rows using semantic styling public init(columns: [TableColumn], styledRows: [StyledTableRow]) { self.columns = columns - rows = styledRows.map { row in - row.map { $0.toTerminalText() } + rows = styledRows.map { TableRow($0) } + } + + /// Creates a new table data structure from data with row builders. + /// - Parameters: + /// - data: The data elements used to populate the rows. + /// - columns: Column definitions. + /// - rows: Row builders (one per column). + public init( + _ data: Data, + columns: [TableColumn], + rows: [TerminalRow] + ) where Data.Element: Identifiable, Data.Element.ID: Sendable { + self.columns = columns + self.rows = data.map { element in + let cells = rows.map { $0.render(element) } + return TableRow(cells, id: TableRowID(element.id)) + } + } + + /// Creates a new table data structure from data with row builders. + /// - Parameters: + /// - data: The data elements used to populate the rows. + /// - columns: Column definitions. + /// - rows: Row builders (one per column). + public init( + _ data: Data, + columns: [TableColumn], + rows: [TerminalRow] + ) { + self.columns = columns + self.rows = data.map { element in + let cells = rows.map { $0.render(element) } + return TableRow(cells) } } @@ -94,7 +242,7 @@ public struct TableData { } /// Represents a viewport into table rows for scrolling -public struct TableViewport { +public struct TableViewport: Sendable { /// First visible row index public var startIndex: Int diff --git a/cli/Sources/Noora/Components/Table/TableRenderer.swift b/cli/Sources/Noora/Components/Table/TableRenderer.swift index 50900b16..24b06476 100644 --- a/cli/Sources/Noora/Components/Table/TableRenderer.swift +++ b/cli/Sources/Noora/Components/Table/TableRenderer.swift @@ -143,15 +143,15 @@ struct TableRenderer { } /// Render a data row - func renderRow( - _ cells: [TerminalText], + func renderRow( + _ cells: Row, layout: TableLayout, style: TableStyle, theme: Theme, terminal: Terminaling, columns: [TableColumn], isHeader _: Bool = false - ) -> String { + ) -> String where Row.Element == TerminalText { var parts: [String] = [] let chars = style.borderCharacters let borderColor = theme.muted diff --git a/cli/Sources/Noora/Components/Table/TableSelectionTracking.swift b/cli/Sources/Noora/Components/Table/TableSelectionTracking.swift new file mode 100644 index 00000000..de6de5ae --- /dev/null +++ b/cli/Sources/Noora/Components/Table/TableSelectionTracking.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Defines how selection behaves when updating tables reorder rows. +public enum TableSelectionTracking { + /// Keeps the selection anchored to the current index when rows reorder. + case index + /// Tracks selection by a stable key derived from the selected row. + case rowKey(@Sendable (TableRow) -> TableRowID) + /// Automatically tracks rows using the row's identifier (defaults to the first column's text). + case automatic + + /// Default tracking strategy for updating selectable tables. + public static var defaultRowKey: TableSelectionTracking { + .automatic + } +} diff --git a/cli/Sources/Noora/Components/Table/UpdatingSelectableTable.swift b/cli/Sources/Noora/Components/Table/UpdatingSelectableTable.swift index b016a086..a3dc8196 100644 --- a/cli/Sources/Noora/Components/Table/UpdatingSelectableTable.swift +++ b/cli/Sources/Noora/Components/Table/UpdatingSelectableTable.swift @@ -2,11 +2,12 @@ import Foundation import Logging /// An interactive table that keeps updating as new data arrives. -struct UpdatingSelectableTable where Updates.Element == TableData { +struct UpdatingSelectableTable where Updates.Element == TableData { let initialData: TableData let updates: Updates let style: TableStyle let pageSize: Int + let selectionTracking: TableSelectionTracking let renderer: Rendering let standardPipelines: StandardPipelines let terminal: Terminaling @@ -14,7 +15,6 @@ struct UpdatingSelectableTable where Updates.Element == let keyStrokeListener: KeyStrokeListening let logger: Logger? let tableRenderer: TableRenderer - private let renderQueue = DispatchQueue(label: "updating-selectable-table-render") func run() async throws -> Int { guard terminal.isInteractive else { @@ -36,112 +36,240 @@ struct UpdatingSelectableTable where Updates.Element == startIndex: 0, size: min(pageSize, initialData.rows.count), totalRows: initialData.rows.count - ) + ), + selectionTracking: selectionTracking ) - let group = DispatchGroup() + let renderCoordinator = LiveSelectableRenderer( + renderer: renderer, + standardPipelines: standardPipelines, + terminal: terminal, + theme: theme, + style: style, + pageSize: pageSize, + logger: logger, + tableRenderer: tableRenderer + ) terminal.inRawMode { terminal.withoutCursor { - render(state.snapshot()) - - group.enter() + let semaphore = DispatchSemaphore(value: 0) Task { - await consumeUpdates(state: state) - group.leave() + defer { semaphore.signal() } + await Self.runLoop( + state: state, + renderer: renderCoordinator, + updates: updates, + keyStrokeListener: keyStrokeListener, + terminal: terminal, + pageSize: pageSize, + logger: logger + ) } + semaphore.wait() + } + } - group.enter() - Task { - listenForInput(state: state) - group.leave() - } + return try await state.result() + } + + private enum RunnerResult { + case updates + case input + } - group.wait() + private static func runLoop( + state: LiveSelectableState, + renderer: LiveSelectableRenderer, + updates: Updates, + keyStrokeListener: KeyStrokeListening, + terminal: Terminaling, + pageSize: Int, + logger: Logger? + ) async { + await renderer.render(snapshot: await state.snapshot()) + + await withTaskGroup(of: RunnerResult.self) { group in + group.addTask { + await consumeUpdates( + state: state, + renderer: renderer, + updates: updates, + pageSize: pageSize, + logger: logger + ) + return .updates + } + + group.addTask { + await listenForInput( + state: state, + renderer: renderer, + keyStrokeListener: keyStrokeListener, + terminal: terminal, + pageSize: pageSize + ) + return .input } - } - return try state.result() + while let result = await group.next() { + switch result { + case .updates: + continue + case .input: + group.cancelAll() + return + } + } + } } - private func consumeUpdates(state: LiveSelectableState) async { + private static func consumeUpdates( + state: LiveSelectableState, + renderer: LiveSelectableRenderer, + updates: Updates, + pageSize: Int, + logger: Logger? + ) async { do { for try await newData in updates { - if Task.isCancelled || state.shouldStop() { + if Task.isCancelled { break } - guard let snapshot = state.updateData(newData, pageSize: pageSize) else { + if await state.shouldStop() { + break + } + + guard let snapshot = await state.updateData(newData, pageSize: pageSize) else { if !newData.isValid || newData.rows.isEmpty { logger?.warning("Table data is invalid: row cell counts don't match column count") } continue } - render(snapshot) + + await renderer.render(snapshot: snapshot) } } catch { logger?.warning("Table updates stream failed: \(error)") } } - private func listenForInput(state: LiveSelectableState) { - keyStrokeListener.listen(terminal: terminal) { keyStroke in - if state.shouldStop() { - return .abort + private static func listenForInput( + state: LiveSelectableState, + renderer: LiveSelectableRenderer, + keyStrokeListener: KeyStrokeListening, + terminal: Terminaling, + pageSize: Int + ) async { + let keyStrokes = keyStrokeStream(keyStrokeListener: keyStrokeListener, terminal: terminal) + + for await keyStroke in keyStrokes { + if await state.shouldStop() { + break } switch keyStroke { case .upArrowKey, .printable("k"): - if let snapshot = state.moveSelection(delta: -1) { - render(snapshot) + if let snapshot = await state.moveSelection(delta: -1) { + await renderer.render(snapshot: snapshot) } - return .continue case .downArrowKey, .printable("j"): - if let snapshot = state.moveSelection(delta: 1) { - render(snapshot) + if let snapshot = await state.moveSelection(delta: 1) { + await renderer.render(snapshot: snapshot) } - return .continue case .pageUp: - if let snapshot = state.moveSelection(delta: -pageSize) { - render(snapshot) + if let snapshot = await state.moveSelection(delta: -pageSize) { + await renderer.render(snapshot: snapshot) } - return .continue case .pageDown: - if let snapshot = state.moveSelection(delta: pageSize) { - render(snapshot) + if let snapshot = await state.moveSelection(delta: pageSize) { + await renderer.render(snapshot: snapshot) } - return .continue case .home: - if let snapshot = state.moveTo(index: 0) { - render(snapshot) + if let snapshot = await state.moveTo(index: 0) { + await renderer.render(snapshot: snapshot) } - return .continue case .end: - if let snapshot = state.moveToEnd() { - render(snapshot) + if let snapshot = await state.moveToEnd() { + await renderer.render(snapshot: snapshot) } - return .continue case .returnKey: - state.selectCurrent() - return .abort + await state.selectCurrent() + return case .escape: - state.cancel() - return .abort + await state.cancel() + return default: - return .continue + continue + } + } + } + + private static func keyStrokeStream( + keyStrokeListener: KeyStrokeListening, + terminal: Terminaling + ) -> AsyncStream { + AsyncStream { continuation in + let task = Task.detached { + keyStrokeListener.listen(terminal: terminal) { keyStroke in + continuation.yield(keyStroke) + switch keyStroke { + case .returnKey, .escape: + continuation.finish() + return .abort + default: + return .continue + } + } + } + + continuation.onTermination = { _ in + task.cancel() } } } +} + +private actor LiveSelectableRenderer { + private let renderer: Rendering + private let standardPipelines: StandardPipelines + private let terminal: Terminaling + private let theme: Theme + private let style: TableStyle + private let pageSize: Int + private let logger: Logger? + private let tableRenderer: TableRenderer + + init( + renderer: Rendering, + standardPipelines: StandardPipelines, + terminal: Terminaling, + theme: Theme, + style: TableStyle, + pageSize: Int, + logger: Logger?, + tableRenderer: TableRenderer + ) { + self.renderer = renderer + self.standardPipelines = standardPipelines + self.terminal = terminal + self.theme = theme + self.style = style + self.pageSize = pageSize + self.logger = logger + self.tableRenderer = tableRenderer + } - private func render(_ snapshot: LiveSelectableState.Snapshot) { + func render(snapshot: LiveSelectableState.Snapshot) { let visibleRows = Array(snapshot.data.rows[snapshot.viewport.startIndex ..< snapshot.viewport.endIndex]) let visibleData = TableData(columns: snapshot.data.columns, rows: visibleRows) let selectedInViewport = snapshot.selectedIndex - snapshot.viewport.startIndex @@ -159,9 +287,7 @@ struct UpdatingSelectableTable where Updates.Element == )) let output = lines.joined(separator: "\n") - renderQueue.sync { - renderer.render(output, standardPipeline: standardPipelines.output) - } + renderer.render(output, standardPipeline: standardPipelines.output) } /// Renders the table with selection highlighting applied @@ -222,7 +348,7 @@ struct UpdatingSelectableTable where Updates.Element == /// Render a selected row with full-width background highlighting and visible borders private func renderSelectedRow( - _ cells: [TerminalText], + _ cells: TableRow, layout: TableLayout, columns: [TableColumn] ) -> String { @@ -316,113 +442,160 @@ struct UpdatingSelectableTable where Updates.Element == } } -private final class LiveSelectableState { - struct Snapshot { +private actor LiveSelectableState { + struct Snapshot: Sendable { let data: TableData let selectedIndex: Int let viewport: TableViewport } - private let queue = DispatchQueue(label: "live-selectable-table") + private let selectionTracking: TableSelectionTracking private var data: TableData private var selectedIndex: Int private var viewport: TableViewport + private var selectionKey: TableRowID? private var stopped = false private var selection: Int? - init(data: TableData, selectedIndex: Int, viewport: TableViewport) { + init( + data: TableData, + selectedIndex: Int, + viewport: TableViewport, + selectionTracking: TableSelectionTracking + ) { + self.selectionTracking = selectionTracking self.data = data self.selectedIndex = selectedIndex self.viewport = viewport + selectionKey = Self.selectionKey( + for: data, + selectedIndex: selectedIndex, + selectionTracking: selectionTracking + ) } func snapshot() -> Snapshot { - queue.sync { - Snapshot(data: data, selectedIndex: selectedIndex, viewport: viewport) - } + Snapshot(data: data, selectedIndex: selectedIndex, viewport: viewport) } func updateData(_ newData: TableData, pageSize: Int) -> Snapshot? { - queue.sync { - guard newData.isValid, !newData.rows.isEmpty else { return nil } - data = newData + guard newData.isValid, !newData.rows.isEmpty else { return nil } + if let matchedIndex = selectionIndex(in: newData) { + selectedIndex = matchedIndex + } - if selectedIndex >= data.rows.count { - selectedIndex = max(0, data.rows.count - 1) - } + data = newData + + if selectedIndex >= data.rows.count { + selectedIndex = max(0, data.rows.count - 1) + } - viewport = TableViewport( - startIndex: min(viewport.startIndex, max(0, data.rows.count - 1)), - size: min(pageSize, data.rows.count), - totalRows: data.rows.count - ) + viewport = TableViewport( + startIndex: min(viewport.startIndex, max(0, data.rows.count - 1)), + size: min(pageSize, data.rows.count), + totalRows: data.rows.count + ) - var v = viewport - v.scrollToShow(selectedIndex) - viewport = v + var v = viewport + v.scrollToShow(selectedIndex) + viewport = v + selectionKey = selectionKey(for: data, selectedIndex: selectedIndex) - return Snapshot(data: data, selectedIndex: selectedIndex, viewport: viewport) - } + return Snapshot(data: data, selectedIndex: selectedIndex, viewport: viewport) } func moveSelection(delta: Int) -> Snapshot? { - queue.sync { - guard !data.rows.isEmpty else { return nil } - let maxIndex = max(0, data.rows.count - 1) - selectedIndex = min(max(0, selectedIndex + delta), maxIndex) - var v = viewport - v.scrollToShow(selectedIndex) - viewport = v - return Snapshot(data: data, selectedIndex: selectedIndex, viewport: viewport) - } + guard !data.rows.isEmpty else { return nil } + let maxIndex = max(0, data.rows.count - 1) + selectedIndex = min(max(0, selectedIndex + delta), maxIndex) + var v = viewport + v.scrollToShow(selectedIndex) + viewport = v + selectionKey = selectionKey(for: data, selectedIndex: selectedIndex) + return Snapshot(data: data, selectedIndex: selectedIndex, viewport: viewport) } func moveTo(index: Int) -> Snapshot? { - queue.sync { - guard !data.rows.isEmpty else { return nil } - selectedIndex = min(max(index, 0), data.rows.count - 1) - var v = viewport - v.scrollToShow(selectedIndex) - viewport = v - return Snapshot(data: data, selectedIndex: selectedIndex, viewport: viewport) - } + guard !data.rows.isEmpty else { return nil } + selectedIndex = min(max(index, 0), data.rows.count - 1) + var v = viewport + v.scrollToShow(selectedIndex) + viewport = v + selectionKey = selectionKey(for: data, selectedIndex: selectedIndex) + return Snapshot(data: data, selectedIndex: selectedIndex, viewport: viewport) } func moveToEnd() -> Snapshot? { - queue.sync { - guard !data.rows.isEmpty else { return nil } - selectedIndex = data.rows.count - 1 - var v = viewport - v.scrollToShow(selectedIndex) - viewport = v - return Snapshot(data: data, selectedIndex: selectedIndex, viewport: viewport) - } + guard !data.rows.isEmpty else { return nil } + selectedIndex = data.rows.count - 1 + var v = viewport + v.scrollToShow(selectedIndex) + viewport = v + selectionKey = selectionKey(for: data, selectedIndex: selectedIndex) + return Snapshot(data: data, selectedIndex: selectedIndex, viewport: viewport) } func selectCurrent() { - queue.sync { - stopped = true - selection = selectedIndex - } + stopped = true + selection = selectedIndex } func cancel() { - queue.sync { - stopped = true - selection = nil - } + stopped = true + selection = nil } func shouldStop() -> Bool { - queue.sync { stopped } + stopped } func result() throws -> Int { - try queue.sync { - guard let selection else { - throw NooraError.userCancelled + guard let selection else { + throw NooraError.userCancelled + } + return selection + } + + private static func selectionKey(for data: TableData, selectedIndex: Int, selectionTracking: TableSelectionTracking) + -> TableRowID? + { + keyForRow(in: data, index: selectedIndex, selectionTracking: selectionTracking) + } + + private static func keyForRow( + in data: TableData, + index: Int, + selectionTracking: TableSelectionTracking + ) -> TableRowID? { + guard data.rows.indices.contains(index) else { return nil } + switch selectionTracking { + case .index: + return nil + case let .rowKey(selector): + return selector(data.rows[index]) + case .automatic: + return data.rows[index].id + } + } + + private func selectionKey(for data: TableData, selectedIndex: Int) -> TableRowID? { + keyForRow(in: data, index: selectedIndex) + } + + private func selectionIndex(in data: TableData) -> Int? { + guard let selectionKey else { return nil } + switch selectionTracking { + case .index: + return nil + case .rowKey, .automatic: + for index in data.rows.indices where keyForRow(in: data, index: index) == selectionKey { + return index } - return selection + return nil } } + + private func keyForRow(in data: TableData, index: Int) -> TableRowID? { + Self.keyForRow(in: data, index: index, selectionTracking: selectionTracking) + } } diff --git a/cli/Sources/Noora/Noora.swift b/cli/Sources/Noora/Noora.swift index eda1d4ed..f6d052db 100644 --- a/cli/Sources/Noora/Noora.swift +++ b/cli/Sources/Noora/Noora.swift @@ -387,12 +387,14 @@ public protocol Noorable: Sendable { /// - data: Initial table data to render. /// - updates: An async sequence emitting new table data to render. /// - pageSize: Number of rows visible at once. + /// - selectionTracking: Controls how selection behaves when rows reorder. Use `.automatic` to track row identifiers. /// - renderer: A rendering interface that holds the UI state. /// - Returns: Selected row index. - func selectableTable( + func selectableTable( _ data: TableData, updates: Updates, pageSize: Int, + selectionTracking: TableSelectionTracking, renderer: Rendering ) async throws -> Int where Updates.Element == TableData @@ -887,10 +889,11 @@ public final class Noora: Noorable { ) } - public func selectableTable( + public func selectableTable( _ data: TableData, updates: Updates, pageSize: Int, + selectionTracking: TableSelectionTracking = .automatic, renderer: Rendering = Renderer() ) async throws -> Int where Updates.Element == TableData { let component = UpdatingSelectableTable( @@ -898,6 +901,7 @@ public final class Noora: Noorable { updates: updates, style: theme.tableStyle, pageSize: pageSize, + selectionTracking: selectionTracking, renderer: renderer, standardPipelines: standardPipelines, terminal: terminal, @@ -910,11 +914,12 @@ public final class Noora: Noorable { return try await component.run() } - public func selectableTable( + public func selectableTable( headers: [String], rows: [[String]], updates: Updates, pageSize: Int, + selectionTracking: TableSelectionTracking = .automatic, renderer: Rendering = Renderer() ) async throws -> Int where Updates.Element == TableData { let tableData = createTableData(headers: headers, rows: rows) @@ -922,15 +927,17 @@ public final class Noora: Noorable { tableData, updates: updates, pageSize: pageSize, + selectionTracking: selectionTracking, renderer: renderer ) } - public func selectableTable( + public func selectableTable( headers: [TableCellStyle], rows: [StyledTableRow], updates: Updates, pageSize: Int, + selectionTracking: TableSelectionTracking = .automatic, renderer: Rendering = Renderer() ) async throws -> Int where Updates.Element == TableData { let tableData = createStyledTableData(headers: headers, rows: rows) @@ -938,6 +945,7 @@ public final class Noora: Noorable { tableData, updates: updates, pageSize: pageSize, + selectionTracking: selectionTracking, renderer: renderer ) } @@ -1394,16 +1402,18 @@ extension Noorable { ) } - public func selectableTable( + public func selectableTable( _ data: TableData, updates: Updates, pageSize: Int, + selectionTracking: TableSelectionTracking = .automatic, renderer: Rendering = Renderer() ) async throws -> Int where Updates.Element == TableData { try await selectableTable( data, updates: updates, pageSize: pageSize, + selectionTracking: selectionTracking, renderer: renderer ) } diff --git a/cli/Sources/Noora/NooraMock.swift b/cli/Sources/Noora/NooraMock.swift index e24578de..070ec2cf 100644 --- a/cli/Sources/Noora/NooraMock.swift +++ b/cli/Sources/Noora/NooraMock.swift @@ -3,6 +3,8 @@ import Logging import Rainbow + // swiftlint:disable type_body_length + /// A test instance of `Noora` that records all standard output and error events /// for verification in tests. /// @@ -25,7 +27,6 @@ /// `description` contains all output made via Noora, with each line prefixed by the output type (`stdout`/`stderr`). /// ``` - // swiftlint:disable:next type_body_length public struct NooraMock: Noorable, CustomStringConvertible { @@ -373,16 +374,18 @@ ) } - public func selectableTable( + public func selectableTable( _ data: TableData, updates: Updates, pageSize: Int, + selectionTracking: TableSelectionTracking, renderer: Rendering ) async throws -> Int where Updates.Element == TableData { try await noora.selectableTable( data, updates: updates, pageSize: pageSize, + selectionTracking: selectionTracking, renderer: renderer ) } @@ -494,4 +497,6 @@ return String(reversedTrimmed.reversed()) } } + + // swiftlint:enable type_body_length #endif diff --git a/cli/Sources/Noora/Utilities/KeyStrokeListener.swift b/cli/Sources/Noora/Utilities/KeyStrokeListener.swift index 001b20fd..f6f66917 100644 --- a/cli/Sources/Noora/Utilities/KeyStrokeListener.swift +++ b/cli/Sources/Noora/Utilities/KeyStrokeListener.swift @@ -58,6 +58,7 @@ public struct KeyStrokeListener: KeyStrokeListening { public init() {} + // swiftlint:disable:next function_body_length public func listen(terminal: Terminaling, onKeyPress: @escaping (KeyStroke) -> OnKeyPressResult) { #if !os(Windows) var buffer = "" diff --git a/cli/Sources/examples-cli/Commands/TableCommand.swift b/cli/Sources/examples-cli/Commands/TableCommand.swift index caf4dab3..c32b8284 100644 --- a/cli/Sources/examples-cli/Commands/TableCommand.swift +++ b/cli/Sources/examples-cli/Commands/TableCommand.swift @@ -66,6 +66,36 @@ struct TableCommand: AsyncParsableCommand { } extension TableCommand { + private struct WiFi: Identifiable, Hashable { + let id: UUID + let ssid: String + let baseRSSI: Int + } + + private func wifiSnapshot( + active: [WiFi], + columns: [TableColumn], + rng: inout SystemRandomNumberGenerator + ) -> TableData { + let pairs: [(WiFi, Int)] = active.map { wifi in + let jitter = Int.random(in: -7 ... 5, using: &rng) + return (wifi, wifi.baseRSSI + jitter) + } + + let sorted = pairs.sorted { $0.1 > $1.1 } + let rows = sorted.map { wifi, reading in + TableRow( + [ + TerminalText(stringLiteral: wifi.ssid), + TerminalText(stringLiteral: "\(reading) dBm"), + ], + id: wifi.id + ) + } + + return TableData(columns: columns, rows: rows) + } + private func simpleStaticTable(_ noora: Noora) async { let headers = ["Name", "Role", "Status"] let rows = [ @@ -269,6 +299,7 @@ extension TableCommand { noora.table(headers: styledHeaders, rows: styledRows) } + // swiftlint:disable:next function_body_length private func liveUpdatingTable(_ noora: Noora) async { let columns = [ TableColumn(title: "SSID", width: .auto, alignment: .left), @@ -341,6 +372,7 @@ extension TableCommand { await noora.table(initial, updates: updates) } + // swiftlint:disable:next function_body_length private func selectableUpdatingTable(_ noora: Noora) async throws { let headers = ["SSID", "Signal"] let columns = [ @@ -348,43 +380,24 @@ extension TableCommand { TableColumn(title: headers[1], width: .auto, alignment: .right), ] - let seedNetworks: [String] = ["Home", "Office", "Cafe", "Library", "Station"] - let baseSignals: [String: Int] = [ - "Home": -40, - "Office": -65, - "Cafe": -72, - "Library": -55, - "Station": -80, - "Airport": -70, - "Bus": -78, - "Event": -66, - "Hotel": -62, + let allNetworks: [WiFi] = [ + WiFi(id: UUID(), ssid: "Home", baseRSSI: -40), + WiFi(id: UUID(), ssid: "Office", baseRSSI: -65), + WiFi(id: UUID(), ssid: "Cafe", baseRSSI: -72), + WiFi(id: UUID(), ssid: "Cafe", baseRSSI: -60), + WiFi(id: UUID(), ssid: "Library", baseRSSI: -55), + WiFi(id: UUID(), ssid: "Station", baseRSSI: -80), + WiFi(id: UUID(), ssid: "Airport", baseRSSI: -70), + WiFi(id: UUID(), ssid: "Bus", baseRSSI: -78), + WiFi(id: UUID(), ssid: "Event", baseRSSI: -66), + WiFi(id: UUID(), ssid: "Hotel", baseRSSI: -62), ] - func snapshot( - active: [String], - rng: inout SystemRandomNumberGenerator - ) -> TableData { - let pairs: [(String, Int)] = active.compactMap { name in - guard let base = baseSignals[name] else { return nil } - let jitter = Int.random(in: -7 ... 5, using: &rng) - return (name, base + jitter) - } - - let sorted = pairs.sorted { $0.1 > $1.1 } - let rows = sorted.map { name, reading in - [ - TerminalText(stringLiteral: name), - TerminalText(stringLiteral: "\(reading) dBm"), - ] - } - - return TableData(columns: columns, rows: rows) - } + let seedNetworks = Array(allNetworks.prefix(5)) var rng = SystemRandomNumberGenerator() - let initialData = snapshot(active: seedNetworks, rng: &rng) - noora.info("Live Wi-Fi scan (updates). Use arrows/Enter to pick while it updates. Esc to cancel.") + let initialData = wifiSnapshot(active: seedNetworks, columns: columns, rng: &rng) + noora.info("Live Wi-Fi scan (updates, duplicate SSIDs). Use arrows/Enter to pick while it updates. Esc to cancel.") var latestData = initialData let snapshotQueue = DispatchQueue(label: "live-selectable-table-snapshot") @@ -399,14 +412,16 @@ extension TableCommand { active.remove(at: Int.random(in: 0 ..< active.count, using: &rng)) } - let available = baseSignals.keys.filter { !active.contains($0) } + let available = allNetworks.filter { candidate in + !active.contains(where: { $0.id == candidate.id }) + } if !available.isEmpty, Int.random(in: 0 ... 2, using: &rng) == 0, - let newName = available.randomElement(using: &rng) + let newNetwork = available.randomElement(using: &rng) { - active.append(newName) + active.append(newNetwork) } - let tableData = snapshot(active: active, rng: &rng) + let tableData = wifiSnapshot(active: active, columns: columns, rng: &rng) snapshotQueue.sync { latestData = tableData } @@ -439,7 +454,13 @@ extension TableCommand { let name = row.first?.plain() ?? "Unknown" let signal = row.dropFirst().first?.plain() ?? "" let suffix = signal.isEmpty ? "" : " (\(signal))" - print("Selected network: \(name)\(suffix)") + let idSuffix: String + if let id = finalData.rows[selectedIndex].id.unwrap(UUID.self) { + idSuffix = " [\(id.uuidString.prefix(6))]" + } else { + idSuffix = "" + } + print("Selected network: \(name)\(suffix)\(idSuffix)") } else { print("Selected row: \(selectedIndex)") } diff --git a/cli/Tests/NooraTests/Components/MultipleChoicePromptTests.swift b/cli/Tests/NooraTests/Components/MultipleChoicePromptTests.swift index b2655fc1..77a9a8e9 100644 --- a/cli/Tests/NooraTests/Components/MultipleChoicePromptTests.swift +++ b/cli/Tests/NooraTests/Components/MultipleChoicePromptTests.swift @@ -15,7 +15,7 @@ struct MultipleChoicePromptTests { } let renderer = MockRenderer() - let terminal = MockTerminal(size: .init(rows: 10, columns: 80)) + let terminal = MockTerminal(isColored: false, size: .init(rows: 10, columns: 80)) let keyStrokeListener = MockKeyStrokeListener() @Test func renders_the_right_content() throws { diff --git a/cli/Tests/NooraTests/Components/TableTests.swift b/cli/Tests/NooraTests/Components/TableTests.swift index 67309dd2..b33ba868 100644 --- a/cli/Tests/NooraTests/Components/TableTests.swift +++ b/cli/Tests/NooraTests/Components/TableTests.swift @@ -141,7 +141,7 @@ struct TableTests { let standardPipelines = StandardPipelines(output: standardOutput, error: standardError) keyStrokeListener.keyPressStub.withValue { $0 = [.downArrowKey, .returnKey] } - keyStrokeListener.delay.withValue { $0 = 0.05 } + keyStrokeListener.delay.withValue { $0 = 0.2 } defer { keyStrokeListener.delay.withValue { $0 = 0 } keyStrokeListener.keyPressStub.withValue { $0 = [] } @@ -152,6 +152,7 @@ struct TableTests { updates: updates, style: TableStyle(theme: .test()), pageSize: 5, + selectionTracking: .index, renderer: renderer, standardPipelines: standardPipelines, terminal: terminal, @@ -169,6 +170,128 @@ struct TableTests { #expect(renderer.renders.last?.contains("Cafe") == true) } + @Test func updating_selectable_table_tracks_selection_on_reorder() async throws { + // Given + let columns = [ + TableColumn(title: TerminalText(stringLiteral: "SSID"), width: .auto, alignment: .left), + TableColumn(title: TerminalText(stringLiteral: "Signal"), width: .auto, alignment: .right), + ] + + let initialData = TableData(columns: columns, rows: [ + [TerminalText(stringLiteral: "Alpha"), TerminalText(stringLiteral: "-40 dBm")], + [TerminalText(stringLiteral: "Bravo"), TerminalText(stringLiteral: "-65 dBm")], + [TerminalText(stringLiteral: "Charlie"), TerminalText(stringLiteral: "-72 dBm")], + ]) + + let updatedData = TableData(columns: columns, rows: [ + [TerminalText(stringLiteral: "Bravo"), TerminalText(stringLiteral: "-60 dBm")], + [TerminalText(stringLiteral: "Alpha"), TerminalText(stringLiteral: "-42 dBm")], + [TerminalText(stringLiteral: "Charlie"), TerminalText(stringLiteral: "-70 dBm")], + ]) + + let updates = AsyncStream { continuation in + Task { + try await Task.sleep(for: .milliseconds(300)) + continuation.yield(updatedData) + continuation.finish() + } + } + + let standardOutput = MockStandardPipeline() + let standardError = MockStandardPipeline() + let standardPipelines = StandardPipelines(output: standardOutput, error: standardError) + + keyStrokeListener.keyPressStub.withValue { $0 = [.downArrowKey, .returnKey] } + keyStrokeListener.delay.withValue { $0 = 0.2 } + defer { + keyStrokeListener.delay.withValue { $0 = 0 } + keyStrokeListener.keyPressStub.withValue { $0 = [] } + } + + let subject = UpdatingSelectableTable( + initialData: initialData, + updates: updates, + style: TableStyle(theme: .test()), + pageSize: 5, + selectionTracking: .automatic, + renderer: renderer, + standardPipelines: standardPipelines, + terminal: terminal, + theme: theme, + keyStrokeListener: keyStrokeListener, + logger: logger, + tableRenderer: TableRenderer() + ) + + // When + let selectedIndex = try await subject.run() + + // Then + #expect(selectedIndex == 0) + } + + @Test func updating_selectable_table_tracks_selection_with_row_ids() async throws { + // Given + let columns = [ + TableColumn(title: TerminalText(stringLiteral: "SSID"), width: .auto, alignment: .left), + TableColumn(title: TerminalText(stringLiteral: "Signal"), width: .auto, alignment: .right), + ] + + let rowIDs = ["wifi-1", "wifi-2", "wifi-3"] + + let initialData = TableData(columns: columns, rows: [ + TableRow([TerminalText(stringLiteral: "Cafe"), TerminalText(stringLiteral: "-40 dBm")], id: rowIDs[0]), + TableRow([TerminalText(stringLiteral: "Cafe"), TerminalText(stringLiteral: "-60 dBm")], id: rowIDs[1]), + TableRow([TerminalText(stringLiteral: "Home"), TerminalText(stringLiteral: "-70 dBm")], id: rowIDs[2]), + ]) + + let updatedData = TableData(columns: columns, rows: [ + TableRow([TerminalText(stringLiteral: "Home"), TerminalText(stringLiteral: "-68 dBm")], id: rowIDs[2]), + TableRow([TerminalText(stringLiteral: "Cafe"), TerminalText(stringLiteral: "-60 dBm")], id: rowIDs[1]), + TableRow([TerminalText(stringLiteral: "Cafe"), TerminalText(stringLiteral: "-41 dBm")], id: rowIDs[0]), + ]) + + let updates = AsyncStream { continuation in + Task { + try await Task.sleep(for: .milliseconds(100)) + continuation.yield(updatedData) + continuation.finish() + } + } + + let standardOutput = MockStandardPipeline() + let standardError = MockStandardPipeline() + let standardPipelines = StandardPipelines(output: standardOutput, error: standardError) + + keyStrokeListener.keyPressStub.withValue { $0 = [.returnKey] } + keyStrokeListener.delay.withValue { $0 = 0.25 } + defer { + keyStrokeListener.delay.withValue { $0 = 0 } + keyStrokeListener.keyPressStub.withValue { $0 = [] } + } + + let subject = UpdatingSelectableTable( + initialData: initialData, + updates: updates, + style: TableStyle(theme: .test()), + pageSize: 5, + selectionTracking: .automatic, + renderer: renderer, + standardPipelines: standardPipelines, + terminal: terminal, + theme: theme, + keyStrokeListener: keyStrokeListener, + logger: logger, + tableRenderer: TableRenderer() + ) + + // When + let selectedIndex = try await subject.run() + + // Then + #expect(selectedIndex == 2) + } + @Test func interactive_table_error_handling() throws { // Given let nonInteractiveTerminal = MockTerminal(isInteractive: false) diff --git a/cli/Tests/NooraTests/Validator/ValidatorTests.swift b/cli/Tests/NooraTests/Validator/ValidatorTests.swift index 1e6c3a6d..f9619e27 100644 --- a/cli/Tests/NooraTests/Validator/ValidatorTests.swift +++ b/cli/Tests/NooraTests/Validator/ValidatorTests.swift @@ -14,7 +14,7 @@ struct ValidatorTests { // then switch result { case .success: - #expect(true) + #expect(Bool(true)) case .failure: Issue.record("The result must be equal to success.") } @@ -54,7 +54,7 @@ struct ValidatorTests { // then switch result { case .success: - #expect(true) + #expect(Bool(true)) case .failure: Issue.record("The result must be equal to success.") } diff --git a/docs/content/components/tables/updating.md b/docs/content/components/tables/updating.md index c6559eb4..c138ef72 100644 --- a/docs/content/components/tables/updating.md +++ b/docs/content/components/tables/updating.md @@ -73,6 +73,34 @@ let selectedRow = latest.rows[selectedIndex] print("Picked network: \(selectedRow[0].plain())") ``` +By default, selection tracks the row identifier (which falls back to the first column's text). Pass `.index` if you prefer the previous behavior of keeping the index fixed during reorders, or `.rowKey` to customize the key. + +If you have duplicate names, keep selection stable with `Identifiable` models: + +```swift +struct WiFi: Identifiable { + let id: UUID + let ssid: String + let rssi: Int +} + +let table = TableData( + networks, + columns: columns, + rows: [ + TerminalRow { TerminalText(stringLiteral: $0.ssid) }, + TerminalRow { TerminalText(stringLiteral: "\($0.rssi) dBm") } + ] +) + +let selectedIndex = try await Noora().selectableTable( + table, + updates: updates, + pageSize: 8 +) +let selectedWiFiID = table.rows[selectedIndex].id.unwrap(UUID.self) +``` + ### Options #### Updating table method @@ -90,4 +118,5 @@ print("Picked network: \(selectedRow[0].plain())") | `data` | Initial `TableData` to render | Yes | | | `updates` | Async sequence emitting `TableData` with the latest rows | Yes | | | `pageSize` | Number of visible rows | Yes | | +| `selectionTracking` | Controls whether selection tracks by index or by a row key | No | `.automatic` | | `renderer` | Rendering interface that holds UI state | No | `Renderer()` | diff --git a/mise/tasks/build b/mise/tasks/build index 6631e0d5..f0cb6c9d 100755 --- a/mise/tasks/build +++ b/mise/tasks/build @@ -1,5 +1,5 @@ #!/bin/bash -# mise description="Build all packages" +#MISE description="Build all packages" set -euo pipefail mise run cli:build diff --git a/mise/tasks/cli/build b/mise/tasks/cli/build index c198fdf4..a1c7e4f1 100755 --- a/mise/tasks/cli/build +++ b/mise/tasks/cli/build @@ -1,5 +1,5 @@ #!/bin/bash -# mise description="Build the CLI package using Swift Package Manager" +#MISE description="Build the CLI package using Swift Package Manager" set -euo pipefail swift build --package-path cli --configuration release diff --git a/mise/tasks/cli/build-linux b/mise/tasks/cli/build-linux index f589318e..05d152a5 100755 --- a/mise/tasks/cli/build-linux +++ b/mise/tasks/cli/build-linux @@ -1,5 +1,5 @@ #!/bin/bash -# mise description="Builds the CLI package using Swift Package Manager in Linux" +#MISE description="Builds the CLI package using Swift Package Manager in Linux" set -euo pipefail # Determine container runtime (docker or podman) diff --git a/mise/tasks/cli/lint b/mise/tasks/cli/lint index 2c9488a0..0b1e97b4 100755 --- a/mise/tasks/cli/lint +++ b/mise/tasks/cli/lint @@ -1,5 +1,5 @@ #!/bin/bash -# mise description="Lint the CLI package using SwiftLint and SwiftFormat" +#MISE description="Lint the CLI package using SwiftLint and SwiftFormat" #USAGE flag "-f --fix" help="Fix the fixable issues" set -eo pipefail diff --git a/mise/tasks/cli/test b/mise/tasks/cli/test index 879b964e..e60e5b38 100755 --- a/mise/tasks/cli/test +++ b/mise/tasks/cli/test @@ -1,5 +1,5 @@ #!/bin/bash -# mise description="Test the CLI package using Swift Package Manager" +#MISE description="Test the CLI package using Swift Package Manager" set -euo pipefail diff --git a/mise/tasks/cli/test-linux b/mise/tasks/cli/test-linux index 006dc043..7d5889aa 100755 --- a/mise/tasks/cli/test-linux +++ b/mise/tasks/cli/test-linux @@ -1,5 +1,5 @@ #!/bin/bash -# mise description="Tests the CLI package using Swift Package Manager in Linux" +#MISE description="Tests the CLI package using Swift Package Manager in Linux" set -euo pipefail # Determine container runtime (docker or podman) diff --git a/mise/tasks/docs/build b/mise/tasks/docs/build index e91b639f..e3b3e031 100755 --- a/mise/tasks/docs/build +++ b/mise/tasks/docs/build @@ -1,5 +1,5 @@ #!/bin/bash -# mise description="Build the website" +#MISE description="Build the website" set -euo pipefail pnpm run -C docs build diff --git a/mise/tasks/docs/deploy b/mise/tasks/docs/deploy index 99603c77..d5a096cf 100755 --- a/mise/tasks/docs/deploy +++ b/mise/tasks/docs/deploy @@ -1,5 +1,5 @@ #!/bin/bash -# mise description="Deploy the website" +#MISE description="Deploy the website" set -euo pipefail pnpm run -C docs deploy diff --git a/mise/tasks/docs/dev b/mise/tasks/docs/dev index 4e734d82..b0026c49 100755 --- a/mise/tasks/docs/dev +++ b/mise/tasks/docs/dev @@ -1,5 +1,5 @@ #!/bin/bash -# mise description="Dev the website" +#MISE description="Dev the website" set -euo pipefail pnpm run -C docs dev diff --git a/mise/tasks/lint b/mise/tasks/lint index a63eb1be..c5dfd603 100755 --- a/mise/tasks/lint +++ b/mise/tasks/lint @@ -1,5 +1,5 @@ #!/bin/bash -# mise description="Lint all packages" +#MISE description="Lint all packages" #USAGE flag "-f --fix" help="Fix the fixable issues" set -eo pipefail diff --git a/mise/tasks/test b/mise/tasks/test index b6c87f5b..e6585afe 100755 --- a/mise/tasks/test +++ b/mise/tasks/test @@ -1,5 +1,5 @@ #!/bin/bash -# mise description="Test all packages" +#MISE description="Test all packages" set -euo pipefail diff --git a/mise/tasks/web/build b/mise/tasks/web/build index c772f78f..0df67738 100755 --- a/mise/tasks/web/build +++ b/mise/tasks/web/build @@ -1,5 +1,5 @@ #!/bin/bash -# mise description="Build the web package using Mix and esbuild" +#MISE description="Build the web package using Mix and esbuild" set -euo pipefail cd web diff --git a/mise/tasks/web/docs b/mise/tasks/web/docs index 372542bb..30f4c43e 100755 --- a/mise/tasks/web/docs +++ b/mise/tasks/web/docs @@ -1,5 +1,5 @@ #!/bin/bash -# mise description="Generate documentation for the web package using ExDoc" +#MISE description="Generate documentation for the web package using ExDoc" set -euo pipefail diff --git a/mise/tasks/web/test.sh b/mise/tasks/web/test.sh index 0e6b9ce4..8922b6b0 100755 --- a/mise/tasks/web/test.sh +++ b/mise/tasks/web/test.sh @@ -1,5 +1,5 @@ #!/bin/bash -# mise description="Runs the tests for the web package" +#MISE description="Runs the tests for the web package" set -euo pipefail pnpm -C web run test