Commit 90a71482 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: download, zip/unzip, PDF export, and ten quality-of-life fixes

Files
- Download to the phone (Android's save dialog) or Share — a file, a folder
  as .zip, or a multi-selection. Streamed to disk with progress and Cancel.
- Long-press selects; a selection zips, downloads, shares or deletes. A ⋮
  on every row carries the per-file actions.
- Compress to .zip and Extract, always into a new folder (the server never
  overwrites). Tapping a .zip lists its contents first.
- Export as PDF, made on the phone: markdown (tables, Arabic RTL, repo
  images inlined, mermaid rendered to SVG in a headless WebView because the
  print WebView runs without JavaScript), code as a numbered highlighted
  listing, images, a folder's markdown as one document with contents, and a
  whole chat. Saved to the phone, shared, or put into the repo beside the
  source under a name that never overwrites.

Quality of life
 1. Retry / Edit & resend under a failed run.
 2. Per-chat drafts that survive leaving the chat.
 3. Quick-prompt chips; save your own, long-press to remove.
 4. Screen stays on while a watched run is live (toggle in Settings).
 5. Image preview with pinch and double-tap zoom.
 6. Copy path, and "Ask Claude about this": a new chat, path pre-typed.
 7. Files reopens the folder last used in each project.
 8. Sort by name, date or size; size and age on every row.
 9. Git lists changed files per repo; each opens its own diff with a +/−
    count and Discard for unstaged changes; untracked files show as new.
10. Text size in Settings, app-wide and in the code editor (re_editor
    ignores the app text scale, so it is applied there explicitly).

Fixes
- File and Git actions never read the server's answer. A 200 carrying
  ok:false — a rejected push, a pull that cannot fast-forward — showed
  "Pushed"/"Pulled", and a refused delete said nothing.
- Uploads over 48 MB are refused before sending, with the reason.
- R8 keep rules for printing's android.print bridge; verified in the
  release mapping that PdfConvert and its callbacks keep their names.
- Version 1.4.0+14; Settings no longer claims 1.0.0.

Tests: test/pdf_export_test.dart (8) — span-balanced listing rows, escaping,
language detection, download names, and markdown image paths that cannot
climb out of the project.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 07dbbf6a
......@@ -38,6 +38,8 @@ android {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// Keeps printing's android.print bridge intact under R8 (see the file).
proguardFiles("proguard-rules.pro")
}
}
}
......
# printing's HTML -> PDF path subclasses android.print.PrintDocumentAdapter's
# package-private callbacks, so its bridge class must stay in package
# android.print under its own name. The package ships no consumer rules; without
# these, R8 may rename or move it in a release build and "Export as PDF" would
# fail only in the build that reaches the phone.
-keep class android.print.** { *; }
-keep class net.nfet.flutter.printing.** { *; }
......@@ -285,6 +285,7 @@ class FileEntry {
required this.size,
this.language = '',
this.modified,
this.binary = false,
});
final String name;
......@@ -294,6 +295,10 @@ class FileEntry {
final String language;
final DateTime? modified;
/// The server's own text/binary call, made from the extension or a NUL-byte
/// probe. Decides whether "Export as PDF" can render it as text.
final bool binary;
factory FileEntry.fromJson(Map<String, dynamic> j) => FileEntry(
name: _s(j['name']),
path: _s(j['path']),
......@@ -301,6 +306,7 @@ class FileEntry {
size: _i(j['size']),
language: _s(_any(j, ['language', 'lang'])),
modified: j['modified'] == null ? null : _dt(j['modified']),
binary: _b(_any(j, ['binary', 'isBinary'])),
);
}
......@@ -367,6 +373,7 @@ class RepoStatus {
this.ahead = 0,
this.behind = 0,
this.changed = 0,
this.staged = const [],
this.modified = const [],
this.untracked = const [],
});
......@@ -378,10 +385,16 @@ class RepoStatus {
final int ahead;
final int behind;
final int changed;
final List<String> staged;
final List<String> modified;
final List<String> untracked;
/// Every tracked path with a change, staged or not — what the Git tab lists
/// to open a per-file diff. A set, because a file can be both.
List<String> get tracked => {...staged, ...modified}.toList();
factory RepoStatus.fromJson(Map<String, dynamic> j) {
final stg = _strings(j['staged']);
final mod = _strings(j['modified']);
final unt = _strings(j['untracked']);
return RepoStatus(
......@@ -392,12 +405,50 @@ class RepoStatus {
ahead: _i(j['ahead']),
behind: _i(j['behind']),
changed: _i(_any(j, ['changes', 'count']), mod.length + unt.length),
staged: stg,
modified: mod,
untracked: unt,
);
}
}
// --- archives ---------------------------------------------------------------
class ZipEntry {
const ZipEntry({required this.name, required this.size, required this.isDir});
final String name;
final int size;
final bool isDir;
factory ZipEntry.fromJson(Map<String, dynamic> j) =>
ZipEntry(name: _s(j['name']), size: _i(j['size']), isDir: _b(j['isDir']));
}
/// What an archive holds, read without extracting it.
class ZipInfo {
const ZipInfo({
required this.path,
required this.entries,
required this.count,
required this.totalBytes,
required this.truncated,
});
final String path;
final List<ZipEntry> entries;
final int count;
final int totalBytes;
final bool truncated;
factory ZipInfo.fromJson(Map<String, dynamic> j) => ZipInfo(
path: _s(j['path']),
entries: _list(j['entries'], ZipEntry.fromJson),
count: _i(j['count']),
totalBytes: _i(j['totalBytes']),
truncated: _b(j['truncated']),
);
}
class Commit {
const Commit({required this.sha, required this.message, required this.author, required this.date});
final String sha;
......
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:dio/dio.dart';
......@@ -18,6 +19,13 @@ class ApiException implements Exception {
String toString() => message;
}
/// Cancels a download in flight, without the UI importing Dio.
class DownloadCancel {
final _token = CancelToken();
void cancel() => _token.cancel();
bool get isCancelled => _token.isCancelled;
}
/// One SSE frame from a run: appended output lines, a status, and whether the
/// run has ended.
class RunEvent {
......@@ -244,30 +252,141 @@ class SonApi {
Future<FileContent> read(String project, String path) async =>
FileContent.fromJson(_obj(await _get('/api/files/read', {'project': project, 'path': path})));
/// For anything that changes state. Several endpoints answer 200 with
/// `ok:false` — a rejected push, a pull that cannot fast-forward — and these
/// calls used to return without looking, which is how the Git tab said
/// "Pushed" for a push that never happened and a refused delete said nothing.
Future<Map<String, dynamic>> _act(Future<Response<dynamic>> call) async {
final j = _obj(await call);
if (j['ok'] == false) {
final m = (j['error'] ?? j['message'] ?? j['output'] ?? '').toString().trim();
throw ApiException(m.isEmpty ? 'The server reported a failure.' : m);
}
return j;
}
Future<void> write(String project, String path, String content) async =>
_send('POST', '/api/files/write', body: {'project': project, 'path': path, 'content': content});
_act(_send('POST', '/api/files/write', body: {'project': project, 'path': path, 'content': content}));
Future<void> createFile(String project, String path, [String content = '']) async =>
_send('POST', '/api/files/create', body: {'project': project, 'path': path, 'content': content});
_act(_send('POST', '/api/files/create', body: {'project': project, 'path': path, 'content': content}));
Future<void> mkdir(String project, String path) async =>
_send('POST', '/api/files/mkdir', body: {'project': project, 'path': path});
_act(_send('POST', '/api/files/mkdir', body: {'project': project, 'path': path}));
Future<void> deletePath(String project, String path) async =>
_send('POST', '/api/files/delete', body: {'project': project, 'path': path});
_act(_send('POST', '/api/files/delete', body: {'project': project, 'path': path}));
Future<void> rename(String project, String from, String to) async =>
_send('POST', '/api/files/rename', body: {'project': project, 'from': from, 'to': to});
_act(_send('POST', '/api/files/rename', body: {'project': project, 'from': from, 'to': to}));
Future<void> uploadFile(String project, String path, List<int> bytes) async =>
_send('POST', '/api/files/upload',
body: {'project': project, 'path': path, 'content': base64Encode(bytes), 'encoding': 'base64'});
/// Base64 inflates a file by a third, and the server takes a 64 MB body.
static const maxUploadBytes = 48 * 1024 * 1024;
Future<void> uploadFile(String project, String path, List<int> bytes) async {
if (bytes.length > maxUploadBytes) {
throw ApiException(
'${path.split('/').last} is ${(bytes.length / 1048576).toStringAsFixed(0)} MB — uploads are limited to 48 MB.');
}
await _act(_send('POST', '/api/files/upload',
body: {'project': project, 'path': path, 'content': base64Encode(bytes), 'encoding': 'base64'}));
}
Future<List<FileEntry>> search(String project, String query, [String path = '.']) async =>
_arr(await _get('/api/files/search', {'project': project, 'q': query, 'path': path}))
.map(FileEntry.fromJson)
.toList();
// Downloads use their own endpoint: /read caps at 1 MB and base64-inflates
// binaries, which is fine for the editor and wrong for getting a file off the
// box. A folder, or several entries of one folder, arrive as a single zip
// that the server builds while it streams.
Map<String, dynamic> _dl(String project, List<String> paths, {bool inline = false}) =>
{'project': project, 'path': paths, if (inline) 'inline': '1'};
/// Into memory: image previews, and PDF assembly.
Future<Uint8List> download(String project, List<String> paths, {bool inline = false}) async {
try {
final r = await _dio.get<List<int>>(
'/api/files/download',
queryParameters: _dl(project, paths, inline: inline),
options: Options(
responseType: ResponseType.bytes,
receiveTimeout: const Duration(minutes: 5),
listFormat: ListFormat.multi,
),
);
if (r.statusCode != 200) _failBytes(r.statusCode, r.data);
return Uint8List.fromList(r.data ?? const []);
} on DioException catch (e) {
throw ApiException(_network(e));
}
}
/// Onto disk, streamed — Download and Share, where the file can be any size.
Future<void> downloadToFile(
String project,
List<String> paths,
String savePath, {
void Function(int received, int total)? onProgress,
DownloadCancel? cancel,
}) async {
try {
final r = await _dio.download(
'/api/files/download',
savePath,
queryParameters: _dl(project, paths),
options: Options(receiveTimeout: const Duration(minutes: 10), listFormat: ListFormat.multi),
onReceiveProgress: onProgress,
cancelToken: cancel?._token,
);
if (r.statusCode != 200) {
// With validateStatus open, the error body was written where the file
// should be. Read the message out of it, then remove it.
final f = File(savePath);
final body = await f.exists() ? await f.readAsBytes() : null;
if (await f.exists()) await f.delete();
_failBytes(r.statusCode, body);
}
} on DioException catch (e) {
throw ApiException(CancelToken.isCancel(e) ? 'Cancelled' : _network(e));
}
}
Never _failBytes(int? status, List<int>? body) {
String? msg;
try {
final j = jsonDecode(utf8.decode(body ?? const []));
if (j is Map) msg = (j['error'] ?? j['message'])?.toString();
} catch (_) {/* not JSON: fall through to the status */}
throw ApiException(
msg?.isNotEmpty == true ? msg! : 'Download failed ($status)',
statusCode: status,
);
}
/// Compresses entries of one folder; returns the new archive's path. The
/// server never reuses an existing name, so this cannot append to an old zip.
Future<String> zip(String project, List<String> paths) async {
final j = _obj(await _send('POST', '/api/files/zip', body: {'project': project, 'paths': paths}));
return (j['path'] ?? '').toString();
}
/// Extracts into a new folder (never an existing one, never overwriting) and
/// returns where it went.
Future<({String path, String? warnings})> unzip(String project, String path, {String? dest}) async {
final j = _obj(await _send('POST', '/api/files/unzip', body: {
'project': project,
'path': path,
if (dest != null && dest.isNotEmpty) 'dest': dest,
}));
return (path: (j['path'] ?? '').toString(), warnings: j['warnings']?.toString());
}
Future<ZipInfo> zipInfo(String project, String path) async =>
ZipInfo.fromJson(_obj(await _get('/api/files/zipinfo', {'project': project, 'path': path})));
// --- git ------------------------------------------------------------------
Future<List<RepoStatus>> gitStatusAll() async =>
......@@ -289,12 +408,14 @@ class SonApi {
: const [];
}
Future<void> gitPull(String project) async => _send('POST', '/api/git/pull', body: {'project': project});
Future<void> gitPush(String project) async => _send('POST', '/api/git/push', body: {'project': project});
Future<void> gitPull(String project) async =>
_act(_send('POST', '/api/git/pull', body: {'project': project}));
Future<void> gitPush(String project) async =>
_act(_send('POST', '/api/git/push', body: {'project': project}));
Future<void> gitCommit(String project, String message) async =>
_send('POST', '/api/git/commit', body: {'project': project, 'message': message});
_act(_send('POST', '/api/git/commit', body: {'project': project, 'message': message}));
Future<void> gitDiscard(String project, String file) async =>
_send('POST', '/api/git/discard', body: {'project': project, 'path': file});
_act(_send('POST', '/api/git/discard', body: {'project': project, 'path': file}));
// --- caprover -------------------------------------------------------------
//
......
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Shown in Settings. Kept beside pubspec's `version:` by hand — reading it at
/// runtime would need package_info_plus for one string.
const kAppVersion = '1.4.0';
/// On-device conveniences: things that make the app nicer to come back to and
/// that the server has no reason to know.
class Prefs {
static Future<SharedPreferences> get _p => SharedPreferences.getInstance();
// --- chat drafts ----------------------------------------------------------
//
// Half a prompt typed and then a notification tapped used to be half a prompt
// gone. A draft lives until it is sent.
static Future<String> draft(String chatId) async => (await _p).getString('draft.$chatId') ?? '';
static Future<void> setDraft(String chatId, String text) async {
final p = await _p;
if (text.trim().isEmpty) {
await p.remove('draft.$chatId');
} else {
await p.setString('draft.$chatId', text);
}
}
// --- quick prompts --------------------------------------------------------
static const defaultQuickPrompts = <String>[
'Pull the latest first, then ',
'Run the tests and fix anything that fails.',
'Commit and push when you are done.',
'Explain what you changed and why.',
'Keep going.',
];
static Future<List<String>> quickPrompts() async {
final raw = (await _p).getString('quick_prompts');
if (raw == null) return defaultQuickPrompts;
try {
final l = jsonDecode(raw);
if (l is List) return [for (final e in l) e.toString()];
} catch (_) {/* a corrupt value falls back to the defaults */}
return defaultQuickPrompts;
}
static Future<void> setQuickPrompts(List<String> v) async =>
(await _p).setString('quick_prompts', jsonEncode(v));
// --- files ------------------------------------------------------------------
/// The folder last open in each project, so switching repos and back returns
/// to where you were instead of the root.
static Future<String?> lastDir(String project) async => (await _p).getString('last_dir.$project');
static Future<void> setLastDir(String project, String path) async =>
(await _p).setString('last_dir.$project', path);
/// 'name' | 'modified' | 'size'.
static Future<String> fileSort() async => (await _p).getString('file_sort') ?? 'name';
static Future<void> setFileSort(String v) async => (await _p).setString('file_sort', v);
// --- display ----------------------------------------------------------------
/// Keep the screen on while a run you are watching is live. On by default:
/// the screen locking mid-run is what makes a live feed stop being live.
static Future<bool> keepAwake() async => (await _p).getBool('keep_awake') ?? true;
static Future<void> setKeepAwake(bool v) async => (await _p).setBool('keep_awake', v);
}
/// App-wide text size, on top of the phone's own setting. 1.0 leaves it as is.
class TextScaleNotifier extends StateNotifier<double> {
TextScaleNotifier() : super(1.0) {
_restore();
}
static const min = 0.85;
static const max = 1.3;
Future<void> _restore() async {
final p = await SharedPreferences.getInstance();
state = (p.getDouble('text_scale') ?? 1.0).clamp(min, max);
}
Future<void> set(double v) async {
state = v.clamp(min, max);
final p = await SharedPreferences.getInstance();
await p.setDouble('text_scale', state);
}
}
final textScaleProvider =
StateNotifierProvider<TextScaleNotifier, double>((ref) => TextScaleNotifier());
This diff is collapsed.
......@@ -8,9 +8,12 @@ import 'package:re_highlight/styles/atom-one-dark.dart';
import '../../core/api/models.dart';
import '../../core/api/son_api.dart';
import '../../core/prefs.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import 'file_actions.dart';
import 'mermaid_view.dart';
import 'pdf_flow.dart';
/// Opens a file: a real code editor for text, a rendered document for markdown,
/// and an honest "binary" panel only when the server actually says so.
......@@ -109,6 +112,28 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
}
}
void _more(String v) {
final api = ref.read(apiProvider);
switch (v) {
case 'download':
case 'share':
// These fetch the file from the server, so unsaved edits are not in it.
if (_dirty) toast(context, 'Using the saved version — save first to include your edits.');
v == 'download'
? FileActions.download(context, api,
project: widget.project, paths: [widget.path], fileName: widget.name)
: FileActions.shareRemote(context, api,
project: widget.project, paths: [widget.path], fileName: widget.name);
case 'pdf':
// The text on screen, unsaved edits included.
PdfFlow.forFile(context, api,
project: widget.project, path: widget.path, name: widget.name, source: _code.text);
case 'path':
Clipboard.setData(ClipboardData(text: widget.path));
toast(context, 'Copied ${widget.path}');
}
}
Future<bool> _confirmLeave() async {
if (!_dirty) return true;
final ok = await showDialog<bool>(
......@@ -182,6 +207,16 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
: Icon(Icons.save_outlined, color: _dirty ? Ember.flame : Ember.textFaint),
onPressed: _dirty && !_saving ? _save : null,
),
PopupMenuButton<String>(
onSelected: _more,
itemBuilder: (_) => [
const PopupMenuItem(value: 'download', child: Text('Download to phone')),
const PopupMenuItem(value: 'share', child: Text('Share')),
if (f != null && !f.isBinary)
const PopupMenuItem(value: 'pdf', child: Text('Export as PDF')),
const PopupMenuItem(value: 'path', child: Text('Copy path')),
],
),
],
),
body: _body(f, isMd),
......@@ -211,8 +246,27 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
const SizedBox(height: 14),
const Text('Binary file', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text('${f.name} · ${f.size} B',
Text('${f.name} · ${humanSize(f.size)}',
style: const TextStyle(color: Ember.textDim, fontSize: 12)),
const SizedBox(height: 20),
Wrap(
spacing: 10,
runSpacing: 10,
alignment: WrapAlignment.center,
children: [
FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: Ember.flame),
onPressed: () => _more('download'),
icon: const Icon(Icons.download_outlined, size: 18),
label: const Text('Download'),
),
OutlinedButton.icon(
onPressed: () => _more('share'),
icon: const Icon(Icons.ios_share, size: 18),
label: const Text('Share'),
),
],
),
],
),
),
......@@ -227,7 +281,9 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
return CodeEditor(
controller: _code,
style: CodeEditorStyle(
fontSize: 12.5,
// re_editor sizes its own text and ignores the app's text scale, so
// the Settings text size is applied here explicitly.
fontSize: 12.5 * ref.watch(textScaleProvider),
fontFamily: 'monospace',
backgroundColor: Ember.bg,
cursorColor: Ember.flame,
......
import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/api/son_api.dart';
import '../../core/theme.dart';
/// Getting bytes off the server and onto the phone: saved through Android's
/// document picker, or handed to another app through the share sheet.
class FileActions {
/// Android's save dialog takes the whole file as bytes over a platform
/// channel. Past this, Share (which passes a file path) is the safe route.
static const saveLimitBytes = 200 * 1024 * 1024;
/// Where a download is named once it lands: the entry's own name, or a zip
/// for a folder or a multi-selection — matching what the server streams.
static String downloadName(List<String> paths, {required bool isDir, String folderName = 'files'}) {
String base(String p) => p.split('/').where((s) => s.isNotEmpty && s != '.').lastOrNull ?? folderName;
if (paths.length > 1) return '$folderName.zip';
final name = base(paths.single);
return isDir ? '$name.zip' : name;
}
/// Streams [paths] into the app's cache behind a progress dialog with a
/// Cancel button. Returns null if it failed or was cancelled.
static Future<File?> fetch(
BuildContext context,
SonApi api, {
required String project,
required List<String> paths,
required String fileName,
}) async {
final dir = await _scratch();
final file = File('${dir.path}/$fileName');
final progress = ValueNotifier<double?>(null);
final received = ValueNotifier<int>(0);
final cancel = DownloadCancel();
if (!context.mounted) return null;
final nav = Navigator.of(context, rootNavigator: true);
var shown = true;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => _ProgressDialog(
name: fileName,
progress: progress,
received: received,
onCancel: cancel.cancel,
),
).whenComplete(() => shown = false);
try {
await api.downloadToFile(
project,
paths,
file.path,
cancel: cancel,
onProgress: (r, t) {
received.value = r;
progress.value = t > 0 ? r / t : null; // a streamed zip has no length
},
);
return file;
} on ApiException catch (e) {
if (!cancel.isCancelled && context.mounted) toast(context, e.message, bad: true);
return null;
} finally {
if (shown) nav.pop();
}
}
/// Download: fetch, then Android's "save as" so the user picks the folder.
static Future<void> download(
BuildContext context,
SonApi api, {
required String project,
required List<String> paths,
required String fileName,
}) async {
final f = await fetch(context, api, project: project, paths: paths, fileName: fileName);
if (f == null || !context.mounted) return;
final size = await f.length();
if (size > saveLimitBytes) {
if (context.mounted) {
toast(context, 'Too large for the save dialog (${humanSize(size)}). Opening Share instead.');
}
await share(f);
return;
}
final bytes = await f.readAsBytes();
if (!context.mounted) return;
await saveBytes(context, fileName: fileName, bytes: bytes);
}
static Future<void> shareRemote(
BuildContext context,
SonApi api, {
required String project,
required List<String> paths,
required String fileName,
}) async {
final f = await fetch(context, api, project: project, paths: paths, fileName: fileName);
if (f != null) await share(f);
}
static Future<void> saveBytes(
BuildContext context, {
required String fileName,
required Uint8List bytes,
}) async {
try {
final where = await FilePicker.platform.saveFile(fileName: fileName, bytes: bytes);
if (where != null && context.mounted) toast(context, 'Saved $fileName');
} catch (e) {
if (context.mounted) toast(context, 'Could not save: $e', bad: true);
}
}
static Future<void> share(File f) => Share.shareXFiles([XFile(f.path)]);
/// Writes bytes made on the phone (a PDF) somewhere Share can read them.
static Future<File> writeTemp(String fileName, Uint8List bytes) async {
final f = File('${(await _scratch()).path}/$fileName');
await f.writeAsBytes(bytes, flush: true);
return f;
}
/// A fresh folder per download, so two files with one name never collide,
/// and yesterday's downloads are cleared on the way in rather than piling up
/// in the cache.
static Future<Directory> _scratch() async {
final root = Directory('${(await getTemporaryDirectory()).path}/downloads');
if (await root.exists()) {
final cutoff = DateTime.now().subtract(const Duration(hours: 6));
await for (final e in root.list()) {
try {
if ((await e.stat()).modified.isBefore(cutoff)) await e.delete(recursive: true);
} catch (_) {/* in use by a share still open: next time */}
}
}
final d = Directory('${root.path}/${DateTime.now().microsecondsSinceEpoch}');
await d.create(recursive: true);
return d;
}
}
String humanSize(int b) {
if (b < 1024) return '$b B';
if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB';
if (b < 1024 * 1024 * 1024) return '${(b / 1048576).toStringAsFixed(1)} MB';
return '${(b / 1073741824).toStringAsFixed(2)} GB';
}
void toast(BuildContext context, String m, {bool bad = false}) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(m),
backgroundColor: bad ? Ember.bad.withValues(alpha: 0.9) : Ember.surfaceHi,
));
}
class _ProgressDialog extends StatelessWidget {
const _ProgressDialog({
required this.name,
required this.progress,
required this.received,
required this.onCancel,
});
final String name;
final ValueNotifier<double?> progress;
final ValueNotifier<int> received;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
child: AlertDialog(
title: const Text('Downloading', style: TextStyle(fontSize: 16)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, color: Ember.textDim)),
const SizedBox(height: 14),
ValueListenableBuilder<double?>(
valueListenable: progress,
builder: (_, v, __) => LinearProgressIndicator(value: v, color: Ember.flame),
),
const SizedBox(height: 8),
ValueListenableBuilder<int>(
valueListenable: received,
builder: (_, r, __) => Text(humanSize(r),
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
),
],
),
actions: [TextButton(onPressed: onCancel, child: const Text('Cancel'))],
),
);
}
}
This diff is collapsed.
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import 'file_actions.dart';
import 'pdf_flow.dart';
/// An image from the repo, shown as an image — not as the "binary file" panel
/// it used to land on. Pinch to zoom; double-tap to zoom in and back out.
class ImageViewerScreen extends ConsumerStatefulWidget {
const ImageViewerScreen({
super.key,
required this.project,
required this.path,
required this.name,
});
final String project;
final String path;
final String name;
@override
ConsumerState<ImageViewerScreen> createState() => _ImageViewerScreenState();
}
class _ImageViewerScreenState extends ConsumerState<ImageViewerScreen> {
final _zoom = TransformationController();
Offset _tap = Offset.zero;
Uint8List? _bytes;
Object? _error;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_zoom.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() => _error = null);
try {
final b = await ref.read(apiProvider).download(widget.project, [widget.path], inline: true);
if (mounted) setState(() => _bytes = b);
} catch (e) {
if (mounted) setState(() => _error = e);
}
}
void _toggleZoom() {
if (_zoom.value.getMaxScaleOnAxis() > 1.01) {
_zoom.value = Matrix4.identity();
return;
}
const s = 2.5;
// Scale about the tapped point, so what you double-tapped stays under
// your finger instead of flying off to a corner.
_zoom.value = Matrix4.diagonal3Values(s, s, 1)
..setTranslationRaw((1 - s) * _tap.dx, (1 - s) * _tap.dy, 0);
}
@override
Widget build(BuildContext context) {
final bytes = _bytes;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
Text(bytes == null ? widget.path : '${widget.path} · ${humanSize(bytes.length)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 10, color: Ember.textFaint)),
],
),
actions: [
IconButton(
tooltip: 'Download',
icon: const Icon(Icons.download_outlined),
onPressed: bytes == null
? null
: () => FileActions.saveBytes(context, fileName: widget.name, bytes: bytes),
),
IconButton(
tooltip: 'Share',
icon: const Icon(Icons.ios_share),
onPressed: bytes == null
? null
: () async => FileActions.share(await FileActions.writeTemp(widget.name, bytes)),
),
IconButton(
tooltip: 'Export as PDF',
icon: const Icon(Icons.picture_as_pdf_outlined),
onPressed: () => PdfFlow.forFile(context, ref.read(apiProvider),
project: widget.project, path: widget.path, name: widget.name),
),
],
),
body: _error != null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(24),
child: Text('$_error',
textAlign: TextAlign.center, style: const TextStyle(color: Ember.textDim)),
),
OutlinedButton(onPressed: _load, child: const Text('Retry')),
],
),
)
: bytes == null
? const Center(child: CircularProgressIndicator())
: GestureDetector(
onDoubleTapDown: (d) => _tap = d.localPosition,
onDoubleTap: _toggleZoom,
child: InteractiveViewer(
transformationController: _zoom,
minScale: 0.5,
maxScale: 10,
child: SizedBox.expand(
child: Image.memory(
bytes,
fit: BoxFit.contain,
gaplessPlayback: true,
errorBuilder: (_, __, ___) => const Center(
child: Text('This image format cannot be displayed.',
style: TextStyle(color: Ember.textDim)),
),
),
),
),
),
);
}
}
This diff is collapsed.
This diff is collapsed.
......@@ -60,6 +60,79 @@ class _RepoCard extends ConsumerStatefulWidget {
class _RepoCardState extends ConsumerState<_RepoCard> {
bool _busy = false;
bool _open = false;
/// Changed files, each tappable for its own diff — the whole-repo diff was
/// the only view, and finding one file in it meant scrolling through all.
Widget _changedFiles(RepoStatus r) {
final all = <(String, bool)>[
for (final f in r.tracked) (_clean(f), false),
for (final f in r.untracked) (_clean(f), true),
];
if (all.isEmpty) return const SizedBox.shrink();
final shown = _open ? all.take(60).toList() : const <(String, bool)>[];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () => setState(() => _open = !_open),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Icon(_open ? Icons.expand_less : Icons.expand_more, size: 16, color: Ember.textDim),
const SizedBox(width: 4),
Text(
_open
? 'Hide changed files'
: 'Show ${all.length} changed file${all.length == 1 ? '' : 's'}',
style: const TextStyle(fontSize: 12, color: Ember.textDim),
),
],
),
),
),
for (final (f, untracked) in shown)
InkWell(
onTap: () => _showDiff(file: f, untracked: untracked),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 18,
child: Text(
untracked ? 'U' : (r.modified.map(_clean).contains(f) ? 'M' : 'S'),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w700,
color: untracked ? Ember.ok : Ember.amber),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(f,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, fontFamilyFallback: Ember.mono)),
),
const Icon(Icons.chevron_right, size: 16, color: Ember.textFaint),
],
),
),
),
if (_open && all.length > shown.length)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text('…and ${all.length - shown.length} more — Diff shows everything',
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
),
],
);
}
SonApi get _api => ref.read(apiProvider);
......@@ -102,23 +175,89 @@ class _RepoCardState extends ConsumerState<_RepoCard> {
await _run(() => _api.gitCommit(widget.repo.name, msg), 'Committed');
}
Future<void> _showDiff() async {
/// Porcelain quotes paths with spaces and writes renames as "old -> new";
/// git wants the plain, current path back.
static String _clean(String f) {
final p = f.contains(' -> ') ? f.split(' -> ').last : f;
return p.length > 1 && p.startsWith('"') && p.endsWith('"') ? p.substring(1, p.length - 1) : p;
}
static String _stat(String diff) {
var add = 0, del = 0;
for (final l in diff.split('\n')) {
if (l.startsWith('+') && !l.startsWith('+++')) {
add++;
} else if (l.startsWith('-') && !l.startsWith('---')) {
del++;
}
}
return '+$add −$del';
}
Future<void> _discard(String file) async {
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Discard changes?'),
content: Text('$file goes back to its last committed state. This cannot be undone.'),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Discard', style: TextStyle(color: Ember.bad)),
),
],
),
);
if (ok == true) await _run(() => _api.gitDiscard(widget.repo.name, file), 'Discarded $file');
}
/// The repo's whole diff, or one file's. An untracked file has no diff yet,
/// so it is shown as what it is: every line new.
Future<void> _showDiff({String? file, bool untracked = false}) async {
final repo = widget.repo;
try {
final diff = await _api.gitDiff(widget.repo.name);
final String diff;
if (untracked && file != null) {
final f = await _api.read(repo.name, file);
diff = f.isBinary
? ''
: f.content.split('\n').map((l) => '+$l').join('\n');
} else {
diff = await _api.gitDiff(repo.name, file);
}
if (!mounted) return;
final canDiscard = file != null && repo.modified.map(_clean).contains(file);
showModalBottomSheet(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
builder: (sheet) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.8,
builder: (_, ctrl) => Column(
children: [
const SizedBox(height: 14),
Text('${widget.repo.name} · diff',
const SizedBox(height: 6),
ListTile(
dense: true,
title: Text(file ?? '${repo.name} · all changes',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 10),
subtitle: Text(untracked ? 'new file, not tracked yet' : _stat(diff),
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
trailing: canDiscard
? TextButton.icon(
onPressed: () {
Navigator.pop(sheet);
_discard(file);
},
icon: const Icon(Icons.undo, size: 16, color: Ember.bad),
label: const Text('Discard', style: TextStyle(color: Ember.bad)),
)
: null,
),
const Divider(height: 1),
Expanded(
child: diff.trim().isEmpty
? const Center(
......@@ -195,13 +334,14 @@ class _RepoCardState extends ConsumerState<_RepoCard> {
_tag(Icons.check, 'clean', Ember.ok),
],
),
if (r.dirty) _changedFiles(r),
const SizedBox(height: 10),
if (_busy)
const LinearProgressIndicator(minHeight: 2, color: Ember.flame)
else
Row(
children: [
_act(Icons.difference_outlined, 'Diff', _showDiff),
_act(Icons.difference_outlined, 'Diff', () => _showDiff()),
_act(Icons.download_outlined, 'Pull',
() => _run(() => _api.gitPull(r.name), 'Pulled')),
_act(Icons.check_circle_outline, 'Commit', r.dirty ? _commit : null),
......
......@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/models.dart';
import '../../core/lock.dart';
import '../../core/prefs.dart';
import '../../core/providers.dart';
import '../../core/watcher.dart';
import '../../core/theme.dart';
......@@ -19,6 +20,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
bool _lockOn = false;
bool _lockAvailable = false;
bool _watchOn = false;
bool _awakeOn = true;
@override
void initState() {
......@@ -30,11 +32,13 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final a = await Lock.available();
final l = await Lock.enabled();
final w = await Watcher.enabled();
final k = await Prefs.keepAwake();
if (!mounted) return;
setState(() {
_lockAvailable = a;
_lockOn = l;
_watchOn = w;
_awakeOn = k;
});
}
......@@ -42,6 +46,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Widget build(BuildContext context) {
final session = ref.watch(sessionProvider);
final usage = ref.watch(usageProvider);
final scale = ref.watch(textScaleProvider);
return Scaffold(
appBar: AppBar(
......@@ -100,6 +105,56 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
),
),
const SizedBox(height: 20),
const _SectionLabel('Display'),
Card(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
child: Row(
children: [
const Text('Text size', style: TextStyle(fontSize: 14)),
const Spacer(),
Text('${(scale * 100).round()}%',
style: const TextStyle(fontSize: 12, color: Ember.textDim)),
],
),
),
Slider(
value: scale,
min: TextScaleNotifier.min,
max: TextScaleNotifier.max,
divisions: 9,
activeColor: Ember.flame,
label: '${(scale * 100).round()}%',
onChanged: (v) => ref.read(textScaleProvider.notifier).set(v),
),
const Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Text(
'Chats, files and code — on top of the phone\'s own text size.',
style: TextStyle(fontSize: 11, color: Ember.textFaint),
),
),
const Divider(height: 1),
SwitchListTile(
value: _awakeOn,
activeThumbColor: Ember.flame,
title: const Text('Keep screen on during live runs',
style: TextStyle(fontSize: 14)),
subtitle: const Text(
'Only while a chat with a running task is open.',
style: TextStyle(fontSize: 11, color: Ember.textFaint),
),
onChanged: (v) async {
setState(() => _awakeOn = v);
await Prefs.setKeepAwake(v);
},
),
],
),
),
const SizedBox(height: 20),
const _SectionLabel('Background'),
Card(
child: SwitchListTile(
......@@ -186,7 +241,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
),
const Center(
child: Text('1.0.0',
child: Text(kAppVersion,
style: TextStyle(color: Ember.textFaint, fontSize: 11)),
),
],
......
......@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/lock.dart';
import 'core/notifications.dart';
import 'core/prefs.dart';
import 'core/watcher.dart';
import 'core/providers.dart';
import 'core/theme.dart';
......@@ -35,10 +36,20 @@ class SonOfAntonApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final session = ref.watch(sessionProvider);
final scale = ref.watch(textScaleProvider);
return MaterialApp(
title: 'Son of Anton',
debugShowCheckedModeBanner: false,
theme: buildTheme(),
// The in-app text size multiplies the phone's own setting instead of
// replacing it, so someone who already enlarged system text keeps that.
builder: (context, child) {
final mq = MediaQuery.of(context);
return MediaQuery(
data: mq.copyWith(textScaler: TextScaler.linear(mq.textScaler.scale(14) / 14 * scale)),
child: child!,
);
},
// Deliberately not go_router: this app has one auth gate and a tab shell.
// A declarative router here would be indirection with nothing to route.
home: session.isAuthed ? const _Guarded(child: HomeShell()) : const LoginScreen(),
......
......@@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: archive
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.2.0"
version: "4.0.9"
args:
dependency: transitive
description:
......@@ -25,6 +25,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.1"
barcode:
dependency: transitive
description:
name: barcode
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
url: "https://pub.dev"
source: hosted
version: "2.2.9"
bidi:
dependency: transitive
description:
name: bidi
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
url: "https://pub.dev"
source: hosted
version: "2.0.13"
boolean_selector:
dependency: transitive
description:
......@@ -664,6 +680,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
package_info_plus:
dependency: transitive
description:
name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev"
source: hosted
version: "9.0.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: transitive
description:
......@@ -672,6 +704,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: "direct main"
description:
......@@ -720,6 +760,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
pdf:
dependency: transitive
description:
name: pdf
sha256: "517df47af468734a23c8b513c0c6a8a62357403ab1b8ab7fad1606576a9dbdd0"
url: "https://pub.dev"
source: hosted
version: "3.13.0"
pdf_widget_wrapper:
dependency: transitive
description:
name: pdf_widget_wrapper
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
url: "https://pub.dev"
source: hosted
version: "1.0.4"
petitparser:
dependency: transitive
description:
......@@ -752,6 +808,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.5.2"
printing:
dependency: "direct main"
description:
name: printing
sha256: f6cd14c768c1352dd37a958a3ee351aa9ce305b398218d3acd86389d5f7ecad1
url: "https://pub.dev"
source: hosted
version: "5.15.0"
pub_semver:
dependency: transitive
description:
......@@ -760,6 +824,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.1"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
re_editor:
dependency: "direct main"
description:
......@@ -1005,6 +1077,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.3.0"
wakelock_plus:
dependency: "direct main"
description:
name: wakelock_plus
sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1
url: "https://pub.dev"
source: hosted
version: "1.5.2"
wakelock_plus_platform_interface:
dependency: transitive
description:
name: wakelock_plus_platform_interface
sha256: "764c25504562abc8ac3406f5d175f126b276e6ba916c7c2675690b73368ca384"
url: "https://pub.dev"
source: hosted
version: "1.7.0"
web:
dependency: transitive
description:
......
name: son_of_anton
description: Drive Claude Code against your GitLab repos from your phone.
publish_to: 'none'
version: 1.0.0+1
version: 1.4.0+14
environment:
sdk: ^3.8.0
......@@ -55,6 +55,8 @@ dependencies:
timeago: ^3.7.0
mime: ^2.0.0
collection: ^1.18.0
printing: ^5.15.0
wakelock_plus: ^1.5.2
dev_dependencies:
flutter_test:
......
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:son_of_anton/core/api/son_api.dart';
import 'package:son_of_anton/features/files/file_actions.dart';
import 'package:son_of_anton/features/files/pdf_export.dart';
import 'package:son_of_anton/features/files/pdf_flow.dart';
/// Records which repo paths an image loader asked for, without a network.
class _FakeApi extends SonApi {
_FakeApi() : super(baseUrl: 'https://example.invalid', token: 't');
final asked = <String>[];
@override
Future<Uint8List> download(String project, List<String> paths, {bool inline = false}) async {
asked.add(paths.single);
return Uint8List.fromList([1, 2, 3]);
}
}
List<String> _rows(String html) =>
RegExp(r'<div class="l">.*?</div>').allMatches(html).map((m) => m.group(0)!).toList();
void main() {
group('code listing for PDF', () {
test('a comment spanning lines keeps every row balanced and coloured', () {
final rows = _rows(PdfExport.listingHtml('/* one\ntwo\nthree */\nfinal x = 1;\n', 'dart'));
expect(rows.length, 4, reason: 'the trailing newline must not add an empty row');
for (final r in rows) {
expect('<span'.allMatches(r).length, '</span>'.allMatches(r).length, reason: r);
}
expect(rows[1], contains('two'));
expect(rows[1], contains('hljs-comment'),
reason: 'the middle line of a block comment is still a comment');
});
test('rows are numbered from 1', () {
final rows = _rows(PdfExport.listingHtml('a\nb\nc', ''));
expect(rows.map((r) => RegExp(r'<span class="n">(\d+)</span>').firstMatch(r)!.group(1)),
['1', '2', '3']);
});
test('plain text is escaped, not interpreted', () {
expect(PdfExport.listingHtml('<b>&"', ''), contains('&lt;b&gt;&amp;&quot;'));
});
test('an empty line still gets a row of height', () {
final rows = _rows(PdfExport.listingHtml('a\n\nb', ''));
expect(rows.length, 3);
expect(rows[1], contains('&#8203;'));
});
});
group('language detection', () {
test('by extension, alias and well-known name', () {
expect(PdfExport.languageFor('src/a.ts'), 'typescript');
expect(PdfExport.languageFor('lib/main.dart'), 'dart');
expect(PdfExport.languageFor('Dockerfile'), 'dockerfile');
expect(PdfExport.languageFor('notes.unknownext'), '');
expect(PdfExport.isMarkdown('docs/README.MD'), isTrue);
});
});
group('names', () {
test('what a download is saved as', () {
expect(FileActions.downloadName(['a/b/c.txt'], isDir: false), 'c.txt');
expect(FileActions.downloadName(['a/b'], isDir: true), 'b.zip');
expect(FileActions.downloadName(['.'], isDir: true, folderName: 'clubphp'), 'clubphp.zip');
expect(FileActions.downloadName(['x', 'y'], isDir: false, folderName: 'docs'), 'docs.zip');
});
test('file-name safety', () {
expect(safeFileName('Fix: a/b?'), 'Fix a b');
expect(safeFileName(' '), 'export');
expect(stemOf('report.final.md'), 'report.final');
expect(stemOf('.env'), '.env');
expect(parentOf('a/b/c.md'), 'a/b');
expect(parentOf('c.md'), '');
expect(isZipName('X.ZIP'), isTrue);
expect(isImageName('shot.webp'), isTrue);
});
});
test('markdown images resolve against the document and never climb out', () async {
final api = _FakeApi();
final load = repoImages(api, 'p', 'docs/guide/intro.md');
await load('img/a.png');
await load('./img/b.png');
await load('../shared/c.png');
await load('/logo.png');
expect(await load('../../../etc/passwd'), isNull);
expect(api.asked, ['docs/guide/img/a.png', 'docs/guide/img/b.png', 'docs/shared/c.png', 'logo.png']);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment