파일 탐색기 같은 이름 창: 두 파일의 크기 · 바뀐 날 · 어느 쪽이 새것인지 (172)

"doc.txt: 보낼 것 3B · 2026-10-09 12:00 / 있는 것 3B · 2026-10-01 08:00 (보낼 것이 더 새것)" (3개까지, 폴더는 "폴더").
_findClashes 가 vStat 으로 두 쪽을 함께 읽는다 (WebDAV 포함). explorer_conflict_test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음4
RyuJongJin 2026-10-10 04:19:47 +09:00
parent f805403641
commit a66f3a8823
5 changed files with 52 additions and 7 deletions

View File

@ -6,8 +6,11 @@
" (결과물 {0}개 함께)": " ({0} results moved too)", " (결과물 {0}개 함께)": " ({0} results moved too)",
" (대기열)": " (queued)", " (대기열)": " (queued)",
" (동영상 {0}개)": " ({0} videos)", " (동영상 {0}개)": " ({0} videos)",
" (바뀐 날 같음)": " (same modified time)",
" (보낼 것이 더 새것)": " (the one being sent is newer)",
" (보통)": " (normal)", " (보통)": " (normal)",
" (사용 불가)": " (unavailable)", " (사용 불가)": " (unavailable)",
" (있는 것이 더 새것)": " (the existing one is newer)",
" (자막 {0}개 함께)": " ({0} subtitles moved too)", " (자막 {0}개 함께)": " ({0} subtitles moved too)",
" (지금: 내림차순 · 다시 누르면 오름차순)": " (now: descending · press again for ascending)", " (지금: 내림차순 · 다시 누르면 오름차순)": " (now: descending · press again for ascending)",
" (지금: 오름차순 · 다시 누르면 내림차순)": " (now: ascending · press again for descending)", " (지금: 오름차순 · 다시 누르면 내림차순)": " (now: ascending · press again for descending)",
@ -932,6 +935,7 @@
"보기": "View", "보기": "View",
"보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom", "보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "How often [Slideshow] in the viewer turns to the next page", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "How often [Slideshow] in the viewer turns to the next page",
"보낼 것 {0} / 있는 것 {1}": "sending {0} / existing {1}",
"보던 폴더가 없어져 상위 폴더로 옮겼습니다": "The folder you were viewing is gone, so moved to its parent folder", "보던 폴더가 없어져 상위 폴더로 옮겼습니다": "The folder you were viewing is gone, so moved to its parent folder",
"보안": "Security", "보안": "Security",
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.", "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.",

View File

@ -6,8 +6,11 @@
" (결과물 {0}개 함께)": "(成果物 {0}個も一緒に)", " (결과물 {0}개 함께)": "(成果物 {0}個も一緒に)",
" (대기열)": "(キュー)", " (대기열)": "(キュー)",
" (동영상 {0}개)": "(動画 {0}個)", " (동영상 {0}개)": "(動画 {0}個)",
" (바뀐 날 같음)": " (更新日時が同じ)",
" (보낼 것이 더 새것)": " (送る方が新しい)",
" (보통)": "(標準)", " (보통)": "(標準)",
" (사용 불가)": "(使用不可)", " (사용 불가)": "(使用不可)",
" (있는 것이 더 새것)": " (既存の方が新しい)",
" (자막 {0}개 함께)": "(字幕 {0}個も一緒に)", " (자막 {0}개 함께)": "(字幕 {0}個も一緒に)",
" (지금: 내림차순 · 다시 누르면 오름차순)": "(現在: 降順 · もう一度押すと昇順)", " (지금: 내림차순 · 다시 누르면 오름차순)": "(現在: 降順 · もう一度押すと昇順)",
" (지금: 오름차순 · 다시 누르면 내림차순)": "(現在: 昇順 · もう一度押すと降順)", " (지금: 오름차순 · 다시 누르면 내림차순)": "(現在: 昇順 · もう一度押すと降順)",
@ -932,6 +935,7 @@
"보기": "表示", "보기": "表示",
"보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小", "보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "表示画面の [連続表示] が次のページへ進む間隔", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "表示画面の [連続表示] が次のページへ進む間隔",
"보낼 것 {0} / 있는 것 {1}": "送る方 {0} / 既存 {1}",
"보던 폴더가 없어져 상위 폴더로 옮겼습니다": "見ていたフォルダーがなくなったため上のフォルダーに移動しました", "보던 폴더가 없어져 상위 폴더로 옮겼습니다": "見ていたフォルダーがなくなったため上のフォルダーに移動しました",
"보안": "セキュリティ", "보안": "セキュリティ",
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。", "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。",

View File

@ -6,8 +6,11 @@
" (결과물 {0}개 함께)": "(成品 {0} 个一并移动)", " (결과물 {0}개 함께)": "(成品 {0} 个一并移动)",
" (대기열)": "(队列)", " (대기열)": "(队列)",
" (동영상 {0}개)": "({0} 个视频)", " (동영상 {0}개)": "({0} 个视频)",
" (바뀐 날 같음)": "(修改时间相同)",
" (보낼 것이 더 새것)": "(要发送的较新)",
" (보통)": "(标准)", " (보통)": "(标准)",
" (사용 불가)": "(不可用)", " (사용 불가)": "(不可用)",
" (있는 것이 더 새것)": "(已有的较新)",
" (자막 {0}개 함께)": "(字幕 {0} 个一并移动)", " (자막 {0}개 함께)": "(字幕 {0} 个一并移动)",
" (지금: 내림차순 · 다시 누르면 오름차순)": "(当前: 降序 · 再按一次改为升序)", " (지금: 내림차순 · 다시 누르면 오름차순)": "(当前: 降序 · 再按一次改为升序)",
" (지금: 오름차순 · 다시 누르면 내림차순)": "(当前: 升序 · 再按一次改为降序)", " (지금: 오름차순 · 다시 누르면 내림차순)": "(当前: 升序 · 再按一次改为降序)",
@ -932,6 +935,7 @@
"보기": "查看", "보기": "查看",
"보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放", "보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "浏览画面中 [连续浏览] 翻到下一页的间隔", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "浏览画面中 [连续浏览] 翻到下一页的间隔",
"보낼 것 {0} / 있는 것 {1}": "要发送 {0} / 已有 {1}",
"보던 폴더가 없어져 상위 폴더로 옮겼습니다": "正在查看的文件夹已不存在,已转到上级文件夹", "보던 폴더가 없어져 상위 폴더로 옮겼습니다": "正在查看的文件夹已不存在,已转到上级文件夹",
"보안": "安全", "보안": "安全",
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。", "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。",

View File

@ -1568,12 +1568,16 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 154: 항목 이름 (앞의 3개 + "외 n개") /// 154: 항목 이름 (앞의 3개 + "외 n개")
/// 48: 받는 폴더에 같은 이름이 있는 원본 (같은 폴더로 복사는 늘 새 이름이라 빼고) /// 48: 받는 폴더에 같은 이름이 있는 원본 (같은 폴더로 복사는 늘 새 이름이라 빼고)
static Future<List<String>> _findClashes(List<String> sources, String dest) async { /// 48 · 172: 대상 폴더에 같은 이름이 있는 원본 - 두 파일의 크기 · 바뀐 날을 함께 (어느 쪽이 새것인지 보고 고르게)
final out = <String>[]; static Future<List<NameClash>> _findClashes(List<String> sources, String dest) async {
final out = <NameClash>[];
for (final s in sources) { for (final s in sources) {
if (samePath(vDirname(s), dest)) continue; if (samePath(vDirname(s), dest)) continue;
try { try {
if (await vExists(vJoin(dest, vBasename(s)))) out.add(s); final there = await vStat(vJoin(dest, vBasename(s)));
if (there == null) continue;
final here = await vStat(s);
out.add((src: s, here: here, there: there));
} catch (_) {} } catch (_) {}
} }
return out; return out;
@ -1711,7 +1715,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
final builtin = method == CopyMethod.builtin || _viaDav([...sources, dest]); final builtin = method == CopyMethod.builtin || _viaDav([...sources, dest]);
// 확인 창은 바로 띄우고, 같은 이름은 창 안에서 뒤에서 찾는다 (느린 네트워크 · WebDAV 폴더라도 창이 늦게 뜨지 않게). // 확인 창은 바로 띄우고, 같은 이름은 창 안에서 뒤에서 찾는다 (느린 네트워크 · WebDAV 폴더라도 창이 늦게 뜨지 않게).
// 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽) // 다 찾기 전에 누르면 이름 바꾸기 (원래 파일을 건드리지 않는 쪽)
final clashesFuture = builtin ? _findClashes(sources, dest) : Future.value(const <String>[]); final clashesFuture = builtin ? _findClashes(sources, dest) : Future.value(const <NameClash>[]);
// 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만) // 48: 환경 설정 "같은 이름이 있을 때" 가 늘 하는 것이면 묻지 않고 그대로 (확인 창에 알림만)
final always = switch (c.settings.copyConflict) { final always = switch (c.settings.copyConflict) {
'rename' => NameConflict.rename, 'rename' => NameConflict.rename,
@ -1734,7 +1738,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
'\n\n${trf('방법: {0}', [tr(method.label)])}' '\n\n${trf('방법: {0}', [tr(method.label)])}'
'${method == CopyMethod.builtin ? '' : ' · ${task.options}'}' '${method == CopyMethod.builtin ? '' : ' · ${task.options}'}'
'${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'), '${task.bandwidthKBps > 0 ? ' · ${trf('속도 제한 {0} KB/s', [task.bandwidthKBps])}' : ''}'),
FutureBuilder<List<String>>( FutureBuilder<List<NameClash>>(
future: clashesFuture, future: clashesFuture,
builder: (ctx, snap) { builder: (ctx, snap) {
if (snap.connectionState != ConnectionState.done) { if (snap.connectionState != ConnectionState.done) {
@ -1743,12 +1747,18 @@ class _ExplorerPageState extends State<ExplorerPage> {
child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), child: Text(tr('같은 이름이 있는지 확인하는 중 …'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
); );
} }
final clashes = snap.data ?? const <String>[]; final clashes = snap.data ?? const <NameClash>[];
if (clashes.isEmpty) return const SizedBox.shrink(); if (clashes.isEmpty) return const SizedBox.shrink();
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
const SizedBox(height: 12), const SizedBox(height: 12),
Text(trf('같은 이름이 이미 있습니다: {0}', [_namesOf(clashes)]), Text(trf('같은 이름이 이미 있습니다: {0}', [_namesOf([for (final x in clashes) x.src])]),
style: const TextStyle(color: Colors.orangeAccent)), style: const TextStyle(color: Colors.orangeAccent)),
// 172: 두 파일의 크기 · 바뀐 날 (3개까지)
for (final x in clashes.take(3))
Padding(
padding: const EdgeInsets.only(top: 4, left: 8),
child: Text(clashCompareText(x), style: const TextStyle(fontSize: 12)),
),
if (always != null) if (always != null)
Text( Text(
trf('환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [ trf('환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)', [
@ -3036,6 +3046,24 @@ class _ExplorerPageState extends State<ExplorerPage> {
} }
} }
/// 172: 같은 이름 - 보낼 것 (복사 · 이동, [here]) 과 대상 폴더에 이미 있는 것 ([there])
typedef NameClash = ({String src, ({bool isDir, int size, DateTime modified})? here, ({bool isDir, int size, DateTime modified}) there});
/// 172: "이름: 보낼 것 1.2 MB · 2026-10-09 14:00 / 있는 것 900 KB · 2026-10-01 09:00 (보낼 것이 더 새것)"
String clashCompareText(NameClash x) {
String one(({bool isDir, int size, DateTime modified}) s) =>
[if (!s.isDir) formatSize(s.size) else tr('폴더'), if (s.modified.year > 1) _date(s.modified)].join(' · ');
final h = x.here, t = x.there;
final newer = h == null || h.modified.year <= 1 || t.modified.year <= 1
? ''
: h.modified.isAfter(t.modified.add(const Duration(seconds: 2)))
? tr(' (보낼 것이 더 새것)')
: t.modified.isAfter(h.modified.add(const Duration(seconds: 2)))
? tr(' (있는 것이 더 새것)')
: tr(' (바뀐 날 같음)');
return '${vBasename(x.src)}: ${trf('보낼 것 {0} / 있는 것 {1}', [h == null ? '?' : one(h), one(t)])}$newer';
}
String _date(DateTime d) => String _date(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ' '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} '
'${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; '${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';

View File

@ -30,6 +30,9 @@ void main() {
File(p.join(right, 'doc.txt')) File(p.join(right, 'doc.txt'))
..createSync(recursive: true) ..createSync(recursive: true)
..writeAsStringSync('old'); ..writeAsStringSync('old');
// 172: 바뀐 날 (왼쪽이 더 새것)
File(p.join(left, 'doc.txt')).setLastModifiedSync(DateTime(2026, 10, 9, 12));
File(p.join(right, 'doc.txt')).setLastModifiedSync(DateTime(2026, 10, 1, 8));
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.explorerPaths = [left, right]; c.settings.explorerPaths = [left, right];
final root = p.rootPrefix(tmp.path); final root = p.rootPrefix(tmp.path);
@ -67,6 +70,8 @@ void main() {
expect(find.widgetWithText(FilledButton, '복사'), findsOneWidget); expect(find.widgetWithText(FilledButton, '복사'), findsOneWidget);
await settle(tester, () => find.textContaining('같은 이름이 이미 있습니다').evaluate().isNotEmpty); await settle(tester, () => find.textContaining('같은 이름이 이미 있습니다').evaluate().isNotEmpty);
expect(find.textContaining('같은 이름이 이미 있습니다: doc.txt'), findsOneWidget); expect(find.textContaining('같은 이름이 이미 있습니다: doc.txt'), findsOneWidget);
// 172: 두 파일의 크기 · 바뀐 날 (어느 쪽이 새것인지)
expect(find.textContaining('doc.txt: 보낼 것 3B · 2026-10-09 12:00 / 있는 것 3B · 2026-10-01 08:00 (보낼 것이 더 새것)'), findsOneWidget);
// 154 · 155: 확인 창에 항목 이름 // 154 · 155: 확인 창에 항목 이름
expect(find.textContaining('\ndoc.txt'), findsOneWidget); expect(find.textContaining('\ndoc.txt'), findsOneWidget);
} }