Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions App/Composition/ListsEventBus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ enum ListsEvent: Sendable, Equatable {
/// A watcher was removed from `listId`.
case watcherRemoved(listId: String, userId: String)

/// A list's saved views changed — one was created, renamed, forked,
/// deleted, or made the default (work-consolidation.md G40 / issue #81).
///
/// Carries the whole post-write collection rather than a single row
/// because the writes are not independent: marking a view as the default
/// clears the previous default, so a per-row event would leave a second
/// window showing two defaults at once.
case savedViewsChanged(listId: String, views: [SavedListView])

/// A connection was created. The graph view appends.
case connectionAdded(ListConnection)

Expand Down
59 changes: 56 additions & 3 deletions App/Features/Lists/ListRowsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,49 @@ struct ListRowsView: View {
/// (work-consolidation.md G16).
@State private var showsCreateFrom = false

/// Drives the saved-views menu (work-consolidation.md G40). Built here
/// rather than by the parent so the control ships with the rows pane it
/// arranges — and so a list opened from the "Shared with me" section gets
/// one too, which is the case the feature exists for.
@State private var savedViewsViewModel: SavedViewsViewModel?

var body: some View {
content(viewModel: viewModel)
.navigationTitle(list.title)
.task(id: viewModel.listId) {
guard let environment else { return }
let model = SavedViewsViewModel(
lists: environment.lists,
eventBus: environment.listsEventBus,
listId: viewModel.listId
)
savedViewsViewModel = model
// `load()` applies the caller's `isDefault` view, so the list
// opens in the arrangement that person chose — which on a
// shared list is not the same as the one the owner chose.
await model.load()
await subscribeSavedViews(model: model, bus: environment.listsEventBus)
}
}

/// Cross-window sync for saved-view writes. `[weak model]` per the project
/// rule: Swift 6 Observation does not guarantee `deinit`-time cancellation,
/// so the subscriber must not keep the view model alive by itself.
private func subscribeSavedViews(model: SavedViewsViewModel, bus: ListsEventBus) async {
Task { [weak model] in
for await event in bus.events() {
guard let model else { return }
model.apply(event: event)
}
}
}

/// How many lines a table / card cell renders. The one `config` value a
/// saved view stores that has a visible effect in this client: the server
/// normalises `mode` to `records` and drops every column/sort key, so
/// `density` is the whole of "applying a view" today (issue #81).
private var cellLineLimit: Int {
savedViewsViewModel?.appliedDensity == .compact ? 1 : 2
}

@ViewBuilder
Expand Down Expand Up @@ -187,6 +227,13 @@ struct ListRowsView: View {
.disabled(selection.isEmpty)
.help("Turn the selected rows into a new list or document")

// Saved views sit next to the view-mode picker because both
// change how these rows are arranged — one per session, one saved
// and shareable (work-consolidation.md G40).
if let savedViewsViewModel {
SavedViewsControl(viewModel: savedViewsViewModel, isReadOnly: isReadOnly)
}

Spacer()

Picker("View", selection: Binding(
Expand Down Expand Up @@ -216,10 +263,16 @@ struct ListRowsView: View {
VStack(spacing: 0) {
Table(viewModel.rows, selection: $selection) {
TableColumnForEach(columns) { column in
// Header from `label`, cell lookup by `key` — see `ListColumn`.
// Header from `label`, cell lookup by `key` — see `ListColumn`
// (#50) — at the row height the active saved view asks for
// (G40). Both landed on this line; they compose, and taking
// either alone loses something real: dropping `ListColumn`
// reintroduces the empty-cell bug for a column whose key
// differs from its label, and dropping `cellLineLimit`
// silently ignores the view's density.
TableColumn(column.label) { (row: ListRow) in
Text(row.fields[column.key]?.displayText ?? "")
.lineLimit(2)
.lineLimit(cellLineLimit)
}
}
}
Expand Down Expand Up @@ -405,7 +458,7 @@ struct ListRowsView: View {
.foregroundStyle(.secondary)
Text(row.fields[column.key]?.displayText ?? "")
.font(.ilBody())
.lineLimit(2)
.lineLimit(cellLineLimit)
Spacer()
}
}
Expand Down
5 changes: 3 additions & 2 deletions App/Features/Lists/OwnedListsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,11 @@ final class OwnedListsViewModel {
lists_loaded.removeAll { $0.id == id }
if selectedListID == id { selectedListID = nil }
case .rowCreated, .rowUpdated, .rowDeleted,
.schemaChanged,
.schemaChanged, .savedViewsChanged,
.watcherChanged, .watcherRemoved,
.connectionAdded, .connectionRemoved:
// Sidebar-level view model only tracks list-level events.
// Sidebar-level view model only tracks list-level events. Saved
// views arrange the rows pane, not the sidebar row.
break
}
}
Expand Down
Loading