xworkmate-app/lib/widgets/assistant_artifact_sidebar.dart
2026-06-07 12:13:58 +08:00

1246 lines
43 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();
typedef AssistantArtifactOpenEntryLocation =
Future<void> Function(AssistantArtifactEntry entry);
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.artifactSyncAtMs,
required this.artifactSyncStatus,
required this.taskContextMessageCount,
required this.taskContextSelectedSkillKeys,
required this.taskContextRemoteWorkingDirectory,
required this.taskContextOpenClawRunId,
required this.taskContextOpenClawStatus,
required this.onCollapse,
required this.loadSnapshot,
required this.loadPreview,
this.onOpenWorkspace,
this.onOpenEntryLocation,
});
final String sessionKey;
final String threadTitle;
final String workspacePath;
final WorkspaceRefKind workspaceKind;
final double? artifactSyncAtMs;
final String artifactSyncStatus;
final int taskContextMessageCount;
final List<String> taskContextSelectedSkillKeys;
final String taskContextRemoteWorkingDirectory;
final String taskContextOpenClawRunId;
final String taskContextOpenClawStatus;
final VoidCallback onCollapse;
final AssistantArtifactSnapshotLoader loadSnapshot;
final AssistantArtifactPreviewLoader loadPreview;
final AssistantArtifactOpenWorkspace? onOpenWorkspace;
final AssistantArtifactOpenEntryLocation? onOpenEntryLocation;
@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;
bool _taskContextExpanded = false;
int _snapshotLoadGeneration = 0;
int _previewLoadGeneration = 0;
Timer? _refreshTimer;
@override
void initState() {
super.initState();
unawaited(_refreshSnapshot());
_syncRefreshTimer();
}
@override
void didUpdateWidget(covariant AssistantArtifactSidebar oldWidget) {
super.didUpdateWidget(oldWidget);
final workspaceChanged =
oldWidget.sessionKey != widget.sessionKey ||
oldWidget.workspacePath != widget.workspacePath ||
oldWidget.workspaceKind != widget.workspaceKind;
if (workspaceChanged ||
oldWidget.artifactSyncAtMs != widget.artifactSyncAtMs ||
oldWidget.artifactSyncStatus != widget.artifactSyncStatus) {
if (workspaceChanged) {
_activeTab = AssistantArtifactSidebarTab.files;
_snapshot = null;
_selectedEntry = null;
_preview = const AssistantArtifactPreview.empty();
_loadError = null;
_loadingSnapshot = false;
_loadingPreview = false;
_previewLoadGeneration += 1;
}
unawaited(_refreshSnapshot());
}
_syncRefreshTimer();
}
@override
void dispose() {
_refreshTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
final snapshot = _snapshot;
final entries = _artifactEntries(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: _TaskContextSummaryCard(
messageCount: widget.taskContextMessageCount,
selectedSkillKeys: widget.taskContextSelectedSkillKeys,
remoteWorkingDirectory: widget.taskContextRemoteWorkingDirectory,
openClawRunId: widget.taskContextOpenClawRunId,
openClawStatus: widget.taskContextOpenClawStatus,
artifactSyncStatus: widget.artifactSyncStatus,
expanded: _taskContextExpanded,
onToggle: () {
setState(() {
_taskContextExpanded = !_taskContextExpanded;
});
},
),
),
const SizedBox(height: AppSpacing.sm),
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 || !selectedEntry.previewable) &&
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,
entries: entries,
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> entries,
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: entries,
emptyMessage: _filesEmptyMessage(snapshot),
onSelectEntry: _selectEntry,
onOpenEntryLocation: widget.onOpenEntryLocation,
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,
) {
return _artifactEntries(
snapshot,
).where((item) => item.previewable).toList(growable: false);
}
List<AssistantArtifactEntry> _artifactEntries(
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) {
if (widget.artifactSyncStatus.trim().toLowerCase() == 'failed') {
return appText(
'本轮没有检测到实际生成的文件。请重新执行,并要求 OpenClaw 在当前 workspace 中创建文件。',
'No files were generated for this run. Try again and ask OpenClaw to create files in the current workspace.',
);
}
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 {
if (_loadingSnapshot) {
return;
}
final generation = ++_snapshotLoadGeneration;
final sessionKey = widget.sessionKey;
setState(() {
_loadingSnapshot = true;
_loadError = null;
});
try {
final snapshot = await widget.loadSnapshot();
if (!mounted ||
generation != _snapshotLoadGeneration ||
sessionKey != widget.sessionKey) {
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 ||
generation != _snapshotLoadGeneration ||
sessionKey != widget.sessionKey) {
return;
}
setState(() {
_loadingSnapshot = false;
_loadError = error;
});
}
}
void _syncRefreshTimer() {
final shouldPoll = _shouldPollArtifactSnapshot(widget.artifactSyncStatus);
if (!shouldPoll) {
_refreshTimer?.cancel();
_refreshTimer = null;
return;
}
if (_refreshTimer?.isActive == true) {
return;
}
_refreshTimer = Timer.periodic(const Duration(seconds: 3), (_) {
if (!mounted || _loadingSnapshot) {
return;
}
unawaited(_refreshSnapshot());
});
}
bool _shouldPollArtifactSnapshot(String status) {
switch (status.trim().toLowerCase()) {
case 'partial':
case 'syncing':
case 'running':
case 'queued':
return true;
default:
return false;
}
}
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 {
if (!entry.previewable) {
setState(() {
_selectedEntry = entry;
});
return;
}
setState(() {
_selectedEntry = entry;
_activeTab = AssistantArtifactSidebarTab.preview;
});
await _loadPreview(entry);
}
Future<void> _loadPreview(AssistantArtifactEntry entry) async {
final generation = ++_previewLoadGeneration;
final sessionKey = widget.sessionKey;
final relativePath = entry.relativePath;
setState(() {
_loadingPreview = true;
_preview = const AssistantArtifactPreview.empty();
});
try {
final preview = await widget.loadPreview(entry);
if (!mounted ||
generation != _previewLoadGeneration ||
sessionKey != widget.sessionKey ||
relativePath != _selectedEntry?.relativePath) {
return;
}
setState(() {
_preview = preview;
_loadingPreview = false;
});
} catch (error) {
if (!mounted ||
generation != _previewLoadGeneration ||
sessionKey != widget.sessionKey ||
relativePath != _selectedEntry?.relativePath) {
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 _TaskContextSummaryCard extends StatelessWidget {
const _TaskContextSummaryCard({
required this.messageCount,
required this.selectedSkillKeys,
required this.remoteWorkingDirectory,
required this.openClawRunId,
required this.openClawStatus,
required this.artifactSyncStatus,
required this.expanded,
required this.onToggle,
});
final int messageCount;
final List<String> selectedSkillKeys;
final String remoteWorkingDirectory;
final String openClawRunId;
final String openClawStatus;
final String artifactSyncStatus;
final bool expanded;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
final skills = selectedSkillKeys
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
final status = artifactSyncStatus.trim().isEmpty
? appText('未同步', 'Not synced')
: artifactSyncStatus.trim();
return Container(
decoration: BoxDecoration(
color: palette.chromeSurface.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(AppRadius.button),
border: Border.all(color: palette.chromeStroke),
),
child: Column(
children: [
InkWell(
key: const Key('assistant-artifact-task-context-toggle'),
onTap: onToggle,
borderRadius: BorderRadius.circular(AppRadius.button),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
child: Row(
children: [
Icon(
Icons.forum_outlined,
size: 16,
color: palette.textSecondary,
),
const SizedBox(width: AppSpacing.xs),
Expanded(
child: Text(
appText('任务上下文会话', 'Task context session'),
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
Text(
'$messageCount msg · $status',
style: theme.textTheme.labelSmall?.copyWith(
color: palette.textMuted,
),
),
const SizedBox(width: AppSpacing.xxs),
Icon(
expanded
? Icons.expand_less_rounded
: Icons.expand_more_rounded,
size: 18,
color: palette.textSecondary,
),
],
),
),
),
AnimatedCrossFade(
duration: const Duration(milliseconds: 140),
crossFadeState: expanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: const SizedBox(width: double.infinity),
secondChild: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.sm,
0,
AppSpacing.sm,
AppSpacing.sm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_TaskContextLine(
label: appText('消息', 'Messages'),
value: '$messageCount',
),
_TaskContextLine(
label: appText('技能', 'Skills'),
value: skills.isEmpty
? appText('未选择', 'None')
: skills.join(', '),
),
_TaskContextLine(
label: appText('远端路径', 'Remote path'),
value: remoteWorkingDirectory.trim().isEmpty
? appText('未记录', 'Not recorded')
: remoteWorkingDirectory.trim(),
),
_TaskContextLine(
label: appText('OpenClaw run', 'OpenClaw run'),
value: openClawRunId.trim().isEmpty
? appText('未绑定', 'Not bound')
: openClawRunId.trim(),
),
_TaskContextLine(
label: appText('OpenClaw 状态', 'OpenClaw status'),
value: openClawStatus.trim().isEmpty
? appText('未记录', 'Not recorded')
: openClawStatus.trim(),
),
],
),
),
),
],
),
);
}
}
class _TaskContextLine extends StatelessWidget {
const _TaskContextLine({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: AppSpacing.xxs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 86,
child: Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: palette.textMuted,
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(
value,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: palette.textSecondary,
),
),
),
],
),
);
}
}
class _ArtifactEntryList extends StatelessWidget {
const _ArtifactEntryList({
super.key,
required this.entries,
required this.emptyMessage,
required this.onSelectEntry,
required this.onOpenEntryLocation,
required this.selectedEntry,
});
final List<AssistantArtifactEntry> entries;
final String emptyMessage;
final ValueChanged<AssistantArtifactEntry> onSelectEntry;
final AssistantArtifactOpenEntryLocation? onOpenEntryLocation;
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: entry.previewable ? () => onSelectEntry(entry) : null,
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,
),
),
],
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: entry.previewable
? appText('预览文件', 'Preview file')
: appText(
'此类型不可预览',
'Preview is unavailable for this type',
),
child: IconButton(
key: ValueKey<String>(
'assistant-artifact-preview-${entry.relativePath}',
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: 28,
height: 28,
),
onPressed: entry.previewable
? () => onSelectEntry(entry)
: null,
icon: const Icon(Icons.visibility_outlined, size: 16),
),
),
Tooltip(
message: appText('打开文件所在位置', 'Open file location'),
child: IconButton(
key: ValueKey<String>(
'assistant-artifact-open-location-${entry.relativePath}',
),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(
width: 28,
height: 28,
),
onPressed: onOpenEntryLocation == null
? null
: () => onOpenEntryLocation!(entry),
icon: const Icon(Icons.folder_open_rounded, size: 16),
),
),
],
),
],
),
),
),
);
},
);
}
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,
),
),
],
),
),
);
}
}