diff --git a/apps/coder_app/lib/l10n/app_en.arb b/apps/coder_app/lib/l10n/app_en.arb index a2fbdd57..5b960fb5 100644 --- a/apps/coder_app/lib/l10n/app_en.arb +++ b/apps/coder_app/lib/l10n/app_en.arb @@ -632,6 +632,10 @@ "@appSettingsBearerToken": { "description": "Text field label for the token of a new remote daemon." }, + "appSettingsRemoteDetails": "Daemon", + "@appSettingsRemoteDetails": { + "description": "Section heading for a remote daemon's name and address." + }, "appSettingsConnectionBehaviour": "Connection", "@appSettingsConnectionBehaviour": { "description": "Section heading for how the app connects to a daemon." diff --git a/apps/coder_app/lib/l10n/app_ko.arb b/apps/coder_app/lib/l10n/app_ko.arb index 4d4b6d13..e21c439e 100644 --- a/apps/coder_app/lib/l10n/app_ko.arb +++ b/apps/coder_app/lib/l10n/app_ko.arb @@ -162,6 +162,7 @@ "appSettingsAddress": "WebSocket 주소", "appSettingsNewToken": "새 Bearer token (변경할 때만 입력)", "appSettingsBearerToken": "Bearer token", + "appSettingsRemoteDetails": "Daemon", "appSettingsConnectionBehaviour": "연결", "appSettingsConnectionFailed": "연결을 저장하지 못했어요", "appSettingsDeleteTitle": "{label}을 삭제할까요?", diff --git a/apps/coder_app/lib/l10n/gen/app_localizations.dart b/apps/coder_app/lib/l10n/gen/app_localizations.dart index a8175037..fe277ef8 100644 --- a/apps/coder_app/lib/l10n/gen/app_localizations.dart +++ b/apps/coder_app/lib/l10n/gen/app_localizations.dart @@ -1070,6 +1070,12 @@ abstract class AppLocalizations { /// **'Bearer token'** String get appSettingsBearerToken; + /// Section heading for a remote daemon's name and address. + /// + /// In en, this message translates to: + /// **'Daemon'** + String get appSettingsRemoteDetails; + /// Section heading for how the app connects to a daemon. /// /// In en, this message translates to: diff --git a/apps/coder_app/lib/l10n/gen/app_localizations_en.dart b/apps/coder_app/lib/l10n/gen/app_localizations_en.dart index 542464cd..abed86e9 100644 --- a/apps/coder_app/lib/l10n/gen/app_localizations_en.dart +++ b/apps/coder_app/lib/l10n/gen/app_localizations_en.dart @@ -532,6 +532,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appSettingsBearerToken => 'Bearer token'; + @override + String get appSettingsRemoteDetails => 'Daemon'; + @override String get appSettingsConnectionBehaviour => 'Connection'; diff --git a/apps/coder_app/lib/l10n/gen/app_localizations_ko.dart b/apps/coder_app/lib/l10n/gen/app_localizations_ko.dart index 31cd414a..d0b1a571 100644 --- a/apps/coder_app/lib/l10n/gen/app_localizations_ko.dart +++ b/apps/coder_app/lib/l10n/gen/app_localizations_ko.dart @@ -525,6 +525,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get appSettingsBearerToken => 'Bearer token'; + @override + String get appSettingsRemoteDetails => 'Daemon'; + @override String get appSettingsConnectionBehaviour => '연결'; diff --git a/apps/coder_app/lib/src/advanced_settings_page.dart b/apps/coder_app/lib/src/advanced_settings_page.dart index ea78c53f..bacedcd6 100644 --- a/apps/coder_app/lib/src/advanced_settings_page.dart +++ b/apps/coder_app/lib/src/advanced_settings_page.dart @@ -69,7 +69,7 @@ class _ResetSectionState extends ConsumerState<_ResetSection> { ? l10n.advancedResetDescription : l10n.advancedResetDescriptionAppOnly, ), - wrapsDescription: true, + unboundedDescription: true, control: TRButton( key: const ValueKey('advanced-settings-reset-button'), appearance: TRAppearance.outline, diff --git a/apps/coder_app/lib/src/app_settings_page.dart b/apps/coder_app/lib/src/app_settings_page.dart index 2b0dcbde..9ac5cd2f 100644 --- a/apps/coder_app/lib/src/app_settings_page.dart +++ b/apps/coder_app/lib/src/app_settings_page.dart @@ -471,9 +471,9 @@ class _RemoteHostEditPageState extends ConsumerState { body: SettingsScaffold( children: [ SettingsSection.form( - title: existing == null - ? l10n.appSettingsAddRemoteTitle - : l10n.appSettingsEditRemoteTitle, + // Not the page title again: the header above already names the + // form, and a section heading is drawn larger than it. + title: l10n.appSettingsRemoteDetails, banner: _error == null ? null : TRAlert( diff --git a/apps/coder_app/lib/src/coder_list_row.dart b/apps/coder_app/lib/src/coder_list_row.dart index 25f0a371..3e23a489 100644 --- a/apps/coder_app/lib/src/coder_list_row.dart +++ b/apps/coder_app/lib/src/coder_list_row.dart @@ -15,7 +15,9 @@ class CoderListRow extends StatefulWidget { this.onTap, this.selected = false, this.subtitle, + this.subtitleMaxLines, this.trailing, + this.unboundedSubtitle = false, super.key, }); @@ -46,6 +48,16 @@ class CoderListRow extends StatefulWidget { /// Whether supporting content may occupy two lines. final bool isThreeLine; + /// Caps the supporting content, overriding the cap [isThreeLine] implies. + final int? subtitleMaxLines; + + /// Whether the supporting content may run to as many lines as it needs. + /// + /// A list row truncates by default, because a list reads as a column of + /// equal rows. A setting's description is prose the reader has to finish, + /// and on a narrow window two lines cut it mid-sentence. + final bool unboundedSubtitle; + /// Overrides token-based content padding when layout requires it. final EdgeInsetsGeometry? contentPadding; @@ -95,8 +107,12 @@ class _CoderListRowState extends State { const SizedBox(height: TRSpacing.extraSmall), DefaultTextStyle.merge( style: TRTypography.bodySm.copyWith(color: colors.textMuted), - maxLines: widget.isThreeLine ? 2 : 1, - overflow: TextOverflow.ellipsis, + maxLines: widget.unboundedSubtitle + ? null + : widget.subtitleMaxLines ?? (widget.isThreeLine ? 2 : 1), + overflow: widget.unboundedSubtitle + ? TextOverflow.clip + : TextOverflow.ellipsis, child: subtitle, ), ], diff --git a/apps/coder_app/lib/src/mcp_settings_page.dart b/apps/coder_app/lib/src/mcp_settings_page.dart index 5c37de6f..2d7ea34d 100644 --- a/apps/coder_app/lib/src/mcp_settings_page.dart +++ b/apps/coder_app/lib/src/mcp_settings_page.dart @@ -188,7 +188,7 @@ class _ServerList extends StatelessWidget { label: l10n.mcpSettingsScopeUser, ), if (state.userServers.isEmpty) - CoderListRow( + SettingsRow( key: const ValueKey('mcp-server-list-empty'), title: TRText.inherit(l10n.mcpSettingsEmpty), ), @@ -253,13 +253,13 @@ class _ServerTile extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - return CoderListRow( + return SettingsRow( key: ValueKey('mcp-server-tile-${server.config.id}'), selected: selected, onTap: onTap, leading: _StatusDot(server: server), title: TRText.inherit(server.config.id), - subtitle: TRText.inherit( + description: TRText.inherit( server.shadowed ? l10n.mcpSettingsShadowed : '${mcpStatusLabel(l10n, server.status)} · ' @@ -268,7 +268,7 @@ class _ServerTile extends StatelessWidget { '${l10n.mcpSettingsDiscoveredResources} ' '${server.resources.length}', ), - trailing: server.scope == McpConfigScope.project + control: server.scope == McpConfigScope.project ? const Icon(CoderIcons.lock) : null, ); @@ -596,6 +596,7 @@ class _ServerEditorState extends ConsumerState<_ServerEditor> { key: ValueKey( 'mcp-tool-tile-${tool.toolId}', ), + contentPadding: SettingsRow.flushPadding, dense: true, title: TRText.inherit(tool.toolId), subtitle: TRText.inherit(tool.description), @@ -617,6 +618,7 @@ class _ServerEditorState extends ConsumerState<_ServerEditor> { key: ValueKey( 'mcp-resource-tile-${resource.uri}', ), + contentPadding: SettingsRow.flushPadding, dense: true, title: TRText.inherit(resource.uri), subtitle: TRText.inherit( @@ -646,6 +648,7 @@ class _ServerEditorState extends ConsumerState<_ServerEditor> { 'mcp-resource-template-tile-' '${template.uriTemplate}', ), + contentPadding: SettingsRow.flushPadding, dense: true, title: TRText.inherit(template.uriTemplate), subtitle: TRText.inherit( @@ -664,6 +667,7 @@ class _ServerEditorState extends ConsumerState<_ServerEditor> { children: [ for (final line in server.diagnostics) CoderListRow( + contentPadding: SettingsRow.flushPadding, dense: true, title: TRText.inherit(line), ), diff --git a/apps/coder_app/lib/src/settings/settings_layout.dart b/apps/coder_app/lib/src/settings/settings_layout.dart index f69c696b..ed7cd0d1 100644 --- a/apps/coder_app/lib/src/settings/settings_layout.dart +++ b/apps/coder_app/lib/src/settings/settings_layout.dart @@ -97,15 +97,18 @@ class SettingsSection extends StatelessWidget { Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( + // A Wrap rather than a Row: on a narrow window, or at a large text + // scale, a heading and its action do not fit on one line. Wrapping is + // what keeps the action from overflowing, and spaceBetween still puts + // it against the trailing edge whenever the two do fit. + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: TRSpacing.large, + runSpacing: TRSpacing.small, children: [ - Expanded( - child: TRText(title, variant: TRTextVariant.headingMd), - ), - if (action case final action?) ...[ - const SizedBox(width: TRSpacing.large), - action, - ], + TRText(title, variant: TRTextVariant.headingMd), + ?action, ], ), if (description case final description?) ...[ @@ -165,6 +168,7 @@ class SettingsRow extends StatelessWidget { this.enabled = true, this.selected = false, this.wrapsDescription = false, + this.unboundedDescription = false, this.flush = false, super.key, }); @@ -193,6 +197,12 @@ class SettingsRow extends StatelessWidget { /// Whether the description may occupy a second line. final bool wrapsDescription; + /// Whether the description may run to as many lines as it needs. + /// + /// For a description that is prose rather than a status line: two lines cut + /// it mid-sentence on a narrow window. + final bool unboundedDescription; + /// Whether the surrounding container already supplies the inline inset. /// /// A dialog pads its own content, so a row inside one would otherwise sit a @@ -215,7 +225,8 @@ class SettingsRow extends StatelessWidget { Widget build(BuildContext context) => CoderListRow( contentPadding: flush ? flushPadding : contentPadding, enabled: enabled, - isThreeLine: wrapsDescription, + isThreeLine: wrapsDescription || unboundedDescription, + unboundedSubtitle: unboundedDescription, leading: leading, onTap: onTap, selected: selected, diff --git a/apps/coder_app/lib/src/settings_page.dart b/apps/coder_app/lib/src/settings_page.dart index 89514b06..310f565b 100644 --- a/apps/coder_app/lib/src/settings_page.dart +++ b/apps/coder_app/lib/src/settings_page.dart @@ -125,10 +125,15 @@ class _SettingsPageState extends ConsumerState { for (final attempt in state.authAttempts.values) if (attempt.status == ProviderAuthAttemptStatus.failed || attempt.status == ProviderAuthAttemptStatus.expired) - CoderListRow( - key: ValueKey('provider-auth-error-${attempt.id}'), - leading: const Icon(CoderIcons.warning), - title: TRText.inherit(attempt.error ?? attempt.status.name), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: TRSpacing.extraLarge, + ), + child: SettingsRow( + key: ValueKey('provider-auth-error-${attempt.id}'), + leading: const Icon(CoderIcons.warning), + title: TRText.inherit(attempt.error ?? attempt.status.name), + ), ), ], ); @@ -590,30 +595,33 @@ class _AuthAttemptBar extends StatelessWidget { final VoidCallback onCancel; @override - Widget build(BuildContext context) => DecoratedBox( - decoration: BoxDecoration( - color: context.tinyrackTheme.surfaceSelected, - borderRadius: const BorderRadius.all(TRRadii.medium), - ), - child: CoderListRow( - leading: const TRSpinner(), - title: TRText.inherit( - AppLocalizations.of(context).providerSettingsOAuthPending, + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: TRSpacing.extraLarge), + child: DecoratedBox( + decoration: BoxDecoration( + color: context.tinyrackTheme.surfaceSelected, + borderRadius: const BorderRadius.all(TRRadii.medium), ), - // The device code has to be copyable for the user to complete the flow. - subtitle: SelectionArea( - child: TRText.inherit( - [ - attempt.authorizationUrl, - attempt.userCode, - ].whereType().join(' · '), + child: SettingsRow( + leading: const TRSpinner(), + title: TRText.inherit( + AppLocalizations.of(context).providerSettingsOAuthPending, + ), + // The device code has to be copyable for the user to complete it. + description: SelectionArea( + child: TRText.inherit( + [ + attempt.authorizationUrl, + attempt.userCode, + ].whereType().join(' · '), + ), + ), + control: TRButton( + key: ValueKey('provider-auth-cancel-${attempt.id}'), + appearance: TRAppearance.ghost, + onPressed: onCancel, + child: TRText.inherit(AppLocalizations.of(context).commonCancel), ), - ), - trailing: TRButton( - key: ValueKey('provider-auth-cancel-${attempt.id}'), - appearance: TRAppearance.ghost, - onPressed: onCancel, - child: TRText.inherit(AppLocalizations.of(context).commonCancel), ), ), ); diff --git a/apps/coder_app/test/golden/goldens/linux/advanced_settings_mobile_dark.png b/apps/coder_app/test/golden/goldens/linux/advanced_settings_mobile_dark.png index 1da7ac05..d47d13d0 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/advanced_settings_mobile_dark.png and b/apps/coder_app/test/golden/goldens/linux/advanced_settings_mobile_dark.png differ diff --git a/apps/coder_app/test/golden/goldens/linux/advanced_settings_mobile_light.png b/apps/coder_app/test/golden/goldens/linux/advanced_settings_mobile_light.png index 3b646682..53e14c98 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/advanced_settings_mobile_light.png and b/apps/coder_app/test/golden/goldens/linux/advanced_settings_mobile_light.png differ diff --git a/apps/coder_app/test/golden/goldens/linux/edit_host_desktop_dark.png b/apps/coder_app/test/golden/goldens/linux/edit_host_desktop_dark.png index b91a6416..b7e91daf 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/edit_host_desktop_dark.png and b/apps/coder_app/test/golden/goldens/linux/edit_host_desktop_dark.png differ diff --git a/apps/coder_app/test/golden/goldens/linux/edit_host_desktop_light.png b/apps/coder_app/test/golden/goldens/linux/edit_host_desktop_light.png index 4c20c0ea..a3f3b5c6 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/edit_host_desktop_light.png and b/apps/coder_app/test/golden/goldens/linux/edit_host_desktop_light.png differ diff --git a/apps/coder_app/test/golden/goldens/linux/edit_host_mobile_dark.png b/apps/coder_app/test/golden/goldens/linux/edit_host_mobile_dark.png index 2a1742db..b50a4bb1 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/edit_host_mobile_dark.png and b/apps/coder_app/test/golden/goldens/linux/edit_host_mobile_dark.png differ diff --git a/apps/coder_app/test/golden/goldens/linux/edit_host_mobile_light.png b/apps/coder_app/test/golden/goldens/linux/edit_host_mobile_light.png index 776a765c..ae9989dc 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/edit_host_mobile_light.png and b/apps/coder_app/test/golden/goldens/linux/edit_host_mobile_light.png differ diff --git a/apps/coder_app/test/golden/goldens/linux/mcp_settings.png b/apps/coder_app/test/golden/goldens/linux/mcp_settings.png index 9eec9a79..ebf146cf 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/mcp_settings.png and b/apps/coder_app/test/golden/goldens/linux/mcp_settings.png differ diff --git a/apps/coder_app/test/golden/goldens/linux/mcp_settings_desktop_dark.png b/apps/coder_app/test/golden/goldens/linux/mcp_settings_desktop_dark.png index d18be43f..8318e1cb 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/mcp_settings_desktop_dark.png and b/apps/coder_app/test/golden/goldens/linux/mcp_settings_desktop_dark.png differ diff --git a/apps/coder_app/test/golden/goldens/linux/mcp_settings_desktop_light.png b/apps/coder_app/test/golden/goldens/linux/mcp_settings_desktop_light.png index b82356f1..2227a970 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/mcp_settings_desktop_light.png and b/apps/coder_app/test/golden/goldens/linux/mcp_settings_desktop_light.png differ diff --git a/apps/coder_app/test/golden/goldens/linux/mcp_settings_mobile_dark.png b/apps/coder_app/test/golden/goldens/linux/mcp_settings_mobile_dark.png index a0368c63..a459751c 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/mcp_settings_mobile_dark.png and b/apps/coder_app/test/golden/goldens/linux/mcp_settings_mobile_dark.png differ diff --git a/apps/coder_app/test/golden/goldens/linux/mcp_settings_mobile_light.png b/apps/coder_app/test/golden/goldens/linux/mcp_settings_mobile_light.png index dcdda165..1b87c171 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/mcp_settings_mobile_light.png and b/apps/coder_app/test/golden/goldens/linux/mcp_settings_mobile_light.png differ diff --git a/apps/coder_app/test/golden/goldens/linux/new_host_desktop_dark.png b/apps/coder_app/test/golden/goldens/linux/new_host_desktop_dark.png index 58170464..72406ae4 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/new_host_desktop_dark.png and b/apps/coder_app/test/golden/goldens/linux/new_host_desktop_dark.png differ diff --git a/apps/coder_app/test/golden/goldens/linux/new_host_desktop_light.png b/apps/coder_app/test/golden/goldens/linux/new_host_desktop_light.png index fcb2e819..b5bd3aa4 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/new_host_desktop_light.png and b/apps/coder_app/test/golden/goldens/linux/new_host_desktop_light.png differ diff --git a/apps/coder_app/test/golden/goldens/linux/new_host_mobile_dark.png b/apps/coder_app/test/golden/goldens/linux/new_host_mobile_dark.png index 3f9dcc71..22117f1a 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/new_host_mobile_dark.png and b/apps/coder_app/test/golden/goldens/linux/new_host_mobile_dark.png differ diff --git a/apps/coder_app/test/golden/goldens/linux/new_host_mobile_light.png b/apps/coder_app/test/golden/goldens/linux/new_host_mobile_light.png index 36edaa46..dcb4a80b 100644 Binary files a/apps/coder_app/test/golden/goldens/linux/new_host_mobile_light.png and b/apps/coder_app/test/golden/goldens/linux/new_host_mobile_light.png differ diff --git a/apps/coder_app/test/settings_layout_test.dart b/apps/coder_app/test/settings_layout_test.dart index e29d6340..cd4d33eb 100644 --- a/apps/coder_app/test/settings_layout_test.dart +++ b/apps/coder_app/test/settings_layout_test.dart @@ -152,6 +152,40 @@ void main() { }); }); + group('SettingsSection header', () { + testWidgets('wraps its action rather than overflowing', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: TinyrackTheme.light(), + home: MediaQuery( + // A heading and its action do not fit on one line on a narrow + // window at a large text scale, and a Row cannot give. + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: Scaffold( + body: SizedBox( + width: 390, + child: SettingsScaffold( + children: [ + SettingsSection( + title: '원격 daemons', + action: TRButton( + onPressed: () {}, + child: const TRText.inherit('원격 daemon 추가'), + ), + children: const [], + ), + ], + ), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + }); + group('SettingsSection banner', () { testWidgets('sits between the heading and the content', (tester) async { await tester.pumpWidget(