xworkmate-app/lib/widgets/assistant_artifact_sidebar.dart

898 lines
30 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:markdown/markdown.dart' as md;
import '../i18n/app_language.dart';
import '../runtime/assistant_artifacts.dart';
import '../runtime/runtime_models.dart';
import '../theme/app_palette.dart';
import '../theme/app_theme.dart';
import 'section_tabs.dart';
import 'surface_card.dart';
typedef AssistantArtifactSnapshotLoader =
Future<AssistantArtifactSnapshot> Function();
typedef AssistantArtifactPreviewLoader =
Future<AssistantArtifactPreview> Function(AssistantArtifactEntry entry);
typedef AssistantArtifactOpenWorkspace = Future<void> Function();
enum AssistantArtifactSidebarTab { files, preview }
class AssistantArtifactSidebar extends StatefulWidget {
const AssistantArtifactSidebar({
super.key,
required this.sessionKey,
required this.threadTitle,
required this.workspacePath,
required this.workspaceKind,
required this.onCollapse,
required this.loadSnapshot,
required this.loadPreview,
this.onOpenWorkspace,
});
final String sessionKey;
final String threadTitle;
final String workspacePath;
final WorkspaceRefKind workspaceKind;
final VoidCallback onCollapse;
final AssistantArtifactSnapshotLoader loadSnapshot;
final AssistantArtifactPreviewLoader loadPreview;
final AssistantArtifactOpenWorkspace? onOpenWorkspace;
@override
State<AssistantArtifactSidebar> createState() =>
_AssistantArtifactSidebarState();
}
class _AssistantArtifactSidebarState extends State<AssistantArtifactSidebar> {
AssistantArtifactSidebarTab _activeTab = AssistantArtifactSidebarTab.files;
AssistantArtifactSnapshot? _snapshot;
AssistantArtifactEntry? _selectedEntry;
AssistantArtifactPreview _preview = const AssistantArtifactPreview.empty();
Object? _loadError;
bool _loadingSnapshot = false;
bool _loadingPreview = false;
@override
void initState() {
super.initState();
unawaited(_refreshSnapshot());
}
@override
void didUpdateWidget(covariant AssistantArtifactSidebar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.sessionKey != widget.sessionKey ||
oldWidget.workspacePath != widget.workspacePath ||
oldWidget.workspaceKind != widget.workspaceKind) {
_activeTab = AssistantArtifactSidebarTab.files;
_selectedEntry = null;
_preview = const AssistantArtifactPreview.empty();
unawaited(_refreshSnapshot());
}
}
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
final snapshot = _snapshot;
final entriesForPreview = _previewCandidates(snapshot);
final selectedEntry = _selectedEntry;
final workspacePath = widget.workspacePath.trim();
final canCopyWorkspace = workspacePath.isNotEmpty;
final canOpenWorkspace =
canCopyWorkspace &&
widget.workspaceKind == WorkspaceRefKind.localPath &&
widget.onOpenWorkspace != null;
return SurfaceCard(
key: const Key('assistant-artifact-pane'),
tone: SurfaceCardTone.chrome,
padding: EdgeInsets.zero,
borderRadius: AppRadius.sidebar,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.md,
AppSpacing.sm,
AppSpacing.sm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.threadTitle.trim().isEmpty
? appText('当前线程', 'Current thread')
: widget.threadTitle.trim(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.xxs),
Text(
appText('当前任务工作路径', 'Current task workspace'),
style: theme.textTheme.labelSmall?.copyWith(
color: palette.textMuted,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSpacing.xxs),
Tooltip(
message: workspacePath,
child: GestureDetector(
key: const Key(
'assistant-artifact-pane-workspace-ref-container',
),
onDoubleTap: canOpenWorkspace
? () => unawaited(_openWorkspace())
: null,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
vertical: AppSpacing.xxs,
),
decoration: BoxDecoration(
color: palette.chromeSurface.withValues(
alpha: 0.72,
),
borderRadius: BorderRadius.circular(
AppRadius.button,
),
border: Border.all(color: palette.chromeStroke),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 1),
child: Icon(
widget.workspaceKind ==
WorkspaceRefKind.localPath
? Icons.folder_open_rounded
: Icons.cloud_queue_rounded,
size: 14,
color: palette.textSecondary,
),
),
const SizedBox(width: AppSpacing.xxs),
Expanded(
child: Text(
_workspaceSummary(
widget.workspacePath,
widget.workspaceKind,
),
key: const Key(
'assistant-artifact-pane-workspace-ref',
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
height: 1.25,
),
),
),
const SizedBox(width: AppSpacing.xxs),
IconButton(
key: const Key(
'assistant-artifact-pane-copy-workspace-ref',
),
tooltip: appText(
'复制工作路径',
'Copy workspace path',
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
onPressed: canCopyWorkspace
? _copyWorkspace
: null,
icon: const Icon(
Icons.content_copy_rounded,
size: 14,
),
),
],
),
),
),
),
],
),
),
IconButton(
key: const Key('assistant-artifact-pane-refresh'),
tooltip: appText('刷新产物', 'Refresh artifacts'),
style: IconButton.styleFrom(
minimumSize: const Size(40, 40),
maximumSize: const Size(40, 40),
),
onPressed: _loadingSnapshot ? null : _refreshSnapshot,
icon: _loadingSnapshot
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh_rounded, size: 18),
),
IconButton(
key: const Key('assistant-artifact-pane-collapse'),
tooltip: appText('收起右侧栏', 'Collapse sidebar'),
style: IconButton.styleFrom(
minimumSize: const Size(40, 40),
maximumSize: const Size(40, 40),
),
onPressed: widget.onCollapse,
icon: const Icon(Icons.keyboard_double_arrow_right_rounded),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: SectionTabs(
size: SectionTabsSize.small,
items: AssistantArtifactSidebarTab.values
.map(_labelForTab)
.toList(growable: false),
value: _labelForTab(_activeTab),
onChanged: (value) {
final nextTab = AssistantArtifactSidebarTab.values.firstWhere(
(item) => _labelForTab(item) == value,
orElse: () => AssistantArtifactSidebarTab.files,
);
setState(() {
_activeTab = nextTab;
});
if (nextTab == AssistantArtifactSidebarTab.preview &&
selectedEntry == null &&
entriesForPreview.isNotEmpty) {
unawaited(_selectEntry(entriesForPreview.first));
}
},
),
),
const SizedBox(height: AppSpacing.sm),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 160),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child: _buildTabBody(
context,
snapshot: snapshot,
previewCandidates: entriesForPreview,
),
),
),
],
),
);
}
Future<void> _copyWorkspace() async {
final workspacePath = widget.workspacePath.trim();
if (workspacePath.isEmpty) {
return;
}
await Clipboard.setData(ClipboardData(text: workspacePath));
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(appText('工作路径已复制', 'Workspace path copied')),
duration: const Duration(milliseconds: 1200),
),
);
}
Future<void> _openWorkspace() async {
if (widget.onOpenWorkspace == null) {
return;
}
await widget.onOpenWorkspace!.call();
}
Widget _buildTabBody(
BuildContext context, {
required AssistantArtifactSnapshot? snapshot,
required List<AssistantArtifactEntry> previewCandidates,
}) {
if (_loadError != null) {
return _SidebarEmptyState(
key: const Key('assistant-artifact-pane-empty'),
icon: Icons.error_outline_rounded,
title: appText('产物载入失败', 'Artifacts failed to load'),
message: _loadError.toString(),
);
}
if (snapshot == null && _loadingSnapshot) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot == null) {
return _SidebarEmptyState(
key: const Key('assistant-artifact-pane-empty'),
icon: Icons.inbox_outlined,
title: appText('暂无产物', 'No artifacts yet'),
message: appText(
'展开右侧栏后会按需加载当前线程的工作目录内容。',
'Open the sidebar to load the current thread workspace on demand.',
),
);
}
return switch (_activeTab) {
AssistantArtifactSidebarTab.files => _ArtifactEntryList(
key: const Key('assistant-artifact-tab-files'),
entries: previewCandidates,
emptyMessage: _filesEmptyMessage(snapshot),
onSelectEntry: _selectEntry,
selectedEntry: _selectedEntry,
),
AssistantArtifactSidebarTab.preview => _ArtifactPreviewPanel(
key: const Key('assistant-artifact-tab-preview'),
entry: _selectedEntry,
preview: _preview,
loading: _loadingPreview,
fallbackEntries: previewCandidates,
onSelectEntry: _selectEntry,
),
};
}
List<AssistantArtifactEntry> _previewCandidates(
AssistantArtifactSnapshot? snapshot,
) {
if (snapshot == null) {
return const <AssistantArtifactEntry>[];
}
final seen = <String>{};
final merged = <AssistantArtifactEntry>[
...snapshot.resultEntries,
...snapshot.fileEntries,
];
return merged
.where((item) => seen.add(item.relativePath))
.toList(growable: false);
}
String _filesEmptyMessage(AssistantArtifactSnapshot snapshot) {
final filesMessage = snapshot.filesMessage.trim();
if (filesMessage.isNotEmpty) {
return filesMessage;
}
final resultsMessage = snapshot.resultMessage.trim();
if (resultsMessage.isNotEmpty) {
return resultsMessage;
}
return appText(
'当前线程里还没有可展示的文件。',
'No files are available for this thread yet.',
);
}
Future<void> _refreshSnapshot() async {
setState(() {
_loadingSnapshot = true;
_loadError = null;
});
try {
final snapshot = await widget.loadSnapshot();
if (!mounted) {
return;
}
final nextSelected = _reconcileSelection(
snapshot,
previous: _selectedEntry,
);
setState(() {
_snapshot = snapshot;
_selectedEntry = nextSelected;
_loadingSnapshot = false;
});
if (_activeTab == AssistantArtifactSidebarTab.preview &&
nextSelected != null) {
await _loadPreview(nextSelected);
}
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_loadingSnapshot = false;
_loadError = error;
});
}
}
AssistantArtifactEntry? _reconcileSelection(
AssistantArtifactSnapshot snapshot, {
AssistantArtifactEntry? previous,
}) {
final candidates = _previewCandidates(snapshot);
if (previous == null) {
return candidates.isEmpty ? null : candidates.first;
}
for (final item in candidates) {
if (item.relativePath == previous.relativePath) {
return item;
}
}
return candidates.isEmpty ? null : candidates.first;
}
Future<void> _selectEntry(AssistantArtifactEntry entry) async {
setState(() {
_selectedEntry = entry;
_activeTab = AssistantArtifactSidebarTab.preview;
});
await _loadPreview(entry);
}
Future<void> _loadPreview(AssistantArtifactEntry entry) async {
setState(() {
_loadingPreview = true;
_preview = const AssistantArtifactPreview.empty();
});
try {
final preview = await widget.loadPreview(entry);
if (!mounted) {
return;
}
setState(() {
_preview = preview;
_loadingPreview = false;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_preview = AssistantArtifactPreview.empty(message: error.toString());
_loadingPreview = false;
});
}
}
String _labelForTab(AssistantArtifactSidebarTab tab) {
return switch (tab) {
AssistantArtifactSidebarTab.files => appText('全部文件', 'All files'),
AssistantArtifactSidebarTab.preview => appText('预览', 'Preview'),
};
}
static String _workspaceSummary(String workspacePath, WorkspaceRefKind kind) {
final trimmed = workspacePath.trim();
if (trimmed.isEmpty) {
return appText('未设置', 'Not set');
}
if (kind == WorkspaceRefKind.remotePath) {
return trimmed;
}
final normalized = trimmed.replaceAll('\\', '/');
if (normalized.length <= 56) {
return normalized;
}
final segments = normalized
.split('/')
.where((item) => item.isNotEmpty)
.toList();
if (segments.length <= 4) {
return normalized;
}
return '.../${segments.sublist(segments.length - 4).join('/')}';
}
}
class AssistantArtifactSidebarRevealButton extends StatelessWidget {
const AssistantArtifactSidebarRevealButton({super.key, required this.onTap});
static const double _buttonSize = 28;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Tooltip(
message: appText('展开右侧栏', 'Expand sidebar'),
child: IconButton(
key: const Key('assistant-artifact-pane-toggle'),
onPressed: onTap,
visualDensity: VisualDensity.compact,
splashRadius: 18,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: _buttonSize,
height: _buttonSize,
),
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(_buttonSize, _buttonSize),
maximumSize: const Size(_buttonSize, _buttonSize),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
backgroundColor: Colors.transparent,
foregroundColor: palette.textSecondary,
overlayColor: palette.chromeSurfacePressed,
side: BorderSide.none,
shape: const CircleBorder(),
),
icon: const Icon(
Icons.keyboard_double_arrow_left_rounded,
size: 20,
),
),
);
}
}
class _ArtifactEntryList extends StatelessWidget {
const _ArtifactEntryList({
super.key,
required this.entries,
required this.emptyMessage,
required this.onSelectEntry,
required this.selectedEntry,
});
final List<AssistantArtifactEntry> entries;
final String emptyMessage;
final ValueChanged<AssistantArtifactEntry> onSelectEntry;
final AssistantArtifactEntry? selectedEntry;
@override
Widget build(BuildContext context) {
if (entries.isEmpty) {
return _SidebarEmptyState(
key: const Key('assistant-artifact-pane-empty'),
icon: Icons.folder_open_outlined,
title: appText('暂无文件', 'No files'),
message: emptyMessage,
);
}
final palette = context.palette;
final theme = Theme.of(context);
return ListView.separated(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
0,
AppSpacing.md,
AppSpacing.md,
),
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(height: AppSpacing.xs),
itemBuilder: (context, index) {
final entry = entries[index];
final selected =
selectedEntry?.relativePath == entry.relativePath &&
selectedEntry?.workspacePath == entry.workspacePath;
return Material(
color: Colors.transparent,
child: InkWell(
key: ValueKey<String>(
'assistant-artifact-entry-${entry.relativePath}',
),
onTap: () => onSelectEntry(entry),
borderRadius: BorderRadius.circular(AppRadius.button),
child: Container(
padding: const EdgeInsets.all(AppSpacing.sm),
decoration: BoxDecoration(
color: selected
? palette.accentMuted.withValues(alpha: 0.88)
: palette.chromeSurface.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(AppRadius.button),
border: Border.all(
color: selected ? palette.accent : palette.chromeStroke,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
_iconForEntry(entry),
size: 18,
color: selected ? palette.accent : palette.textSecondary,
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSpacing.xxs),
Text(
entry.relativePath,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
),
const SizedBox(height: AppSpacing.xxs),
Text(
_metaLabel(entry),
style: theme.textTheme.labelSmall?.copyWith(
color: palette.textMuted,
),
),
],
),
),
if (entry.previewable)
Icon(
Icons.visibility_outlined,
size: 16,
color: palette.textMuted,
),
],
),
),
),
);
},
);
}
static IconData _iconForEntry(AssistantArtifactEntry entry) {
if (entry.mimeType.startsWith('image/')) {
return Icons.image_outlined;
}
if (entry.mimeType == 'text/markdown') {
return Icons.description_outlined;
}
if (entry.mimeType == 'text/html') {
return Icons.language_rounded;
}
return Icons.insert_drive_file_outlined;
}
static String _metaLabel(AssistantArtifactEntry entry) {
final parts = <String>[
if (entry.mimeType.trim().isNotEmpty) entry.mimeType,
if (entry.sizeBytes != null) _formatBytes(entry.sizeBytes!),
if (entry.updatedAtMs != null)
_formatTimestamp(entry.updatedAtMs!.toInt()),
];
return parts.join(' · ');
}
static String _formatBytes(int bytes) {
if (bytes < 1024) {
return '$bytes B';
}
if (bytes < 1024 * 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
static String _formatTimestamp(int millis) {
final date = DateTime.fromMillisecondsSinceEpoch(millis);
final month = date.month.toString().padLeft(2, '0');
final day = date.day.toString().padLeft(2, '0');
final hour = date.hour.toString().padLeft(2, '0');
final minute = date.minute.toString().padLeft(2, '0');
return '${date.year}-$month-$day $hour:$minute';
}
}
class _ArtifactPreviewPanel extends StatelessWidget {
const _ArtifactPreviewPanel({
super.key,
required this.entry,
required this.preview,
required this.loading,
required this.fallbackEntries,
required this.onSelectEntry,
});
final AssistantArtifactEntry? entry;
final AssistantArtifactPreview preview;
final bool loading;
final List<AssistantArtifactEntry> fallbackEntries;
final ValueChanged<AssistantArtifactEntry> onSelectEntry;
@override
Widget build(BuildContext context) {
final resolvedEntry = entry;
final theme = Theme.of(context);
final palette = context.palette;
if (loading) {
return const Center(child: CircularProgressIndicator());
}
if (resolvedEntry == null) {
return _SidebarEmptyState(
key: const Key('assistant-artifact-pane-empty'),
icon: Icons.preview_outlined,
title: appText('暂无预览对象', 'No preview target'),
message: appText(
'从全部文件里选择一个文件后,会在这里轻量预览。',
'Select a file from all files to preview it here.',
),
);
}
if (preview.kind == AssistantArtifactPreviewKind.empty &&
preview.message.trim().isNotEmpty) {
return _SidebarEmptyState(
key: const Key('assistant-artifact-pane-empty'),
icon: Icons.preview_outlined,
title: resolvedEntry.label,
message: preview.message,
);
}
final body = switch (preview.kind) {
AssistantArtifactPreviewKind.markdown => MarkdownBody(
key: const Key('assistant-artifact-preview-markdown'),
data: preview.content,
selectable: true,
extensionSet: md.ExtensionSet.gitHubWeb,
),
AssistantArtifactPreviewKind.html => Html(
key: const Key('assistant-artifact-preview-html'),
data: preview.content,
style: <String, Style>{
'body': Style(
margin: Margins.zero,
padding: HtmlPaddings.zero,
fontSize: FontSize(13),
color: palette.textPrimary,
),
},
),
AssistantArtifactPreviewKind.text => SelectableText(
preview.content,
key: const Key('assistant-artifact-preview-text'),
style: theme.textTheme.bodyMedium?.copyWith(
fontFamily: 'Menlo',
height: 1.4,
),
),
AssistantArtifactPreviewKind.unsupported => Column(
key: const Key('assistant-artifact-preview-unsupported'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
preview.message.trim().isEmpty
? appText(
'当前文件类型不支持轻量预览。',
'Lightweight preview is unavailable for this file type.',
)
: preview.message,
style: theme.textTheme.bodyMedium?.copyWith(
color: palette.textSecondary,
),
),
if (fallbackEntries.isNotEmpty) ...[
const SizedBox(height: AppSpacing.md),
Text(
appText('可继续查看的文件', 'Other files you can preview'),
style: theme.textTheme.labelLarge?.copyWith(
color: palette.textSecondary,
),
),
const SizedBox(height: AppSpacing.xs),
...fallbackEntries.take(6).map((item) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
child: InkWell(
onTap: () => onSelectEntry(item),
child: Text(
item.relativePath,
style: theme.textTheme.bodyMedium?.copyWith(
color: palette.accent,
),
),
),
);
}),
],
],
),
AssistantArtifactPreviewKind.empty => const SizedBox.shrink(),
};
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
0,
AppSpacing.md,
AppSpacing.md,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
resolvedEntry.label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.xxs),
Text(
resolvedEntry.relativePath,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
),
const SizedBox(height: AppSpacing.md),
body,
],
),
);
}
}
class _SidebarEmptyState extends StatelessWidget {
const _SidebarEmptyState({
super.key,
required this.icon,
required this.title,
required this.message,
});
final IconData icon;
final String title;
final String message;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 28, color: palette.textMuted),
const SizedBox(height: AppSpacing.sm),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
message,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: palette.textSecondary,
),
),
],
),
),
);
}
}