fix image stale-render on in-place overwrite (T-312)

Image cards, thumbnails, the lightbox, and `clide image show` rendered
via Image.file, whose FileImage keys Flutter's imageCache by (path,
scale) only — so overwriting a file at the same path handed back the
previously decoded frame (hit live re-exporting a wireframe PNG). Add
ClideFileImage, a FileImage that folds mtime + size into ==/hashCode so an
in-place change is a fresh cache key (miss → re-decode), and route the
five Image.file sites through it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 13:25:58 +02:00
co-authored by Claude Opus 4.8
parent 1194527ad5
commit a879238102
9 changed files with 103 additions and 16 deletions
@@ -11,7 +11,6 @@ library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/builtin/claude/src/activity_cluster.dart';
import 'package:clide/builtin/claude/src/conversation_card.dart';
@@ -499,8 +498,8 @@ class _ConversationTurn extends StatelessWidget {
borderRadius: BorderRadius.circular(4),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 360),
child: Image.file(
File(m.path),
child: Image(
image: ClideFileImage(m.path),
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
errorBuilder: (_, __, ___) => _imagePlaceholder(m.path),
@@ -522,8 +521,8 @@ class _ConversationTurn extends StatelessWidget {
ClideKernel.of(context).dialog.show<Object>(
(ctx, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: Image.file(
File(path),
child: Image(
image: ClideFileImage(path),
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => _imagePlaceholder(path),
),
+1 -1
View File
@@ -378,7 +378,7 @@ class ClaudeExtension extends ClideExtension {
_ctx?.dialog.show<Object>(
(c, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: Image.file(File(path), fit: BoxFit.contain),
child: Image(image: ClideFileImage(path), fit: BoxFit.contain),
),
);
return;
+9 -10
View File
@@ -3,14 +3,13 @@
/// A bounded, keyboard-activatable thumbnail that opens the full image in the
/// shared lightbox (T-252) on tap. Used both for `@<path>` image tokens in the
/// conversation log and for the composer's attachment chips, so the two stay
/// visually consistent. Reads the file directly via `Image.file` (dart:io) —
/// pasted temp files live outside the workspace, so this is not gated by the
/// `files.read` allow-list (D-80); it's display-only. A missing/unreadable file
/// degrades to a muted placeholder instead of throwing.
/// visually consistent. Reads the file directly via [ClideFileImage] (which
/// re-decodes when the file changes in place, T-312) — pasted temp files live
/// outside the workspace, so this is not gated by the `files.read` allow-list
/// (D-80); it's display-only. A missing/unreadable file degrades to a muted
/// placeholder instead of throwing.
library;
import 'dart:io';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
@@ -20,8 +19,8 @@ void openImageLightbox(BuildContext context, String path) {
ClideKernel.of(context).dialog.show<Object>(
(ctx, dismiss) => ClideLightbox(
onDismiss: dismiss,
child: Image.file(
File(path),
child: Image(
image: ClideFileImage(path),
fit: BoxFit.contain,
errorBuilder: (ctx, _, __) => _placeholder(ctx, 48),
),
@@ -65,8 +64,8 @@ class ImageThumbnail extends StatelessWidget {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Image.file(
File(path),
child: Image(
image: ClideFileImage(path),
width: size,
height: size,
fit: BoxFit.cover,
+39
View File
@@ -0,0 +1,39 @@
import 'dart:io';
import 'package:flutter/widgets.dart';
/// A [FileImage] whose cache key also folds in the file's modification time and
/// size — so re-showing a path whose bytes changed *in place* re-decodes
/// instead of returning Flutter's stale cached frame (T-312).
///
/// Plain `Image.file` / `FileImage` key the global `imageCache` by `(path,
/// scale)` only, so overwriting a file at the same path is invisible: the cache
/// hands back the previously decoded image. Hit live when re-exporting a
/// wireframe PNG in place kept showing the prior render. Folding mtime + size
/// into `==`/`hashCode` makes an overwrite a fresh key → a cache miss → a
/// re-read of the current bytes; an unchanged file still hits the cache.
class ClideFileImage extends FileImage {
ClideFileImage(String path, {double scale = 1.0})
: _stamp = _stampOf(path),
super(File(path), scale: scale);
/// mtime ⊕ size — changes on any in-place overwrite (a write bumps mtime; a
/// different length bumps size even within one clock tick). 0 if the file
/// can't be stat'd, which falls back to plain path+scale keying.
final int _stamp;
static int _stampOf(String path) {
try {
final s = File(path).statSync();
return s.modified.millisecondsSinceEpoch ^ s.size;
} catch (_) {
return 0;
}
}
@override
bool operator ==(Object other) => other is ClideFileImage && other.file.path == file.path && other.scale == scale && other._stamp == _stamp;
@override
int get hashCode => Object.hash(file.path, scale, _stamp);
}
+1
View File
@@ -13,6 +13,7 @@ export 'src/clide_collapser_card.dart';
export 'src/clide_column_hat.dart';
export 'src/clide_code_block.dart';
export 'src/clide_divider.dart';
export 'src/clide_file_image.dart';
export 'src/clide_filter_box.dart';
export 'src/clide_lightbox.dart';
export 'src/clide_markdown.dart';