From bb0c5cd81234b32d093bd2ae128e22cc289a17c4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 2 Nov 2026 23:52:37 +0700 Subject: [PATCH 1/2] fix(core-storage): drop the 64-character cap on connection group names --- .../storage/src/connection_group_name.rs | 31 +++++------------ .../crates/storage/src/connection_tag_name.rs | 21 ++++++++---- .../storage/src/connections/legacy_groups.rs | 9 ++--- core/crates/storage/src/connections/mod.rs | 9 ++--- core/crates/storage/src/group_name_error.rs | 10 ++---- core/crates/storage/src/name_rules.rs | 33 +++++++------------ .../docs/decisions/0006-connection-library.md | 2 +- .../app/src/ui/picker/new_group_dialog.rs | 2 +- linux/docs/storage.md | 4 +-- 9 files changed, 47 insertions(+), 74 deletions(-) diff --git a/core/crates/storage/src/connection_group_name.rs b/core/crates/storage/src/connection_group_name.rs index 4b0ef8ba6e..709948d1c5 100644 --- a/core/crates/storage/src/connection_group_name.rs +++ b/core/crates/storage/src/connection_group_name.rs @@ -14,21 +14,18 @@ use crate::name_rules::{self, NameFault}; pub struct ConnectionGroupName(String); impl ConnectionGroupName { - /// Long enough for any real folder name, short enough for a menu. - pub const MAX_CHARS: usize = 64; - + // No length limit: a macOS group name has none and has to read back unchanged. pub fn new(value: &str) -> Result { - match name_rules::checked(value, Self::MAX_CHARS) { + match name_rules::checked(value) { Ok(name) => Ok(Self(name.to_owned())), Err(NameFault::Empty) => Err(GroupNameError::Empty), - Err(NameFault::TooLong) => Err(GroupNameError::TooLong { max: Self::MAX_CHARS }), Err(NameFault::ControlCharacter) => Err(GroupNameError::ControlCharacter), } } - /// A name read back from a file. A blank one means no group. + // A blank name means no group. pub(crate) fn from_stored(value: &str) -> Option { - Self::new(&name_rules::cleaned(value, Self::MAX_CHARS)).ok() + Self::new(&name_rules::cleaned(value)).ok() } pub fn as_str(&self) -> &str { @@ -76,15 +73,9 @@ mod tests { use super::*; #[test] - fn group_name_rejects_blank_long_and_control_characters() { + fn group_name_rejects_blank_and_control_characters() { assert_eq!(ConnectionGroupName::new(""), Err(GroupNameError::Empty)); assert_eq!(ConnectionGroupName::new(" \t "), Err(GroupNameError::Empty)); - assert_eq!( - ConnectionGroupName::new(&"a".repeat(ConnectionGroupName::MAX_CHARS + 1)), - Err(GroupNameError::TooLong { - max: ConnectionGroupName::MAX_CHARS - }) - ); assert_eq!( ConnectionGroupName::new("Work\nHome"), Err(GroupNameError::ControlCharacter) @@ -96,19 +87,15 @@ mod tests { } #[test] - fn the_limit_counts_characters_not_bytes() { - let name = "é".repeat(ConnectionGroupName::MAX_CHARS); + fn a_name_of_any_length_is_kept_whole() { + let name = "é".repeat(1_000); - assert_eq!(ConnectionGroupName::new(&name).map(String::from), Ok(name)); + assert_eq!(ConnectionGroupName::new(&name).map(String::from), Ok(name.clone())); + assert_eq!(ConnectionGroupName::from_stored(&name).map(String::from), Some(name)); } #[test] fn a_stored_name_is_cleaned_rather_than_refused() { - let long = "a".repeat(ConnectionGroupName::MAX_CHARS + 10); - assert_eq!( - ConnectionGroupName::from_stored(&long).map(String::from), - Some("a".repeat(ConnectionGroupName::MAX_CHARS)) - ); assert_eq!( ConnectionGroupName::from_stored("Work\tHome\n").map(String::from), Some(String::from("Work Home")) diff --git a/core/crates/storage/src/connection_tag_name.rs b/core/crates/storage/src/connection_tag_name.rs index 9190205dbd..307ec4f091 100644 --- a/core/crates/storage/src/connection_tag_name.rs +++ b/core/crates/storage/src/connection_tag_name.rs @@ -13,10 +13,10 @@ impl ConnectionTagName { pub const MAX_CHARS: usize = 32; pub fn new(value: &str) -> Result { - match name_rules::checked(value, Self::MAX_CHARS) { + match name_rules::checked(value) { + Ok(name) if name.chars().count() > Self::MAX_CHARS => Err(TagNameError::TooLong { max: Self::MAX_CHARS }), Ok(name) => Ok(Self(name.to_owned())), Err(NameFault::Empty) => Err(TagNameError::Empty), - Err(NameFault::TooLong) => Err(TagNameError::TooLong { max: Self::MAX_CHARS }), Err(NameFault::ControlCharacter) => Err(TagNameError::ControlCharacter), } } @@ -36,15 +36,17 @@ impl ConnectionTagName { } } -/// A tag's name read back from a file, cleaned as a group's is. A name -/// left blank is refused: a tag nobody can see cannot be told apart. +// A blank name is refused: a tag nobody can see cannot be told apart. pub(crate) fn deserialize_stored<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let stored = String::deserialize(deserializer)?; - ConnectionTagName::new(&name_rules::cleaned(&stored, ConnectionTagName::MAX_CHARS)) - .map_err(serde::de::Error::custom) + let cut: String = name_rules::cleaned(&stored) + .chars() + .take(ConnectionTagName::MAX_CHARS) + .collect(); + ConnectionTagName::new(&cut).map_err(serde::de::Error::custom) } impl TryFrom for ConnectionTagName { @@ -87,6 +89,13 @@ mod tests { assert_eq!(ConnectionTagName::new("a\u{7}"), Err(TagNameError::ControlCharacter)); } + #[test] + fn the_limit_counts_characters_not_bytes() { + let name = "é".repeat(ConnectionTagName::MAX_CHARS); + + assert_eq!(ConnectionTagName::new(&name).map(String::from), Ok(name)); + } + #[test] fn names_that_differ_only_in_case_are_the_same_tag() { let name = ConnectionTagName::new("Production").expect("a name"); diff --git a/core/crates/storage/src/connections/legacy_groups.rs b/core/crates/storage/src/connections/legacy_groups.rs index febb425d45..e20d27d52e 100644 --- a/core/crates/storage/src/connections/legacy_groups.rs +++ b/core/crates/storage/src/connections/legacy_groups.rs @@ -181,8 +181,8 @@ mod tests { } #[test] - fn a_name_too_long_or_with_control_characters_is_cleaned_first() { - let long = "g".repeat(ConnectionGroupName::MAX_CHARS + 5); + fn a_long_name_is_kept_and_control_characters_are_cleaned_first() { + let long = "g".repeat(200); let document = upgraded(&format!( r#"[{{"name": "a", "group": "{long}"}}, {{"name": "b", "group": "Work\tHome"}}]"# )); @@ -190,10 +190,7 @@ mod tests { let names: Vec<&str> = (0..2) .map(|at| document["groups"][at]["name"].as_str().unwrap_or_default()) .collect(); - assert_eq!( - names, - ["g".repeat(ConnectionGroupName::MAX_CHARS).as_str(), "Work Home"] - ); + assert_eq!(names, [long.as_str(), "Work Home"]); } #[test] diff --git a/core/crates/storage/src/connections/mod.rs b/core/crates/storage/src/connections/mod.rs index 308d18b27b..a2c4a4e5af 100644 --- a/core/crates/storage/src/connections/mod.rs +++ b/core/crates/storage/src/connections/mod.rs @@ -1965,13 +1965,10 @@ mod tests { } #[test] - fn a_group_an_earlier_build_wrote_past_the_limits_still_loads() { - let long = "g".repeat(crate::ConnectionGroupName::MAX_CHARS + 1); + fn a_long_group_loads_whole_and_control_characters_are_cleaned() { + let long = "g".repeat(200); for (stored, read) in [ - ( - format!(r#""{long}""#), - "g".repeat(crate::ConnectionGroupName::MAX_CHARS), - ), + (format!(r#""{long}""#), long.clone()), (String::from(r#""Work\tHome""#), String::from("Work Home")), ] { let root = TempDir::new().expect("tempdir"); diff --git a/core/crates/storage/src/group_name_error.rs b/core/crates/storage/src/group_name_error.rs index 38a5861fc3..1a5a702e60 100644 --- a/core/crates/storage/src/group_name_error.rs +++ b/core/crates/storage/src/group_name_error.rs @@ -1,22 +1,17 @@ -/// Why a group name cannot be used. The messages are also what a -/// connection list with such a name on disk is refused with. +// The messages are also what a connection list with such a name on disk is refused with. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum GroupNameError { #[error("a group name cannot be empty")] Empty, - #[error("a group name can be at most {max} characters long")] - TooLong { max: usize }, #[error("a group name cannot contain control characters")] ControlCharacter, } impl GroupNameError { - /// A name for the variant that stays the same across releases, for a - /// UI outside Rust to word the error from. + // Stable across releases, for a UI outside Rust to word the error from. pub fn code(&self) -> &'static str { match self { Self::Empty => "group_name.empty", - Self::TooLong { .. } => "group_name.too_long", Self::ControlCharacter => "group_name.control_character", } } @@ -29,7 +24,6 @@ mod tests { #[test] fn each_name_error_has_a_code_of_its_own() { assert_eq!(GroupNameError::Empty.code(), "group_name.empty"); - assert_eq!(GroupNameError::TooLong { max: 64 }.code(), "group_name.too_long"); assert_eq!(GroupNameError::ControlCharacter.code(), "group_name.control_character"); } } diff --git a/core/crates/storage/src/name_rules.rs b/core/crates/storage/src/name_rules.rs index b81ee033d8..1c9da5a867 100644 --- a/core/crates/storage/src/name_rules.rs +++ b/core/crates/storage/src/name_rules.rs @@ -4,32 +4,24 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum NameFault { Empty, - TooLong, ControlCharacter, } -/// `value` without the spaces around it, when it is a name a menu, a row -/// and a section title can all show. -pub(crate) fn checked(value: &str, max: usize) -> Result<&str, NameFault> { +pub(crate) fn checked(value: &str) -> Result<&str, NameFault> { let name = value.trim(); if name.is_empty() { return Err(NameFault::Empty); } - if name.chars().count() > max { - return Err(NameFault::TooLong); - } if name.chars().any(char::is_control) { return Err(NameFault::ControlCharacter); } Ok(name) } -/// A name read back from a file, with control characters turned into -/// spaces and cut to the limit, since a file is never refused for what -/// an earlier build let the user type. -pub(crate) fn cleaned(value: &str, max: usize) -> String { +// A file is never refused for what an earlier build let the user type. +pub(crate) fn cleaned(value: &str) -> String { let shown: String = value.chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); - shown.trim().chars().take(max).collect() + shown.trim().to_owned() } /// A connection's name as the list shows it: control characters as @@ -71,18 +63,15 @@ mod tests { } #[test] - fn a_name_is_trimmed_and_checked_in_characters() { - assert_eq!(checked(" Work ", 4), Ok("Work")); - assert_eq!(checked("éééé", 4), Ok("éééé")); - assert_eq!(checked("ééééé", 4), Err(NameFault::TooLong)); - assert_eq!(checked(" \t ", 4), Err(NameFault::Empty)); - assert_eq!(checked("a\nb", 4), Err(NameFault::ControlCharacter)); + fn a_name_is_trimmed_and_checked() { + assert_eq!(checked(" Work "), Ok("Work")); + assert_eq!(checked(" \t "), Err(NameFault::Empty)); + assert_eq!(checked("a\nb"), Err(NameFault::ControlCharacter)); } #[test] - fn a_stored_name_loses_control_characters_and_the_excess() { - assert_eq!(cleaned("Work\tHome\n", 64), "Work Home"); - assert_eq!(cleaned("abcdef", 4), "abcd"); - assert_eq!(cleaned(" \u{7} ", 4), ""); + fn a_stored_name_loses_control_characters() { + assert_eq!(cleaned("Work\tHome\n"), "Work Home"); + assert_eq!(cleaned(" \u{7} "), ""); } } diff --git a/core/docs/decisions/0006-connection-library.md b/core/docs/decisions/0006-connection-library.md index 1e4c021485..523afc2724 100644 --- a/core/docs/decisions/0006-connection-library.md +++ b/core/docs/decisions/0006-connection-library.md @@ -41,7 +41,7 @@ The saved-connection list in `tablepro-storage` filed each connection under a gr | Bundle format 1 (`groupName`) | a top-level group per name, as the version 2 upgrade does; the core does not read bundles yet | | Bundle format 2 (`groups` with `ref` and `parentRef`, `groupRef`) | the same tree; a bundle carries no order or favourites | -What the core cannot hold exactly: a group name over 64 characters or with control characters is cut and cleaned as a Linux one is, and the core's `Teal` has no macOS colour. Linked folders and the team library are not stored in the library, so they stay outside it. +What the core cannot hold exactly: a group name with control characters is cleaned as a Linux one is, and the core's `Teal` has no macOS colour. Linked folders and the team library are not stored in the library, so they stay outside it. ## Alternatives considered diff --git a/linux/crates/app/src/ui/picker/new_group_dialog.rs b/linux/crates/app/src/ui/picker/new_group_dialog.rs index 959f571322..44ea9fba77 100644 --- a/linux/crates/app/src/ui/picker/new_group_dialog.rs +++ b/linux/crates/app/src/ui/picker/new_group_dialog.rs @@ -65,7 +65,7 @@ mod tests { ("work", false), ("WORK", false), ("Archive", true), - (&"x".repeat(ConnectionGroupName::MAX_CHARS + 1), false), + (&"x".repeat(200), true), ("Staging", true), ("work / staging", false), ] { diff --git a/linux/docs/storage.md b/linux/docs/storage.md index 6dda7807e2..ef4396d45b 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -26,13 +26,13 @@ The app's data goes to the store its shape calls for. The core's `storage` crate - `external_access` is what MCP clients may do on the connection: `blocked`, `read_only` or `read_write` ([ADR 0022](decisions/0022-ai-assistant-and-mcp-server.md)). A file written before it existed reads as `read_only`, the level a new connection starts at. - `ai_policy` is what the AI assistant may send from the connection: `ask`, `allow` or `never`. A file written before it existed reads as `ask`, the policy a new connection starts at. - `query_timeout_secs` is the editor's query timeout on the connection, in seconds, with 0 for none. Left out, as in a file written before it existed, the connection follows the timeout in Preferences, and it is written back without one. -- The file's `groups` list is the library's folders, a tree ([core ADR 0006](../../core/docs/decisions/0006-connection-library.md)). Each `ConnectionGroup` has an `id`, a `name` (`ConnectionGroupName`: trimmed, 1 to 64 characters, no control characters, cleaned on read as a tag's is), a `parent_id` (none for a top-level group), an `order` among its siblings, and an optional `color` and `icon` (`GroupIcon`: one of the `ConnectionIcon` names, or the SF Symbol a macOS group wears, which Linux does not draw). Groups nest at most 3 levels. A new or moved group may not share a name with a sibling in any case; a list upgraded from version 2 can still hold two that differ only in case. +- The file's `groups` list is the library's folders, a tree ([core ADR 0006](../../core/docs/decisions/0006-connection-library.md)). Each `ConnectionGroup` has an `id`, a `name` (`ConnectionGroupName`: trimmed, not empty, of any length as on macOS, no control characters, which a read turns into spaces), a `parent_id` (none for a top-level group), an `order` among its siblings, and an optional `color` and `icon` (`GroupIcon`: one of the `ConnectionIcon` names, or the SF Symbol a macOS group wears, which Linux does not draw). Groups nest at most 3 levels. A new or moved group may not share a name with a sibling in any case; a list upgraded from version 2 can still hold two that differ only in case. - A connection's `group_id` names its group, none for the top of the list, and its `order` is its place there. Every write numbers each group's groups and connections from 0, so a new entry, or one moved to another group, goes last there. A read roots a group whose parent is missing or that sits on a cycle, keeps the first of two entries with one id, and moves a connection whose group is missing to the top. - `create_group_blocking`, `rename_group_blocking`, `set_group_color_blocking`, `set_group_icon_blocking`, `move_groups_blocking` (to another parent, before a sibling), `delete_group_blocking`, `move_connections_blocking` (to a group, before a connection) and `move_connections_to_group_named_blocking` change the tree. Each refuses what would break it with a `StorageError` carrying a stable code: `GroupNotFound`, `GroupExists`, `GroupNameTaken`, `GroupCycle`, `GroupTooDeep`, `ConnectionNotFound`. Deleting a group deletes the groups inside it and moves their connections to the top of the list. - `favorites` lists the favourite connections in their order. `add_favorites_blocking` adds or moves some before a given one, `remove_favorites_blocking` takes them off, and deleting a connection drops it. `ConnectionList::recent` is the opened connections, newest first, without the favourites. - The Linux app shows a group by its path from the top, such as `Work / Staging`, in the picker's sections, the window's tree, the search and every Move to Group menu, and sorts groups by that path. New Group, and a Move to Group pick of a name the list does not show, file the connection under the top-level group of that name, made when none has it in any case. A group with no connection is not shown. - Version 2 filed a connection under a `group` name. Its upgrade makes each name a top-level group whose id comes from the name, so identical names share one group and a read before the first save gives the same ids; names are cleaned as above, and a blank one is no group. Groups and each group's connections are numbered by name. -- The file's `tags` list holds every `ConnectionTag`: an `id`, a `name` (`ConnectionTagName`: trimmed, 1 to 32 characters, no control characters, cleaned on read as a group's is) and a `color` from the same palette as `color`. No two tags share a name in any case: `create_tag_blocking` and `update_tag_blocking` refuse one with `TagNameTaken`. +- The file's `tags` list holds every `ConnectionTag`: an `id`, a `name` (`ConnectionTagName`: trimmed, 1 to 32 characters, no control characters, cleaned on read as a group's is and cut to 32) and a `color` from the same palette as `color`. No two tags share a name in any case: `create_tag_blocking` and `update_tag_blocking` refuse one with `TagNameTaken`. - A connection's `tags` are ids from that list. Every write drops an id the list lacks and a repeated one, so `delete_tag_blocking` takes the tag off every connection in the same write, and a connection saved with a tag deleted meanwhile loses it. - `icon` is one of the `ConnectionIcon` names. The app draws each with a symbolic icon from the theme (`crates/app/src/ui/icons.rs`). - A connection's `name` is stored and compared as the list shows it: control characters as spaces, characters that show as nothing (U+200B, U+FEFF) dropped, no blanks around it, so a name that looks like a saved one is the same name. From 1e4bb6e75e6dcaf263f181ae5baba5c00d2ce361 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 2 Nov 2026 23:53:14 +0700 Subject: [PATCH 2/2] docs(linux): add changelog fragment for #3994 --- linux/changelog.d/3994.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 linux/changelog.d/3994.changed.md diff --git a/linux/changelog.d/3994.changed.md b/linux/changelog.d/3994.changed.md new file mode 100644 index 0000000000..78103dbc23 --- /dev/null +++ b/linux/changelog.d/3994.changed.md @@ -0,0 +1 @@ +Connection group names can be longer than 64 characters.