Skip to content

Commit 84632c0

Browse files
authored
Merge pull request #3 from cozyGarage/feat/schema-review-views
feat(app): list PostgreSQL views in the sidebar through the policy guard
2 parents c9f0ce4 + 7a4546b commit 84632c0

18 files changed

Lines changed: 325 additions & 23 deletions

File tree

PLAN.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,14 @@ environment colour on each saved-connection row. Slice 10.3 has a
541541
capability-declared TSV activity dialog whose in-flight query is cancelled when
542542
the dialog closes. The typed sessions console and blocking trees are not
543543
started. Structure edit tabs now restore with the rest of the workspace.
544-
Slices 10.4 through 10.7 are not started.
544+
Slice 10.6 has started: PostgreSQL views list through the guard and appear
545+
in the sidebar as read-only objects. Materialized views, routines, triggers,
546+
sequences, extensions, roles, and grants are not started. Slices 10.4, 10.5,
547+
and 10.7 are not started.
548+
549+
Which macOS 0.68–0.69 behavior we reimplement, and in what order, is recorded
550+
in `linux/docs/upstream-adoption.md`. Review their behavior; do not merge
551+
their source.
545552

546553
The product is strong on safety and thin on operations. A DBA who manages many
547554
servers gets one active connection per process, an activity dialog that renders
@@ -661,6 +668,11 @@ sequences, extensions, roles, and grants. The connection trait gains methods
661668
whose defaults return nothing, matching the existing index and foreign-key
662669
pattern, so drivers opt in without breaking. PostgreSQL implements all of them.
663670

671+
`list_views` is the first method. PostgreSQL reads `pg_views` through the
672+
guard. The sidebar shows those rows under Views and offers Open only. A
673+
failed list does not look like an empty catalog. The remaining object kinds
674+
follow the same shape.
675+
664676
Database metadata is untrusted input. Identifiers are dialect-quoted and never
665677
joined into SQL as text.
666678

linux/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Added
66

7+
- PostgreSQL views appear in the sidebar as read-only objects, listed through the same policy and timeout path as tables
78
- Saved connections can name a certificate authority, so a server whose certificate is issued privately can be verified with Verify Ca or Verify Full
89
- Policy crate with AST SQL classification, PolicyGuard, blast-radius rewrite, column masking, and policy.toml. Statements that read host files, run a program, or send SQL to another server are treated as administrative, so an agent is refused and a read-only connection denies them
910
- Environment field on saved connections (Local / Dev / Staging / Prod)

linux/ROADMAP.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,13 @@ The repository extraction completed on 2026-08-17. Product planning now follows
156156
- [ ] A typed sessions and locks console with capability-declared driver support and governed session termination
157157
- [ ] A PostgreSQL server health panel that degrades cleanly when a statistics extension is absent
158158
- [ ] Configurable pool size and timeouts per saved connection, honoured by the driver
159-
- [ ] Read-only review of views, routines, triggers, sequences, extensions, roles, and grants
159+
- [ ] Read-only review of views, routines, triggers, sequences, extensions, roles, and grants (PostgreSQL views are listing)
160160
- [ ] A decision record, design, and measured prototype for an out-of-process Python runner
161161

162162
Phase 10 is in progress. Slice 10.2 added connection organisation: groups, tags, favourites, search across name/group/tag/driver, and URL import whose password reaches the keyring and never the saved file. Its first slice retired the one-active-connection limit: activation is additive and every window owns and releases its own connection, proven by two windows writing to two databases in the installed suite. Every connection it exposes stays policy-guarded, and no slice ships DDL or server configuration writes.
163163

164164
## Next implementation target
165165

166166
The immediate target is the internal Arch RC. The exact-commit hosted jobs first ran fully green on 2026-08-21 at `c8f91f06`, so the Phase 4 soak ledger has started and needs 30 consecutive retry-free attempts across at least six runs. What remains is accumulating that ledger and verifying install/upgrade/rollback on Wayland. Those gates are mostly waiting, so Phase 10 feature work runs in parallel on `linux` while the candidate stays frozen on a release branch. Phase 10 comes before full-table snapshot export and Phase 6 object administration; new drivers come after both.
167+
168+
Which macOS features we take, skip, and in what order is in [docs/upstream-adoption.md](docs/upstream-adoption.md). The next product slice after views is the typed activity console (10.3), then the rest of 10.6.

linux/crates/app/src/services/connection_service.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use super::database_service::{self, ConnectionMetadata, ReconnectParams};
1414
/// the existing workspace and connection untouched.
1515
pub struct PreparedConnection {
1616
pub tables: Vec<tablepro_core::TableInfo>,
17+
pub views: Vec<tablepro_core::TableInfo>,
1718
pub driver_id: String,
1819
id: uuid::Uuid,
1920
metadata: ConnectionMetadata,
@@ -29,19 +30,22 @@ impl std::fmt::Debug for PreparedConnection {
2930
.field("id", &self.id)
3031
.field("driver_id", &self.driver_id)
3132
.field("table_count", &self.tables.len())
33+
.field("view_count", &self.views.len())
3234
.finish_non_exhaustive()
3335
}
3436
}
3537

3638
pub struct ActivatedConnection {
3739
pub id: Uuid,
3840
pub tables: Vec<tablepro_core::TableInfo>,
41+
pub views: Vec<tablepro_core::TableInfo>,
3942
pub driver_id: String,
4043
}
4144

4245
impl PreparedConnection {
4346
pub(crate) fn new(
4447
tables: Vec<tablepro_core::TableInfo>,
48+
views: Vec<tablepro_core::TableInfo>,
4549
driver_id: String,
4650
metadata: ConnectionMetadata,
4751
connection: Box<dyn Connection>,
@@ -50,6 +54,7 @@ impl PreparedConnection {
5054
) -> Self {
5155
Self {
5256
tables,
57+
views,
5358
driver_id,
5459
id: metadata.id,
5560
read_only: metadata.read_only,
@@ -73,6 +78,7 @@ impl PreparedConnection {
7378
ActivatedConnection {
7479
id,
7580
tables: self.tables,
81+
views: self.views,
7682
driver_id: self.driver_id,
7783
}
7884
}
@@ -100,6 +106,10 @@ pub async fn open_saved(
100106
.list_tables_controlled(&control)
101107
.await
102108
.map_err(|e| format!("list_tables: {e}"))?;
109+
let views = conn
110+
.list_views_controlled(&control)
111+
.await
112+
.map_err(|e| format!("list_views: {e}"))?;
103113
let metadata = ConnectionMetadata {
104114
id,
105115
name: saved.name.clone(),
@@ -115,6 +125,7 @@ pub async fn open_saved(
115125
};
116126
Ok(PreparedConnection::new(
117127
tables,
128+
views,
118129
saved.driver_id,
119130
metadata,
120131
conn,

linux/crates/app/src/ui/app/connection.rs

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@ impl App {
4848
activated: connection_service::ActivatedConnection,
4949
sender: ComponentSender<Self>,
5050
) {
51-
let connection_service::ActivatedConnection { id, tables, driver_id } = activated;
51+
let connection_service::ActivatedConnection {
52+
id,
53+
tables,
54+
views,
55+
driver_id,
56+
} = activated;
5257
self.dismiss_loading_page();
5358
self.dialog = None;
5459
self.connected = true;
@@ -64,8 +69,14 @@ impl App {
6469
// a tab via sidebar click or Ctrl+T.
6570
self.ensure_workspace_root(sender.clone());
6671
self.content_holder.set_content(Some(&self.workspace_outer_stack));
67-
self.table_names = tables.iter().map(|t| t.name.clone()).collect();
68-
tracing::info!(driver = %driver_id, table_count = tables.len(), "workspace ready");
72+
self.table_names = tables.iter().chain(views.iter()).map(|t| t.name.clone()).collect();
73+
tracing::info!(
74+
driver = %driver_id,
75+
table_count = tables.len(),
76+
view_count = views.len(),
77+
"workspace ready"
78+
);
79+
self.sidebar_views = views;
6980
self.repopulate_sidebar(&tables);
7081
self.rebuild_schema_buffer();
7182
self.refresh_window_title();
@@ -164,6 +175,8 @@ impl App {
164175
self.refresh_window_title();
165176
self.table_search.set_text("");
166177
self.sidebar_schemas.borrow_mut().clear();
178+
self.sidebar_kinds.borrow_mut().clear();
179+
self.sidebar_views.clear();
167180
self.sidebar_factory.guard().clear();
168181
self.show_welcome_page(sender);
169182
tracing::info!("disconnected");
@@ -518,11 +531,33 @@ impl App {
518531
let mut schemas = self.sidebar_schemas.borrow_mut();
519532
schemas.clear();
520533
schemas.extend(tables.iter().map(|t| t.schema.clone()));
534+
schemas.extend(self.sidebar_views.iter().map(|t| t.schema.clone()));
535+
}
536+
{
537+
let mut kinds = self.sidebar_kinds.borrow_mut();
538+
kinds.clear();
539+
kinds.extend(std::iter::repeat_n(
540+
crate::ui::sidebar_row::SidebarObjectKind::Table,
541+
tables.len(),
542+
));
543+
kinds.extend(std::iter::repeat_n(
544+
crate::ui::sidebar_row::SidebarObjectKind::View,
545+
self.sidebar_views.len(),
546+
));
521547
}
522548
let mut guard = self.sidebar_factory.guard();
523549
guard.clear();
524550
for table in tables {
525-
guard.push_back(table.clone());
551+
guard.push_back(crate::ui::sidebar_row::SidebarRowInit {
552+
info: table.clone(),
553+
kind: crate::ui::sidebar_row::SidebarObjectKind::Table,
554+
});
555+
}
556+
for view in &self.sidebar_views {
557+
guard.push_back(crate::ui::sidebar_row::SidebarRowInit {
558+
info: view.clone(),
559+
kind: crate::ui::sidebar_row::SidebarObjectKind::View,
560+
});
526561
}
527562
drop(guard);
528563
self.sidebar_factory.widget().invalidate_headers();

linux/crates/app/src/ui/app/init_sidebar.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,19 @@ use relm4::prelude::*;
77
use relm4::{adw, gtk};
88

99
use super::{App, AppMsg, AppWidgets, OpenMode};
10-
use crate::ui::sidebar_row::{SidebarRow, SidebarRowOutput};
10+
use crate::ui::sidebar_row::{SidebarObjectKind, SidebarRow, SidebarRowOutput};
1111

1212
/// The sidebar's factory plus the parallel schema list its filter,
1313
/// header and selection code index by row position.
1414
pub(super) struct SidebarParts {
1515
pub(super) factory: FactoryVecDeque<SidebarRow>,
1616
pub(super) schemas: Rc<RefCell<Vec<Option<String>>>>,
17+
pub(super) kinds: Rc<RefCell<Vec<SidebarObjectKind>>>,
1718
}
1819

1920
pub(super) fn build_sidebar(widgets: &AppWidgets, sender: &ComponentSender<App>) -> SidebarParts {
2021
let sidebar_schemas: Rc<RefCell<Vec<Option<String>>>> = Rc::new(RefCell::new(Vec::new()));
22+
let sidebar_kinds: Rc<RefCell<Vec<SidebarObjectKind>>> = Rc::new(RefCell::new(Vec::new()));
2123

2224
let sidebar_factory: FactoryVecDeque<SidebarRow> = FactoryVecDeque::builder()
2325
.launch(
@@ -133,9 +135,32 @@ pub(super) fn build_sidebar(widgets: &AppWidgets, sender: &ComponentSender<App>)
133135
.build();
134136

135137
let schemas_for_header = sidebar_schemas.clone();
138+
let kinds_for_header = sidebar_kinds.clone();
136139
let sender_for_header = sender.clone();
137140
sidebar_listbox.set_header_func(move |row, before| {
138141
let schemas = schemas_for_header.borrow();
142+
let kinds = kinds_for_header.borrow();
143+
let idx = row.index() as usize;
144+
let current_kind = kinds.get(idx).copied();
145+
let prev_kind = before.and_then(|b| kinds.get(b.index() as usize).copied());
146+
if current_kind == Some(SidebarObjectKind::View) && prev_kind != Some(SidebarObjectKind::View) {
147+
let header = gtk::Label::builder()
148+
.label(crate::tr!("Views"))
149+
.xalign(0.0)
150+
.margin_top(12)
151+
.margin_bottom(6)
152+
.margin_start(12)
153+
.margin_end(12)
154+
.build();
155+
header.add_css_class("caption-heading");
156+
header.add_css_class("dim-label");
157+
row.set_header(Some(&header));
158+
return;
159+
}
160+
if current_kind == Some(SidebarObjectKind::View) {
161+
row.set_header(gtk::Widget::NONE);
162+
return;
163+
}
139164
let total_distinct: std::collections::BTreeSet<&str> = schemas.iter().filter_map(|s| s.as_deref()).collect();
140165
// Postgres-style multi-schema connections render a header
141166
// per schema with a "+" button for "New Table…". Single-
@@ -214,5 +239,6 @@ pub(super) fn build_sidebar(widgets: &AppWidgets, sender: &ComponentSender<App>)
214239
SidebarParts {
215240
factory: sidebar_factory,
216241
schemas: sidebar_schemas,
242+
kinds: sidebar_kinds,
217243
}
218244
}

linux/crates/app/src/ui/app/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ pub struct App {
7373
disconnect_action: gio::SimpleAction,
7474
sidebar_factory: FactoryVecDeque<SidebarRow>,
7575
sidebar_schemas: std::rc::Rc<std::cell::RefCell<Vec<Option<String>>>>,
76+
sidebar_kinds: std::rc::Rc<std::cell::RefCell<Vec<crate::ui::sidebar_row::SidebarObjectKind>>>,
77+
sidebar_views: Vec<tablepro_core::TableInfo>,
7678
content_holder: adw::ToolbarView,
7779
toast_overlay: adw::ToastOverlay,
7880
/// Persistent "Connecting…" toast handle. Held so we can dismiss it
@@ -411,6 +413,8 @@ impl SimpleComponent for App {
411413
disconnect_action,
412414
sidebar_factory: sidebar.factory,
413415
sidebar_schemas: sidebar.schemas,
416+
sidebar_kinds: sidebar.kinds,
417+
sidebar_views: Vec::new(),
414418
content_holder: widgets.content_holder.clone(),
415419
toast_overlay: widgets.toast_overlay.clone(),
416420
connect_progress_toast: None,

linux/crates/app/src/ui/connect_dialog/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,10 @@ async fn run_connect(request: ConnectRequest) -> Result<connection_service::Prep
785785
.list_tables_controlled(&control)
786786
.await
787787
.map_err(|e| format!("list_tables: {e}"))?;
788+
let views = conn
789+
.list_views_controlled(&control)
790+
.await
791+
.map_err(|e| format!("list_views: {e}"))?;
788792

789793
let existing = find_existing(&driver_id, &opts_clone, driver.is_file_based(), ssh.as_ref()).await;
790794
let id = existing
@@ -877,6 +881,7 @@ async fn run_connect(request: ConnectRequest) -> Result<connection_service::Prep
877881
};
878882
Ok(connection_service::PreparedConnection::new(
879883
tables,
884+
views,
880885
saved.driver_id.clone(),
881886
metadata,
882887
conn,

linux/crates/app/src/ui/sidebar_row.rs

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,29 @@ use relm4::gtk::prelude::*;
77

88
use tablepro_core::TableInfo;
99

10+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+
pub enum SidebarObjectKind {
12+
Table,
13+
View,
14+
}
15+
16+
#[derive(Debug, Clone)]
17+
pub struct SidebarRowInit {
18+
pub info: TableInfo,
19+
pub kind: SidebarObjectKind,
20+
}
21+
22+
fn sidebar_icon(kind: SidebarObjectKind) -> &'static str {
23+
match kind {
24+
SidebarObjectKind::Table => "view-list-symbolic",
25+
SidebarObjectKind::View => "view-paged-symbolic",
26+
}
27+
}
28+
1029
#[derive(Debug)]
1130
pub struct SidebarRow {
1231
pub info: TableInfo,
32+
kind: SidebarObjectKind,
1333
open_button: gtk::Button,
1434
/// The eagerly-parented context-menu popover. Held on the model
1535
/// so `shutdown` can `unparent()` it before the row's root widget
@@ -68,7 +88,7 @@ pub enum SidebarRowOutput {
6888

6989
#[relm4::factory(pub)]
7090
impl FactoryComponent for SidebarRow {
71-
type Init = TableInfo;
91+
type Init = SidebarRowInit;
7292
type Input = SidebarRowMsg;
7393
type Output = SidebarRowOutput;
7494
type CommandOutput = ();
@@ -106,7 +126,7 @@ impl FactoryComponent for SidebarRow {
106126
set_margin_bottom: 6,
107127

108128
gtk::Image {
109-
set_icon_name: Some("view-list-symbolic"),
129+
set_icon_name: Some(sidebar_icon(self.kind)),
110130
set_pixel_size: 16,
111131
},
112132

@@ -122,8 +142,8 @@ impl FactoryComponent for SidebarRow {
122142
}
123143
}
124144

125-
fn init_model(info: Self::Init, _index: &DynamicIndex, _sender: FactorySender<Self>) -> Self {
126-
let open_label = crate::tr!("Open {name}").replace("{name}", &info.name);
145+
fn init_model(init: Self::Init, _index: &DynamicIndex, _sender: FactorySender<Self>) -> Self {
146+
let open_label = crate::tr!("Open {name}").replace("{name}", &init.info.name);
127147
let open_button = gtk::Button::builder()
128148
.icon_name("go-next-symbolic")
129149
.tooltip_text(&open_label)
@@ -132,7 +152,8 @@ impl FactoryComponent for SidebarRow {
132152
.build();
133153
open_button.update_property(&[gtk::accessible::Property::Label(&open_label)]);
134154
Self {
135-
info,
155+
info: init.info,
156+
kind: init.kind,
136157
open_button,
137158
popover: None,
138159
}
@@ -210,16 +231,18 @@ impl FactoryComponent for SidebarRow {
210231
Some("sidebar-row.open-in-new-tab"),
211232
);
212233
menu.append_section(None, &open_section);
213-
let structure_section = gtk::gio::Menu::new();
214-
structure_section.append(Some(&crate::tr!("Edit Structure")), Some("sidebar-row.edit-structure"));
215-
structure_section.append(
216-
Some(&crate::tr!("Show CREATE TABLE")),
217-
Some("sidebar-row.show-create-table"),
218-
);
219-
menu.append_section(None, &structure_section);
220-
let mutate_section = gtk::gio::Menu::new();
221-
mutate_section.append(Some(&crate::tr!("Drop Table\u{2026}")), Some("sidebar-row.drop-table"));
222-
menu.append_section(None, &mutate_section);
234+
if self.kind == SidebarObjectKind::Table {
235+
let structure_section = gtk::gio::Menu::new();
236+
structure_section.append(Some(&crate::tr!("Edit Structure")), Some("sidebar-row.edit-structure"));
237+
structure_section.append(
238+
Some(&crate::tr!("Show CREATE TABLE")),
239+
Some("sidebar-row.show-create-table"),
240+
);
241+
menu.append_section(None, &structure_section);
242+
let mutate_section = gtk::gio::Menu::new();
243+
mutate_section.append(Some(&crate::tr!("Drop Table\u{2026}")), Some("sidebar-row.drop-table"));
244+
menu.append_section(None, &mutate_section);
245+
}
223246

224247
let popover = gtk::PopoverMenu::from_model(Some(&menu));
225248
popover.set_has_arrow(true);

0 commit comments

Comments
 (0)