426 lines
21 KiB
Dart
426 lines
21 KiB
Dart
/// 복사 · rsync 작업 목록과 카드 (모니터링 > 복사 · rsync 탭, 다른 화면도 그대로 넣어 씀).
|
|
/// 약속:
|
|
/// - CopyTaskList(c): CopyCenter.of(c) 의 작업을 카드로 보임. 없으면 안내 글. 작업 · 설정이 바뀌면 다시 그림.
|
|
/// - CopyTaskCard(c, task): 한 작업의 옵션 고치기 · 저장 · 실행 · 멈춤 · 실패 원문 · lsync 로 옮기기.
|
|
/// 카드가 고치던 칸은 카드 자신의 상태 (key 를 ValueKey(task.id) 로 주어야 목록이 바뀌어도 남음).
|
|
/// 실패는 SnackBar · 창으로 알리고, CopyCenter.start 의 예외는 그대로 밖으로 나간다.
|
|
/// 설정은 읽기만 한다 (copySkipNewer · rsyncSource · rsyncOptions · robocopyOptions).
|
|
library;
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../../app/app_controller.dart';
|
|
import '../../app/copy_center.dart';
|
|
import '../../app/live_sync.dart';
|
|
import '../../app/settings.dart';
|
|
import '../../core/download_detect.dart' show redactUrlSecrets;
|
|
import '../../core/file_ops.dart';
|
|
import '../../core/sync_tools.dart';
|
|
import '../../core/vfs.dart';
|
|
import '../../l10n/tr.dart';
|
|
import '../path_label.dart';
|
|
import '../rsync_setup.dart';
|
|
import '../../core/sync_preview.dart' show optionsRemoveSource;
|
|
import '../sync_preview_view.dart' show OptionProblemNote, OptionTextFormatter, confirmDeletingRun, effectiveRunText;
|
|
import '../theme.dart';
|
|
import 'transfer_widgets.dart';
|
|
|
|
class CopyTaskList extends StatelessWidget {
|
|
final AppController c;
|
|
const CopyTaskList({super.key, required this.c});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final center = CopyCenter.of(c);
|
|
return ListenableBuilder(
|
|
listenable: Listenable.merge([center, c]),
|
|
builder: (context, _) {
|
|
final tasks = center.tasks;
|
|
if (tasks.isEmpty) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text(tr('실행한 rsync 가 없습니다. Rsync 화면에서 → · ← · ⇄ 로 실행하면 여기에 자동으로 등록됩니다.'),
|
|
textAlign: TextAlign.center, style: TextStyle(color: JjColors.textDim)),
|
|
),
|
|
);
|
|
}
|
|
return ListView(
|
|
padding: const EdgeInsets.all(12),
|
|
children: [for (final t in tasks) CopyTaskCard(key: ValueKey(t.id), c: c, task: t)],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class CopyTaskCard extends StatefulWidget {
|
|
final AppController c;
|
|
final CopyTask task;
|
|
const CopyTaskCard({super.key, required this.c, required this.task});
|
|
|
|
@override
|
|
State<CopyTaskCard> createState() => _CopyTaskCardState();
|
|
}
|
|
|
|
class _CopyTaskCardState extends State<CopyTaskCard> {
|
|
CopyCenter get center => CopyCenter.of(widget.c);
|
|
late String _method = widget.task.method;
|
|
late final _options = TextEditingController(text: widget.task.options);
|
|
late final _bw = TextEditingController(text: '${widget.task.bandwidthKBps}');
|
|
late bool _once = widget.task.once;
|
|
late String _prune = widget.task.prune;
|
|
|
|
bool get _dirty =>
|
|
_prune != widget.task.prune ||
|
|
_method != widget.task.method ||
|
|
_options.text.trim() != widget.task.options ||
|
|
(int.tryParse(_bw.text) ?? 0) != widget.task.bandwidthKBps ||
|
|
_once != widget.task.once;
|
|
|
|
@override
|
|
void dispose() {
|
|
_options.dispose();
|
|
_bw.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// 237: 실패 원문 보기 · 복사
|
|
Future<void> _showFailure(CopyTask t) {
|
|
final detail = redactUrlSecrets(t.lastDetail);
|
|
return showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
scrollable: true,
|
|
title: Text(t.lastMessage),
|
|
content: SizedBox(
|
|
width: 560,
|
|
child: SelectableText(detail, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () async {
|
|
await Clipboard.setData(ClipboardData(text: detail));
|
|
if (ctx.mounted) Navigator.pop(ctx);
|
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('복사했습니다'))));
|
|
},
|
|
child: Text(tr('복사')),
|
|
),
|
|
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기'))),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _run(CopyTask t) async {
|
|
// 236: 옵션 칸의 포커스를 푼다 - 남아 있으면 폰 · 태블릿에서 키보드가 올라와 끝 알림을 가린다
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
// 104: 지우기가 들어 있으면 Rsync 화면과 같은 미리 보기를 거친다 (그사이 대상에 생긴 파일을 묻지 않고 지우지 않게).
|
|
// 210: 이동 (원본 지우기) 도 - 카드의 [실행] 은 따로 묻는 창이 없어 곧바로 원본을 지웠다. 복사만은 지금처럼 바로
|
|
if (!await confirmDeletingRun(context, t, includeMove: true, skipNewer: widget.c.settings.copy.skipNewer) || !mounted) return;
|
|
widget.c.note(trf('rsync 시작 ({0}): {1} → {2}', [tr('모니터링의 [실행]'), t.sources.join(', '), t.dest]));
|
|
String? exe;
|
|
if (t.method == 'rsync') {
|
|
exe = await rsyncExecutable(widget.c.settings);
|
|
if (exe == null && Platform.isWindows && widget.c.settings.copy.rsyncSource == 'download' && mounted) {
|
|
exe = await installRsyncWithDialog(context);
|
|
}
|
|
if (exe == null) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('rsync 실행 파일을 찾을 수 없습니다. 환경 설정 > 파일 탐색기에서 경로를 확인하세요.'))));
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
final problem = transferProblem(t.sources, t.dest, move: t.move, contents: t.contents);
|
|
await center.start(t, rsyncExe: exe);
|
|
if (problem != null && mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problem)));
|
|
}
|
|
|
|
/// 칸에 보이는 (고친) 옵션으로 만든 작업
|
|
CopyTask _edited() => widget.task.copyWith(
|
|
method: _method,
|
|
options: _options.text.trim(),
|
|
bandwidthKBps: int.tryParse(_bw.text) ?? 0,
|
|
once: _once,
|
|
prune: _prune,
|
|
);
|
|
|
|
/// 114: [실행] 을 눌렀는데 저장하지 않은 고침이 있으면 묻는다 (칸에 보이는 것과 다른 옵션으로 몰래 돌지 않게).
|
|
/// 저장하고 실행하면 그 옵션으로 (지우기가 있으면 미리 보기 창)
|
|
Future<void> _runPressed(CopyTask t) async {
|
|
if (!_dirty) return _run(t);
|
|
final pick = await showDialog<String>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
scrollable: true,
|
|
actionsOverflowDirection: VerticalDirection.down,
|
|
title: Text(tr('고친 옵션을 저장하지 않았습니다')),
|
|
content: Text(trf('칸에 보이는 옵션: {0}\n저장된 옵션: {1}\n\n고친 옵션을 저장하고 실행할까요?', [
|
|
'${_method == 'builtin' ? tr('현재 방식') : _method} ${_options.text.trim()}',
|
|
// 178: 실제로 쓰는 옵션 (원본 지우기는 이동일 때만)
|
|
effectiveRunText(t.method, t.options, move: t.move, skipNewer: widget.c.settings.copy.skipNewer),
|
|
])),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))),
|
|
TextButton(onPressed: () => Navigator.pop(ctx, 'old'), child: Text(tr('저장된 옵션으로 실행'))),
|
|
FilledButton(onPressed: () => Navigator.pop(ctx, 'save'), child: Text(tr('저장하고 실행'))),
|
|
],
|
|
),
|
|
);
|
|
if (!mounted || pick == null) return;
|
|
if (pick == 'old') return _run(t);
|
|
final edited = _edited();
|
|
await center.update(edited);
|
|
if (mounted) await _run(edited);
|
|
}
|
|
|
|
/// 옵션 저장 → 지금 실행할지 묻기 (현재 유지 / 반영 후 실행)
|
|
Future<void> _save() async {
|
|
final t = _edited();
|
|
await center.update(t);
|
|
if (!mounted) return;
|
|
final running = center.isRunning(t.id);
|
|
final go = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
scrollable: true,
|
|
title: Text(tr('옵션을 저장했습니다')),
|
|
content: Text(running
|
|
// 233: 이동 카드는 "이동" 으로
|
|
? (t.move
|
|
? tr('지금 도는 이동은 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?')
|
|
: tr('지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?'))
|
|
: (t.move
|
|
? tr('이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?')
|
|
: tr('이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?'))),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('현재 유지'))),
|
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('설정 반영 후 실행'))),
|
|
],
|
|
),
|
|
);
|
|
if (go != true) return;
|
|
if (running) {
|
|
final old = center.jobs[t.id]!;
|
|
old.cancel();
|
|
await old.done;
|
|
}
|
|
await _run(t);
|
|
}
|
|
|
|
/// lsync 로: 끝나지 않은 것 (실행 중 · 실패 · 취소 · 아직 안 함) 도 옮길 수 있다 - 남은 것은 lsync 가 이어서 맞춘다.
|
|
/// 실행 중이면 물어본 뒤 멈추고 옮긴다.
|
|
Future<void> _toLive(CopyTask t) async {
|
|
final running = center.isRunning(t.id);
|
|
// 243: 이동 카드 - 실시간 동기화는 원본을 지우지 않는다 (107). 옮기기 전에 알린다
|
|
if (running || t.move) {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
scrollable: true,
|
|
title: Text(tr('lsync 로 이동')),
|
|
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
if (running) Text(tr('지금 실행 중입니다. 멈추고 lsync (실시간 동기화) 로 옮길까요? 남은 것은 lsync 가 이어서 맞춥니다.')),
|
|
if (running && t.move) const SizedBox(height: 10),
|
|
if (t.move)
|
|
Text(tr('실시간 동기화는 원본을 지우지 않습니다 (복사로 맞춤). 원본을 비우려면 lsync 의 [최종 정리] 를 쓰세요.'),
|
|
style: TextStyle(color: JjColors.warning)),
|
|
]),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(running ? tr('멈추고 옮기기') : tr('옮기기'))),
|
|
],
|
|
),
|
|
);
|
|
if (ok != true || !mounted) return;
|
|
}
|
|
if (running) {
|
|
final job = center.jobs[t.id]!;
|
|
job.cancel();
|
|
await job.done;
|
|
if (!mounted) return;
|
|
}
|
|
final n = await center.toLiveSync(t);
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text(n == 0 ? tr('폴더만 lsync 로 옮길 수 있습니다.') : trf('{0}개 폴더를 lsync (실시간 동기화) 로 옮겼습니다.', [n]))));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = widget.task;
|
|
final job = center.jobs[t.id];
|
|
final running = job != null && !job.finished;
|
|
final s = widget.c.settings;
|
|
final (icon, color, status) = running
|
|
? (Icons.sync, JjColors.accent, job.move ? tr('옮기는 중') : tr('복사하는 중'))
|
|
: switch (t.lastResult) {
|
|
// 235: 이동인데 원본에 남은 것이 있으면 "끝" 만 보이지 않게 (주황 · 남은 수)
|
|
'done' when t.lastLeft > 0 => (
|
|
Icons.warning_amber,
|
|
JjColors.warning,
|
|
'${trf('끝 · {0} · 파일 {1}개', [formatRunStamp(t.lastRun), t.lastFiles])} · ${trf('{0}개는 원본에 남음 (건너뜀)', [t.lastLeft])}'
|
|
),
|
|
'done' => (Icons.check_circle, JjColors.positive, trf('끝 · {0} · 파일 {1}개', [formatRunStamp(t.lastRun), t.lastFiles])),
|
|
'failed' => (Icons.error, JjColors.alert, trf('실패 · {0}', [formatRunStamp(t.lastRun)])),
|
|
'cancelled' => (Icons.pause_circle, JjColors.warning, trf('멈춤 · {0}', [formatRunStamp(t.lastRun)])),
|
|
_ => (Icons.schedule, JjColors.textDim, tr('아직 실행 안 함')),
|
|
};
|
|
final optsHint = switch (CopyMethod.of(_method)) {
|
|
CopyMethod.rsync => defaultRsyncOptions,
|
|
CopyMethod.robocopy => defaultRobocopyOptions,
|
|
CopyMethod.builtin => tr('(현재 방식은 옵션 없음)'),
|
|
};
|
|
return Card(
|
|
color: JjColors.panel,
|
|
margin: const EdgeInsets.only(bottom: 10),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Row(children: [
|
|
Icon(icon, color: color, size: 20),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
// 112: 끝 폴더가 보이게 짧게, 전체 경로는 작게
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(
|
|
t.sources.length == 1
|
|
? shortPath(t.sources.first)
|
|
: trf('{0} 외 {1}개', [shortPath(t.sources.first), t.sources.length - 1]),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
Text('→ ${shortPath(t.dest)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w600)),
|
|
Text('${vDisplay(t.sources.first)}${t.contents ? '/' : ''} → ${vDisplay(t.dest)}',
|
|
maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: JjColors.textDim)),
|
|
]),
|
|
),
|
|
Chip(
|
|
visualDensity: VisualDensity.compact,
|
|
label: Text('${t.move ? tr('이동') : tr('복사')} · ${tr(CopyMethod.of(t.method).label)}'),
|
|
),
|
|
]),
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 28, top: 2),
|
|
child: Wrap(spacing: 16, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
|
Text(status, style: TextStyle(fontSize: 12, color: color)),
|
|
DiskSpaceInfo(path: t.dest),
|
|
]),
|
|
),
|
|
if (t.lastResult == 'failed' && !running && t.lastMessage.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 28, top: 4),
|
|
child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 4, children: [
|
|
Text(t.lastMessage, maxLines: 3, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: JjColors.alert)),
|
|
// 237: 원문 (예외 이름 · 명령 줄) 은 [자세히] 에서 - 보고 복사해 물어볼 수 있게 (다운로드 실패 줄 136 과 같게)
|
|
if (t.lastDetail.trim().isNotEmpty)
|
|
TextButton(
|
|
style: TextButton.styleFrom(
|
|
visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 6), minimumSize: const Size(0, 28)),
|
|
onPressed: () => _showFailure(t),
|
|
child: Text(tr('자세히')),
|
|
),
|
|
]),
|
|
),
|
|
if (job != null && (running || job.finished && job.error == null))
|
|
Padding(padding: const EdgeInsets.only(left: 28, top: 6), child: TransferProgressLines(job: job)),
|
|
const SizedBox(height: 8),
|
|
// 옵션 (고치면 [저장] → 지금 실행할지 묻기)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 28),
|
|
child: Wrap(spacing: 10, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
|
DropdownButton<String>(
|
|
value: _method,
|
|
items: [
|
|
for (final m in CopyMethod.values)
|
|
if (copyMethodAvailable(m, s) || m.name == _method) DropdownMenuItem(value: m.name, child: Text(tr(m.label))),
|
|
],
|
|
onChanged: (v) => setState(() {
|
|
if (v != _method && _options.text.trim().isEmpty || _options.text.trim() == defaultRsyncOptions || _options.text.trim() == defaultRobocopyOptions) {
|
|
_options.text = v == 'robocopy' ? s.copy.robocopyOptions : v == 'rsync' ? s.copy.rsyncOptions : '';
|
|
}
|
|
_method = v!;
|
|
}),
|
|
),
|
|
SizedBox(
|
|
width: 320,
|
|
child: TextField(
|
|
controller: _options,
|
|
inputFormatters: const [OptionTextFormatter()],
|
|
enabled: _method != 'builtin',
|
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
|
|
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('옵션'), hintText: optsHint),
|
|
onChanged: (_) => setState(() {}),
|
|
),
|
|
),
|
|
// 209: 실제로 실행할 것 - 이동 / 복사를 분명히 (칸의 원본 지우기 옵션은 이동일 때만 쓰인다)
|
|
SizedBox(
|
|
width: 460,
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
|
Text(
|
|
'${t.move ? tr('이동') : tr('복사')} · ${trf('실제 실행: {0}', [effectiveRunText(_method, _options.text.trim(), move: t.move, skipNewer: s.copy.skipNewer)])}',
|
|
style: TextStyle(fontSize: 12, color: t.move ? JjColors.warning : JjColors.textDim),
|
|
),
|
|
// 관리자 지시 (10/20, 28 확인): 고쳐 읽어도 알아볼 수 없는 옵션 (실행은 작업이 막는다)
|
|
if (_method == 'rsync' && optionProblems(_options.text).isNotEmpty) OptionProblemNote(optionProblems(_options.text)),
|
|
if (!t.move && optionsRemoveSource(_options.text))
|
|
Text(tr('복사 작업이라 칸의 원본 지우기 옵션 (--remove-source-files · /MOV) 은 쓰지 않습니다. 옮기려면 Rsync 화면에서 [원본 파일 지우기] 를 켜세요'),
|
|
style: TextStyle(fontSize: 12, color: JjColors.warning)),
|
|
]),
|
|
),
|
|
SizedBox(
|
|
width: 130,
|
|
child: TextField(
|
|
controller: _bw,
|
|
keyboardType: TextInputType.number,
|
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
|
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: tr('속도 제한'), suffixText: 'KB/s'),
|
|
onChanged: (_) => setState(() {}),
|
|
),
|
|
),
|
|
if (t.sources.length > 1 && _method == 'rsync')
|
|
FilterChip(label: Text(tr('한 번에')), selected: _once, onSelected: (v) => setState(() => _once = v)),
|
|
// 이동 (원본 파일 지우기) 이면: 끝난 뒤 원본의 빈 폴더 · 원본 폴더 정리
|
|
if (t.move && _method == 'rsync')
|
|
DropdownButton<String>(
|
|
value: _prune,
|
|
items: [for (final v in ['', 'keep', 'all']) DropdownMenuItem(value: v, child: Text(pruneLabel(v)))],
|
|
onChanged: (v) => setState(() => _prune = v!),
|
|
),
|
|
if (_dirty) FilledButton.tonal(onPressed: _save, child: Text(tr('저장'))),
|
|
]),
|
|
),
|
|
const SizedBox(height: 6),
|
|
// 257 · 253: 폰 세로 · 글자가 크면 버튼이 화면 밖으로 넘치지 않게 줄을 바꾼다 (오른쪽 정렬은 그대로)
|
|
Wrap(alignment: WrapAlignment.end, crossAxisAlignment: WrapCrossAlignment.center, spacing: 6, runSpacing: 4, children: [
|
|
if (running)
|
|
TextButton.icon(onPressed: () => center.cancel(t.id), icon: const Icon(Icons.stop, size: 18), label: Text(tr('취소')))
|
|
else
|
|
FilledButton.icon(
|
|
// 관리자 지시 (10/20, 28 지적): 옵션이 막히면 (빨간 안내가 보이면) 확인 창처럼 [실행] 도 끈다 - 누르면 실패로 끝났다
|
|
onPressed: _method == 'rsync' && optionProblems(_options.text).isNotEmpty ? null : () => _runPressed(t),
|
|
icon: const Icon(Icons.play_arrow, size: 18),
|
|
label: Text(t.lastResult == 'cancelled' || t.lastResult == 'failed' ? tr('재개') : tr('실행')),
|
|
),
|
|
TextButton.icon(
|
|
onPressed: () => _toLive(t),
|
|
icon: const Icon(Icons.sync_alt, size: 18),
|
|
label: Text(tr('lsync 로 이동')),
|
|
),
|
|
IconButton(
|
|
tooltip: tr('목록에서 지우기 (파일은 그대로)'),
|
|
icon: Icon(Icons.delete_outline, color: JjColors.textDim),
|
|
onPressed: () => center.remove(t.id),
|
|
),
|
|
]),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|