show pasted files/images as removable chips in the composer (T-142)
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 31s
test / unit + widget + golden + a11y (push) Failing after 31s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 31s
Pasting a file or image now adds a chip above the input instead of inserting the raw @path as editable text: an image thumbnail (Image.file of the cache/temp file, with an icon fallback) or a file icon plus the basename, each with a × to cancel it before sending. On submit the chips' @path tokens are appended to the typed text and the chips clear. resolveClipboardAttachment now returns ComposerAttachment descriptors (path + isImage) rather than a pre-joined token string, so the composer can render and manage each one. No new package dependency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/claude_composer.dart';
|
||||
import 'package:clide/builtin/claude/src/clipboard_paste.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -80,7 +81,16 @@ void main() {
|
||||
expect(tester.widget<EditableText>(find.byType(EditableText)).readOnly, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('Ctrl+V routes through the paste resolver and inserts its @path', (tester) async {
|
||||
Future<void> pasteAttachment(WidgetTester tester) async {
|
||||
tester.widget<EditableText>(find.byType(EditableText)).focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.control);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.control);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('Ctrl+V of a file shows a chip and leaves the text field empty', (tester) async {
|
||||
// WidgetsApp provides DefaultTextEditingShortcuts in the real app;
|
||||
// the bare harness doesn't, so wrap explicitly to map Ctrl+V ->
|
||||
// PasteTextIntent, which the composer's Actions override intercepts.
|
||||
@@ -89,22 +99,59 @@ void main() {
|
||||
DefaultTextEditingShortcuts(
|
||||
child: ClaudeComposer(
|
||||
onSubmit: (_) {},
|
||||
pasteResolver: () async => '@/tmp/shot.png',
|
||||
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
|
||||
),
|
||||
),
|
||||
));
|
||||
tester.widget<EditableText>(find.byType(EditableText)).focusNode.requestFocus();
|
||||
await tester.pump();
|
||||
await pasteAttachment(tester);
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.control);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyV);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.control);
|
||||
expect(find.text('notes.txt'), findsOneWidget);
|
||||
expect(tester.widget<EditableText>(find.byType(EditableText)).controller.text, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('submit appends attachment @path tokens to the message', (tester) async {
|
||||
final submitted = <String>[];
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
DefaultTextEditingShortcuts(
|
||||
child: ClaudeComposer(
|
||||
onSubmit: submitted.add,
|
||||
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.enterText(find.byType(EditableText), 'look at this');
|
||||
await pasteAttachment(tester);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.widget<EditableText>(find.byType(EditableText)).controller.text,
|
||||
'@/tmp/shot.png',
|
||||
);
|
||||
expect(submitted, ['look at this @/tmp/notes.txt']);
|
||||
// Chip cleared after send.
|
||||
expect(find.text('notes.txt'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('remove × cancels the attachment before send', (tester) async {
|
||||
final submitted = <String>[];
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
DefaultTextEditingShortcuts(
|
||||
child: ClaudeComposer(
|
||||
onSubmit: submitted.add,
|
||||
pasteResolver: () async => const [ComposerAttachment(path: '/tmp/notes.txt', isImage: false)],
|
||||
),
|
||||
),
|
||||
));
|
||||
await pasteAttachment(tester);
|
||||
expect(find.text('notes.txt'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('composer-remove-/tmp/notes.txt')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('notes.txt'), findsNothing);
|
||||
|
||||
await tester.enterText(find.byType(EditableText), 'just text');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted, ['just text']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,13 +25,15 @@ void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('resolveClipboardAttachment', () {
|
||||
test('files become space-joined @path tokens (no temp file)', () async {
|
||||
test('files become attachments with isImage by extension', () async {
|
||||
final source = _FakeSource(files: ['/home/u/a.txt', '/home/u/b.png']);
|
||||
final result = await resolveClipboardAttachment(source);
|
||||
expect(result, '@/home/u/a.txt @/home/u/b.png');
|
||||
expect(result.map((a) => a.path), ['/home/u/a.txt', '/home/u/b.png']);
|
||||
expect(result.map((a) => a.isImage), [false, true]);
|
||||
expect(result.map((a) => a.pathToken), ['@/home/u/a.txt', '@/home/u/b.png']);
|
||||
});
|
||||
|
||||
test('a raw image is written to a temp file and referenced by @path', () async {
|
||||
test('a raw image is written to a temp file and attached', () async {
|
||||
final dir = await Directory.systemTemp.createTemp('clide-paste-test-');
|
||||
addTearDown(() => dir.delete(recursive: true));
|
||||
final bytes = Uint8List.fromList([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
|
||||
@@ -44,7 +46,9 @@ void main() {
|
||||
);
|
||||
|
||||
final expectedPath = '${dir.path}/paste-1700000000000.png';
|
||||
expect(result, '@$expectedPath');
|
||||
expect(result, hasLength(1));
|
||||
expect(result.single.isImage, isTrue);
|
||||
expect(result.single.path, expectedPath);
|
||||
expect(await File(expectedPath).readAsBytes(), bytes);
|
||||
});
|
||||
|
||||
@@ -52,11 +56,18 @@ void main() {
|
||||
final result = await resolveClipboardAttachment(
|
||||
_FakeSource(files: ['/x/y'], image: Uint8List.fromList([1, 2])),
|
||||
);
|
||||
expect(result, '@/x/y');
|
||||
expect(result.map((a) => a.path), ['/x/y']);
|
||||
});
|
||||
|
||||
test('empty clipboard returns null (fall back to text)', () async {
|
||||
expect(await resolveClipboardAttachment(_FakeSource()), isNull);
|
||||
test('empty clipboard returns no attachments (fall back to text)', () async {
|
||||
expect(await resolveClipboardAttachment(_FakeSource()), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('ComposerAttachment', () {
|
||||
test('fileName is the last path segment', () {
|
||||
expect(const ComposerAttachment(path: '/a/b/c.png', isImage: true).fileName, 'c.png');
|
||||
expect(const ComposerAttachment(path: 'bare.txt', isImage: false).fileName, 'bare.txt');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user