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
4 changes: 4 additions & 0 deletions apps/coder_app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions apps/coder_app/lib/l10n/app_ko.arb
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@
"appSettingsAddress": "WebSocket 주소",
"appSettingsNewToken": "새 Bearer token (변경할 때만 입력)",
"appSettingsBearerToken": "Bearer token",
"appSettingsRemoteDetails": "Daemon",
"appSettingsConnectionBehaviour": "연결",
"appSettingsConnectionFailed": "연결을 저장하지 못했어요",
"appSettingsDeleteTitle": "{label}을 삭제할까요?",
Expand Down
6 changes: 6 additions & 0 deletions apps/coder_app/lib/l10n/gen/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions apps/coder_app/lib/l10n/gen/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get appSettingsBearerToken => 'Bearer token';

@override
String get appSettingsRemoteDetails => 'Daemon';

@override
String get appSettingsConnectionBehaviour => 'Connection';

Expand Down
3 changes: 3 additions & 0 deletions apps/coder_app/lib/l10n/gen/app_localizations_ko.dart
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get appSettingsBearerToken => 'Bearer token';

@override
String get appSettingsRemoteDetails => 'Daemon';

@override
String get appSettingsConnectionBehaviour => '연결';

Expand Down
2 changes: 1 addition & 1 deletion apps/coder_app/lib/src/advanced_settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ class _ResetSectionState extends ConsumerState<_ResetSection> {
? l10n.advancedResetDescription
: l10n.advancedResetDescriptionAppOnly,
),
wrapsDescription: true,
unboundedDescription: true,
control: TRButton(
key: const ValueKey<String>('advanced-settings-reset-button'),
appearance: TRAppearance.outline,
Expand Down
6 changes: 3 additions & 3 deletions apps/coder_app/lib/src/app_settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -471,9 +471,9 @@ class _RemoteHostEditPageState extends ConsumerState<RemoteHostEditPage> {
body: SettingsScaffold(
children: <Widget>[
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(
Expand Down
20 changes: 18 additions & 2 deletions apps/coder_app/lib/src/coder_list_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ class CoderListRow extends StatefulWidget {
this.onTap,
this.selected = false,
this.subtitle,
this.subtitleMaxLines,
this.trailing,
this.unboundedSubtitle = false,
super.key,
});

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -95,8 +107,12 @@ class _CoderListRowState extends State<CoderListRow> {
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,
),
],
Expand Down
12 changes: 8 additions & 4 deletions apps/coder_app/lib/src/mcp_settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ class _ServerList extends StatelessWidget {
label: l10n.mcpSettingsScopeUser,
),
if (state.userServers.isEmpty)
CoderListRow(
SettingsRow(
key: const ValueKey<String>('mcp-server-list-empty'),
title: TRText.inherit(l10n.mcpSettingsEmpty),
),
Expand Down Expand Up @@ -253,13 +253,13 @@ class _ServerTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return CoderListRow(
return SettingsRow(
key: ValueKey<String>('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)} · '
Expand All @@ -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,
);
Expand Down Expand Up @@ -596,6 +596,7 @@ class _ServerEditorState extends ConsumerState<_ServerEditor> {
key: ValueKey<String>(
'mcp-tool-tile-${tool.toolId}',
),
contentPadding: SettingsRow.flushPadding,
dense: true,
title: TRText.inherit(tool.toolId),
subtitle: TRText.inherit(tool.description),
Expand All @@ -617,6 +618,7 @@ class _ServerEditorState extends ConsumerState<_ServerEditor> {
key: ValueKey<String>(
'mcp-resource-tile-${resource.uri}',
),
contentPadding: SettingsRow.flushPadding,
dense: true,
title: TRText.inherit(resource.uri),
subtitle: TRText.inherit(
Expand Down Expand Up @@ -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(
Expand All @@ -664,6 +667,7 @@ class _ServerEditorState extends ConsumerState<_ServerEditor> {
children: <Widget>[
for (final line in server.diagnostics)
CoderListRow(
contentPadding: SettingsRow.flushPadding,
dense: true,
title: TRText.inherit(line),
),
Expand Down
29 changes: 20 additions & 9 deletions apps/coder_app/lib/src/settings/settings_layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,18 @@ class SettingsSection extends StatelessWidget {
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
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: <Widget>[
Expanded(
child: TRText(title, variant: TRTextVariant.headingMd),
),
if (action case final action?) ...<Widget>[
const SizedBox(width: TRSpacing.large),
action,
],
TRText(title, variant: TRTextVariant.headingMd),
?action,
],
),
if (description case final description?) ...<Widget>[
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
60 changes: 34 additions & 26 deletions apps/coder_app/lib/src/settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,15 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
for (final attempt in state.authAttempts.values)
if (attempt.status == ProviderAuthAttemptStatus.failed ||
attempt.status == ProviderAuthAttemptStatus.expired)
CoderListRow(
key: ValueKey<String>('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<String>('provider-auth-error-${attempt.id}'),
leading: const Icon(CoderIcons.warning),
title: TRText.inherit(attempt.error ?? attempt.status.name),
),
),
],
);
Expand Down Expand Up @@ -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(
<String?>[
attempt.authorizationUrl,
attempt.userCode,
].whereType<String>().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(
<String?>[
attempt.authorizationUrl,
attempt.userCode,
].whereType<String>().join(' · '),
),
),
control: TRButton(
key: ValueKey<String>('provider-auth-cancel-${attempt.id}'),
appearance: TRAppearance.ghost,
onPressed: onCancel,
child: TRText.inherit(AppLocalizations.of(context).commonCancel),
),
),
trailing: TRButton(
key: ValueKey<String>('provider-auth-cancel-${attempt.id}'),
appearance: TRAppearance.ghost,
onPressed: onCancel,
child: TRText.inherit(AppLocalizations.of(context).commonCancel),
),
),
);
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/coder_app/test/golden/goldens/linux/mcp_settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions apps/coder_app/test/settings_layout_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Widget>[
SettingsSection(
title: '원격 daemons',
action: TRButton(
onPressed: () {},
child: const TRText.inherit('원격 daemon 추가'),
),
children: const <Widget>[],
),
],
),
),
),
),
),
);

expect(tester.takeException(), isNull);
});
});

group('SettingsSection banner', () {
testWidgets('sits between the heading and the content', (tester) async {
await tester.pumpWidget(
Expand Down
Loading