Skip to content

Commit fa32d56

Browse files
committed
Commit page: carry the role denial and disable both commit buttons
CommitData had no access field, so the commit page happily offered Commit and Commit-and-Push on a bucket the active role cannot read, and the workflow gate's HEAD on .quilt/workflows/config.yml turned the click into a 403. get_commit_data now takes the RoleCache and calls the same denied_mark helper the package page uses, carrying a no_access_reason field of the same name and shape — three surfaces, one field, one helper. CommitWorkflows is left alone: a denial and a transient load failure both land on Unavailable there, and telling them apart would only matter if the workflow selector had to react differently, which it does not now that no_access_reason disables the buttons outright. Both buttons render disabled with the shared tooltip.
1 parent a7f3965 commit fa32d56

6 files changed

Lines changed: 218 additions & 52 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

quilt-sync/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
<!-- markdownlint-disable MD013 -->
1010
# Changelog
1111

12+
## [v0.20.0-alpha3] - 2026-07-29
13+
14+
### Changed
15+
16+
- The package page and the commit page now disable Commit and Commit-and-Push when the active role cannot read the package's bucket, explaining in a tooltip that names the role and points at switching it, instead of letting the commit fail with a storage error (<https://github.com/quiltdata/quilt-rs/pull/NNN>)
17+
1218
## [v0.20.0-alpha2] - 2026-07-28
1319

1420
### Added

quilt-sync/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "quilt-sync"
3-
version = "0.20.0-alpha2"
3+
version = "0.20.0-alpha3"
44
authors = ["Quilt Data, Inc."]
55
description = "Cross-platform desktop application for editing Quilt data packages"
66
documentation = "https://docs.quiltdata.com"

quilt-sync/src-tauri/src/commands/commit_data.rs

Lines changed: 118 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ use quilt_uri::Host;
1919
use quilt_uri::S3Uri;
2020

2121
use crate::Error;
22+
use crate::commands::RoleCache;
2223
use crate::model;
2324
use crate::model::QuiltModel;
2425
use crate::quilt;
2526

2627
use super::package_data::InstalledPackageEntryData;
28+
use super::package_list::denied_mark;
2729

2830
// ── Merge data for Leptos UI ──
2931

@@ -89,6 +91,18 @@ pub struct CommitData {
8991
pub user_meta_error: Option<String>,
9092
pub workflow: Option<CommitWorkflowData>,
9193
pub workflows: CommitWorkflows,
94+
/// Why the active role cannot reach this package's bucket, worded exactly
95+
/// as the roster words it. `Some` only on a denial.
96+
///
97+
/// Same field name, same shape and same helper as
98+
/// [`super::package_data::InstalledPackageData::no_access_reason`], so the
99+
/// three surfaces that speak about a denial all say one thing.
100+
///
101+
/// Committing is not offline work — the workflow quality gate reads the
102+
/// bucket's `.quilt/workflows/config.yml` before any manifest is written —
103+
/// so on a denial the commit affordances cannot succeed and the page
104+
/// disables them, quoting this as the reason.
105+
pub no_access_reason: Option<String>,
92106
pub entries: Vec<InstalledPackageEntryData>,
93107
pub ignored_count: usize,
94108
pub unmodified_count: usize,
@@ -258,6 +272,7 @@ fn workflows_config_to_commit_workflows(
258272
#[allow(clippy::too_many_lines, reason = "cohesive commit-data assembly")]
259273
async fn get_commit_data_from_model(
260274
m: &impl model::QuiltModel,
275+
roles: &RoleCache,
261276
tracing: &crate::telemetry::Telemetry,
262277
namespace: &quilt_uri::Namespace,
263278
) -> Result<CommitData, Error> {
@@ -267,23 +282,42 @@ async fn get_commit_data_from_model(
267282
))
268283
})?;
269284

270-
// Committing is local work: the remote round trip inside `status` only
271-
// refreshes `upstream_state`, which this page reports but does not act
272-
// on. A denial there must therefore not keep the page shut — the user
273-
// can still commit a package in a bucket the active role cannot read.
285+
let lineage = m.get_installed_package_lineage(&installed_package).await?;
286+
287+
let typed_uri = lineage
288+
.remote_uri
289+
.as_ref()
290+
.map(quilt_uri::S3PackageUri::from);
291+
let origin_host = typed_uri.as_ref().and_then(|u| u.catalog.as_ref());
292+
if let Some(host) = origin_host {
293+
tracing.add_host(host);
294+
}
295+
296+
// Opening the page is local work: the remote round trip inside `status`
297+
// only refreshes `upstream_state`, which this page reports but does not
298+
// act on. A denial there must therefore not keep the page shut.
274299
// Recomputing against the cached manifest gives the same answer the
275300
// engine used to hand back when it swallowed the refresh error, minus
276301
// the upstream freshness nobody here needs.
302+
//
303+
// Committing itself is a different matter: the workflow quality gate
304+
// reads the bucket's config before any manifest is written, so under a
305+
// denied role no commit can succeed. The page still opens — the file
306+
// list and the message are worth seeing — but it carries the reason so
307+
// the commit affordances can be disabled and explained rather than
308+
// failing on click.
309+
let mut no_access_reason = None;
277310
let status = match m
278311
.get_installed_package_status(&installed_package, None)
279312
.await
280313
{
281314
Ok(status) => status,
282315
Err(err) if err.is_access_denied() => {
283316
tracing::info!(
284-
"No read access to the remote of {}; committing on cached lineage",
317+
"No read access to the remote of {}; opening the commit page on cached lineage",
285318
installed_package.namespace,
286319
);
320+
no_access_reason = denied_mark(m, roles, origin_host).await.reason;
287321
m.recompute_local_status(&installed_package, None).await?
288322
}
289323
Err(err) => return Err(err),
@@ -298,17 +332,6 @@ async fn get_commit_data_from_model(
298332
quilt::lineage::UpstreamState::Error => "error",
299333
};
300334

301-
let lineage = m.get_installed_package_lineage(&installed_package).await?;
302-
303-
let typed_uri = lineage
304-
.remote_uri
305-
.as_ref()
306-
.map(quilt_uri::S3PackageUri::from);
307-
let origin_host = typed_uri.as_ref().and_then(|u| u.catalog.as_ref());
308-
if let Some(host) = origin_host {
309-
tracing.add_host(host);
310-
}
311-
312335
// Build lookup maps for junky files
313336
let junky_map: std::collections::HashMap<_, _> = status
314337
.junky_changes
@@ -435,6 +458,7 @@ async fn get_commit_data_from_model(
435458
user_meta_error,
436459
workflow,
437460
workflows,
461+
no_access_reason,
438462
entries: entries_list,
439463
ignored_count,
440464
unmodified_count,
@@ -444,14 +468,15 @@ async fn get_commit_data_from_model(
444468
#[tauri::command]
445469
pub async fn get_commit_data(
446470
m: tauri::State<'_, model::Model>,
471+
roles: tauri::State<'_, RoleCache>,
447472
tracing: tauri::State<'_, crate::telemetry::Telemetry>,
448473
namespace: String,
449474
) -> Result<CommitData, String> {
450475
let namespace: quilt_uri::Namespace = namespace
451476
.try_into()
452477
.map_err(|e: quilt_uri::UriError| e.to_string())?;
453478

454-
get_commit_data_from_model(&*m, &tracing, &namespace)
479+
get_commit_data_from_model(&*m, &roles, &tracing, &namespace)
455480
.await
456481
.map_err(|e| e.to_frontend_string())
457482
}
@@ -785,7 +810,7 @@ mod tests {
785810
let tracing = crate::telemetry::Telemetry::default();
786811
let namespace = ("foo", "bar").into();
787812

788-
let data = get_commit_data_from_model(&model, &tracing, &namespace)
813+
let data = get_commit_data_from_model(&model, &RoleCache::default(), &tracing, &namespace)
789814
.await
790815
.map_err(|e| e.to_string())?;
791816

@@ -799,12 +824,10 @@ mod tests {
799824
Ok(())
800825
}
801826

802-
/// Committing is local work. A role that cannot read the remote bucket
803-
/// can still have local edits worth committing, so a denial on the
804-
/// status refresh must not keep the Commit page from opening — the
805-
/// entries come from the local recompute instead.
806-
#[tokio::test]
807-
async fn commit_data_opens_when_the_role_cannot_read_the_remote() -> Result<(), String> {
827+
/// The model mocks shared by the denial-contrast pair below: an installed
828+
/// package with a remote, a cached manifest, and an ungoverned bucket. The
829+
/// caller wires up the status call, which is the only thing that differs.
830+
fn denial_contrast_model() -> crate::model::MockQuiltModel {
808831
let mut model = mocks::create();
809832
model
810833
.expect_get_installed_package()
@@ -818,9 +841,6 @@ mod tests {
818841
"abcdef".to_string(),
819842
))
820843
});
821-
model
822-
.expect_get_installed_package_status()
823-
.returning(|_, _| Err(access_denied_error()));
824844
// The installed revision's manifest is already in the local cache,
825845
// so reading it back needs no remote round trip and no read access.
826846
model
@@ -830,25 +850,84 @@ mod tests {
830850
.expect_get_installed_package_records()
831851
.returning(|_| Ok(std::collections::BTreeMap::new()));
832852
model.expect_get_workflows_config().returning(|_| Ok(None));
853+
model
854+
}
855+
856+
/// Opening the page is local work: a role that cannot read the remote
857+
/// bucket can still have local edits worth looking at, so a denial on the
858+
/// status refresh must not keep the Commit page shut — the entries come
859+
/// from the local recompute instead.
860+
///
861+
/// Committing is *not* local work (the workflow gate reads the bucket's
862+
/// config before any manifest is written), so the page must also carry the
863+
/// reason, worded exactly as the roster words it, for the disabled commit
864+
/// affordances to quote.
865+
#[tokio::test]
866+
async fn commit_data_opens_and_states_the_denial() -> Result<(), String> {
867+
let mut model = denial_contrast_model();
868+
model
869+
.expect_get_installed_package_status()
870+
.returning(|_, _| Err(access_denied_error()));
833871
model.expect_recompute_local_status().returning(|_, _| {
834872
Ok(quilt::lineage::InstalledPackageStatus::new(
835873
quilt::lineage::UpstreamState::UpToDate,
836874
one_local_change(),
837875
))
838876
});
877+
model.expect_refresh_roles().returning(|_| {
878+
Ok(quilt_rs::RoleInfo {
879+
current: "ReadOnly".to_string(),
880+
available: vec!["ReadWrite".to_string(), "ReadOnly".to_string()],
881+
})
882+
});
883+
model.expect_clear_remote_client_cache().returning(|_| ());
839884

840885
let tracing = crate::telemetry::Telemetry::default();
841886
let namespace = ("foo", "bar").into();
842887

843-
let data = get_commit_data_from_model(&model, &tracing, &namespace)
888+
let data = get_commit_data_from_model(&model, &RoleCache::default(), &tracing, &namespace)
844889
.await
845890
.map_err(|e| e.to_string())?;
846891

847892
assert_eq!(data.namespace, "foo/bar");
848893
assert!(
849894
data.entries.iter().any(|e| e.filename == "file.txt"),
850-
"the locally-computed change must still be offered for commit"
895+
"the locally-computed change must still be listed"
896+
);
897+
assert_eq!(
898+
data.no_access_reason.as_deref(),
899+
Some("Current role ReadOnly has no access to this bucket"),
900+
"the commit page must say what the roster and the detail page say"
901+
);
902+
Ok(())
903+
}
904+
905+
/// The contrast: the same package on a bucket the active role *can* read
906+
/// carries no reason at all, so the commit affordances stay live.
907+
#[tokio::test]
908+
async fn commit_data_on_a_readable_bucket_states_no_denial() -> Result<(), String> {
909+
let mut model = denial_contrast_model();
910+
model
911+
.expect_get_installed_package_status()
912+
.returning(|_, _| {
913+
Ok(quilt::lineage::InstalledPackageStatus::new(
914+
quilt::lineage::UpstreamState::UpToDate,
915+
one_local_change(),
916+
))
917+
});
918+
919+
let tracing = crate::telemetry::Telemetry::default();
920+
let namespace = ("foo", "bar").into();
921+
922+
let data = get_commit_data_from_model(&model, &RoleCache::default(), &tracing, &namespace)
923+
.await
924+
.map_err(|e| e.to_string())?;
925+
926+
assert!(
927+
data.entries.iter().any(|e| e.filename == "file.txt"),
928+
"the same local change is on offer"
851929
);
930+
assert_eq!(data.no_access_reason, None);
852931
Ok(())
853932
}
854933

@@ -859,7 +938,8 @@ mod tests {
859938
let tracing = crate::telemetry::Telemetry::default();
860939
let namespace = ("missing", "package").into();
861940

862-
let result = get_commit_data_from_model(&model, &tracing, &namespace).await;
941+
let result =
942+
get_commit_data_from_model(&model, &RoleCache::default(), &tracing, &namespace).await;
863943
assert!(result.is_err());
864944
}
865945

@@ -921,7 +1001,7 @@ mod tests {
9211001
let tracing = crate::telemetry::Telemetry::default();
9221002
let namespace = ("foo", "bar").into();
9231003

924-
let data = get_commit_data_from_model(&model, &tracing, &namespace)
1004+
let data = get_commit_data_from_model(&model, &RoleCache::default(), &tracing, &namespace)
9251005
.await
9261006
.map_err(|e| e.to_string())?;
9271007

@@ -987,7 +1067,7 @@ mod tests {
9871067
let tracing = crate::telemetry::Telemetry::default();
9881068
let namespace = ("foo", "bar").into();
9891069

990-
let data = get_commit_data_from_model(&model, &tracing, &namespace)
1070+
let data = get_commit_data_from_model(&model, &RoleCache::default(), &tracing, &namespace)
9911071
.await
9921072
.map_err(|e| e.to_string())?;
9931073

@@ -1045,7 +1125,7 @@ mod tests {
10451125
let tracing = crate::telemetry::Telemetry::default();
10461126
let namespace = ("foo", "bar").into();
10471127

1048-
let data = get_commit_data_from_model(&model, &tracing, &namespace)
1128+
let data = get_commit_data_from_model(&model, &RoleCache::default(), &tracing, &namespace)
10491129
.await
10501130
.map_err(|e| e.to_string())?;
10511131

@@ -1128,7 +1208,7 @@ workflows:
11281208
let tracing = crate::telemetry::Telemetry::default();
11291209
let namespace = ("foo", "bar").into();
11301210

1131-
let data = get_commit_data_from_model(&model, &tracing, &namespace)
1211+
let data = get_commit_data_from_model(&model, &RoleCache::default(), &tracing, &namespace)
11321212
.await
11331213
.map_err(|e| e.to_string())?;
11341214

@@ -1173,7 +1253,7 @@ workflows:
11731253
let tracing = crate::telemetry::Telemetry::default();
11741254
let namespace = ("foo", "bar").into();
11751255

1176-
let data = get_commit_data_from_model(&model, &tracing, &namespace)
1256+
let data = get_commit_data_from_model(&model, &RoleCache::default(), &tracing, &namespace)
11771257
.await
11781258
.map_err(|e| e.to_string())?;
11791259

@@ -1195,7 +1275,7 @@ workflows:
11951275
let tracing = crate::telemetry::Telemetry::default();
11961276
let namespace = ("foo", "bar").into();
11971277

1198-
let data = get_commit_data_from_model(&model, &tracing, &namespace)
1278+
let data = get_commit_data_from_model(&model, &RoleCache::default(), &tracing, &namespace)
11991279
.await
12001280
.map_err(|e| e.to_string())?;
12011281

@@ -1279,7 +1359,7 @@ workflows:
12791359
let tracing = crate::telemetry::Telemetry::default();
12801360
let namespace = ("foo", "bar").into();
12811361

1282-
let data = get_commit_data_from_model(&model, &tracing, &namespace)
1362+
let data = get_commit_data_from_model(&model, &RoleCache::default(), &tracing, &namespace)
12831363
.await
12841364
.map_err(|e| e.to_string())?;
12851365

@@ -1313,7 +1393,7 @@ workflows:
13131393
let tracing = crate::telemetry::Telemetry::default();
13141394
let namespace = ("foo", "bar").into();
13151395

1316-
let data = get_commit_data_from_model(&model, &tracing, &namespace)
1396+
let data = get_commit_data_from_model(&model, &RoleCache::default(), &tracing, &namespace)
13171397
.await
13181398
.map_err(|e| e.to_string())?;
13191399

quilt-sync/ui/src/commands.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ pub struct CommitData {
6363
pub workflow: Option<WorkflowData>,
6464
/// The bucket's workflow-selection situation for the commit dialog.
6565
pub workflows: CommitWorkflows,
66+
/// The active role cannot reach this package's bucket, worded as the
67+
/// roster words it — the same field name, shape and wording as
68+
/// [`InstalledPackageData::no_access_reason`]. Committing is not offline
69+
/// work (the workflow gate reads the bucket's config first), so this
70+
/// disables the commit affordances and supplies their tooltip.
71+
pub no_access_reason: Option<String>,
6672
pub entries: Vec<EntryData>,
6773
pub ignored_count: usize,
6874
pub unmodified_count: usize,

0 commit comments

Comments
 (0)