라이브 데모
2026. 5. 13.
feat(extension,host): v0.3.2 — 폴더 우선 정렬, 드래그 가능한 splitter, ZIP 압축
Tab Explorer v0.3.2 PR — folder-first sort, resizable splitters, ZIP compression. Claude AI가 작성한 한국어 PR 리뷰·설명 dogfood 결과.
이슈 분포 + 소요 시간
PR 설명 자동 생성
PR 제목
feat(extension,host): v0.3.2 — 폴더 우선 정렬, 드래그 가능한 splitter, ZIP 압축 설명
기존 Tab Explorer는 파일 리스트가 이름 순으로만 정렬되어 폴더와 파일이 알파벳 순으로 뒤섞였고, 좌측 드라이브 패널과 하단 상태바가 고정 폭/높이라 사용자가 화면을 자기 작업에 맞게 조정할 수 없었습니다. 또한 v0.3.1까지는 호스트에 압축 기능이 없어 ZIP 생성을 위해 매번 외부 도구로 빠져나가야 했습니다. v0.3.2는 이 세 가지 UX 격차를 한 릴리스로 묶어 해결하여 Windows 탐색기 수준의 기본 조작감을 맞추는 것을 목표로 합니다. 해결 방식은 영역별로 분리했습니다. 정렬은 호스트 측 `sortEntries`에 디렉터리 우선 1차 키(`dirRank`)를 추가해 사용자가 고른 desc 옵션과 무관하게 폴더가 항상 위에 오도록 했습니다. Splitter는 controlled 컴포넌트(`Splitter.tsx`) + Pointer Capture로 구현하고, 너비/높이는 별도 `layout` zustand store에 두어 300ms 디바운스 + `beforeunload` flush로 localStorage 영속화했습니다. ZIP 압축은 호스트의 streaming op로 신규 추가(`compress.go`)하여 copy/move와 동일한 progress/done 이벤트 envelope을 재사용하고, UTF-8 헤더 플래그(0x800)로 한글 파일명을 보존합니다.
체인지로그
## [0.3.2] - 2026-05-13 ### Added - 파일 리스트에 디렉터리 우선 2단계 정렬을 도입해 Windows 탐색기와 동일하게 폴더가 항상 파일보다 위에 오도록 했습니다. - 좌측 드라이브 패널과 하단 상태바 사이에 드래그·키보드·더블클릭 리셋이 가능한 splitter를 추가했습니다. 조절값은 localStorage에 영속화됩니다. - 호스트에 streaming `compress` op를 추가하고, 행 컨텍스트 메뉴에 "ZIP으로 압축" 항목을 노출했습니다. 단일 선택은 `<이름>.zip`, 복수 선택은 `archive_YYYYMMDD_HHMMSS.zip`으로 자동 명명합니다. - 진행률 토스트와 결과 모달에 압축(compress) 종류를 통합했습니다. - 신규 에러 코드 `E_ARCHIVE_FAILED`를 추가했습니다. ### Changed - `App.css`의 메인 레이아웃 grid를 CSS 변수 기반으로 전환해 splitter 드래그가 즉시 반영되도록 했습니다. - ProgressToasts의 종류 라벨을 `kindLabel` switch로 리팩토링했습니다(향후 종류 추가 시 누락 방지). - 확장 `manifest.json`/`package.json` 및 네이티브 호스트 버전을 0.3.2로 올렸습니다. ### Fixed - 압축 작업 실패 모달 제목이 "이동 결과"로 잘못 표기되는 경로를 차단했습니다(FailureSummary 큐 분기 누락 수정).
한 줄 요약
폴더 우선 2단 정렬, 드래그 가능한 splitter, streaming 기반 ZIP 압축 op를 한 릴리스에 묶은 견고한 기능 PR — 호스트 단위 테스트는 두텁지만 압축 op의 archiveName 검증·메모리 효율·실패 시나리오 커버에 보강 여지가 있다. **CRITICAL**: 이 변경에서 즉시 데이터 손상·보안 사고로 이어지는 항목은 발견하지 못했다. 아래 WARNING #1은 "현 UI에서는 도달 불가"라 격하했지만 IPC 직접 호출을 허용하는 구조라면 CRITICAL로 승급 검토 권장.
WARNING
5 건 WARNING native-host/internal/ops/compress.go:117-127
archiveName 검증이 \x00/\\와 길이만 본다. archiveName = ".." / "." / 끝이 점·공백인 Windows 예약 패턴이 통과된다.
- 근거: filepath.Join(destClean, "..")는 destClean의 부모로 정규화되므로, CheckMutatingOp(destDir)가 통과한 권한 경계 밖의 경로가 archivePath로 산출될 수 있다. 실제로는 결과가 디렉터리라 os.Create가 EISDIR로 실패해 파일이 쓰이진 않지만, E_BAD_REQUEST 대신 일반 IO 에러로 노출돼 진단이 흐려진다. UI는 현재 archiveName을 노출하지 않아 도달 불가이지만 IPC를 직접 호출하면 트리거 가능.
- 수정 예시:
```go
if archiveName == "." || archiveName == ".." {
return protocol.ErrorResponse(req.ID, protocol.ErrCodeBadRequest,
"archiveName must not be '.' or '..'", false)
}
// 추가: Windows 예약 이름 (CON, PRN, NUL, COM1..LPT9), 끝 공백/점도 거절
``` WARNING native-host/internal/ops/compress.go (내부 walk 콜백)
buf := make([]byte, copyBufSize)로 64KB를 새로 할당한다.
- 근거: 폴더가 수천 개 파일을 포함하면 N × 64KB의 단명 객체가 생겨 GC 압력이 커진다. copy.go의 copyBufSize를 재사용하는 패턴과도 일관성이 떨어진다.
- 수정 예시: Compress 함수 진입부에서 buf := make([]byte, copyBufSize)를 한 번 할당하고 walk 콜백 클로저에서 캡처해 재사용. 동시성 없음(같은 op 안에서 직렬 진행). WARNING native-host/internal/ops/compress.go (pre-walk 블록)
bytesTotal/fileTotal 산출을 위해 전체 트리를 두 번 traverse한다.
- 근거: 대형 폴더(예: node_modules 수십만 파일)에서 메타데이터 I/O가 두 배가 되고, pre-walk 중 사라진 파일을 본 walk가 다시 보지 못해 fileTotal 불일치(progress 100% 미달)가 생긴다. critic의 W2와 같은 사안.
- 완화안: pre-walk를 옵션으로 두거나, 첫 progress emit까지의 latency가 길어도 본 walk와 합치는 단일-패스 구조로 변경. 또는 pre-walk는 빠른 추정(stat sample)으로 줄이고 progress UI는 "indeterminate" 상태 허용. WARNING native-host/internal/ops/compress.go (모든 `_ = emit(...)` 호출부)
canceled = true; return filepath.SkipAll로 walk 조기 종료, cleanup 호출 후 protocol.ErrorResponse(...) 반환. WARNING native-host/internal/ops/compress.go의 `Compress` 함수 전체
validateAndPlan(args) → plan, prewalkTotals(plan), streamArchive(ctx, plan, prog, emit) → failures, canceled, finalize(...) 4 함수로 분리. 테스트는 streamArchive에 집중.SUGGESTION
8 건 SUGGESTION extension/src/ui/components/Splitter.tsx
::before pseudo-element로 ±4px 양쪽 padding을 줘 시각 폭은 4px, hit zone은 12px로 분리 권장. SUGGESTION native-host/internal/ops/compress_test.go
ctx cancel → done.canceled=true + 부분 zip 삭제 확인
- 트리 내 EACCES 파일이 FailureInfo로 누적되고 walk가 계속됨을 검증
- 다중 파일 압축 시 progress 이벤트가 최소 1회 emit됨을 검증
- 빈 디렉터리(파일 0개)만 압축 → zip에 디렉터리 엔트리만 존재
- archiveName=".." / "." (WARNING #1 검증용)
- 시스템 경로 destDir + explicitConfirm=false → E_SYSTEM_PATH_CONFIRM_REQUIRED SUGGESTION extension/src/ui/store/jobs.ts:332-396
startCompressJob의 handle.promise.then 블록과 evt.event === "done" 분기가 모두 completeJob을 호출할 수 있다. 현재는 state 가드(done|failed|canceled)로 중복 호출을 막지만, 호출 순서(done 이벤트가 먼저 vs 응답이 먼저)에 따른 race를 명시 주석으로 박아두면 다음 사람이 안전. SUGGESTION extension/src/ui/App.tsx:399-419 `handleCompressZip`
SUGGESTION extension/src/ui/components/ProgressToasts.tsx:15-24 `kindLabel`
default: assertNever(kind) 추가 시 향후 JobKind 확장 시 누락이 컴파일 에러로 잡힌다. 현재도 TS exhaustiveness가 잡지만 런타임 방어로 한 줄 더 둘 가치 있음. SUGGESTION extension/src/ui/store/layout.ts:177-198
beforeunload 리스너가 모듈 로드 시점에 등록되고 제거 경로가 없다. 확장 컨텍스트에서는 페이지 단위 lifecycle이라 문제 없지만, 단위 테스트에서 store를 import하면 listener가 쌓일 수 있다. 테스트 환경 가드(if (import.meta.env?.MODE !== "test")) 또는 cleanup export 검토. SUGGESTION native-host/internal/ops/compress.go의 `archivePathEqual`
strings.EqualFold를 쓰는데, walk path와 archivePath 둘 다 같은 filepath.Join/filepath.Clean을 거치므로 바이트 동등성으로도 충분하다. EqualFold는 Unicode case folding 비용이 있어 큰 폴더에서 매 항목 호출되면 미세하게 비용 누적. 그래도 defense-in-depth 가치는 인정. SUGGESTION native-host/internal/ops/compress.go의 `dedupeSubPaths`
Compress)에서도 "deduped[0]은 caller가 준 paths[0]이 살아 있다면 그대로"라는 보장이 docstring으로 남으면 좋다(현재는 sort.SliceStable + 원본 순서 복원으로 그렇게 동작).