From 287b26279c57cb872250f96ab14860582ceed275 Mon Sep 17 00:00:00 2001 From: Bori-github Date: Tue, 4 Aug 2026 01:52:11 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat(rust):=20=EC=84=B8=EC=85=98=EC=97=90?= =?UTF-8?q?=20=EC=B5=9C=EA=B7=BC=20=ED=8C=8C=EC=9D=BC=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=84=20=EC=A0=80=EC=9E=A5=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recent_files는 tabs[].path와 같은 규칙으로 걸러진다 — 파일만, 존재하는 경로만, 읽을 때 파일 단위 허용 루트로 등록. 필드가 없는 옛 session.json은 빈 목록으로 읽어 기존 세션을 잃지 않는다. 갱신 시점·상한 정책은 document-model.md#최근-파일이 소유한다. Co-Authored-By: Claude Fable 5 --- .claude/docs/document-model.md | 13 ++++- .claude/docs/rust-commands.md | 4 ++ apps/desktop/src-tauri/src/session.rs | 75 +++++++++++++++++++++++++ apps/desktop/src/shared/ipc/bindings.ts | 4 ++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/.claude/docs/document-model.md b/.claude/docs/document-model.md index 5aa4e27..d64cd6b 100644 --- a/.claude/docs/document-model.md +++ b/.claude/docs/document-model.md @@ -41,7 +41,7 @@ interface WorkspaceState { // 스토어가 별도 보유한다 — 영속화하지 않는다(접힘 영속화와 동일 원칙) tabs: Tab[]; activeTabId: string | null; - recentFiles: string[]; + recentFiles: string[]; // 최근에 연 파일 (→ #최근-파일) } ``` @@ -102,12 +102,21 @@ interface WorkspaceState { 이 규칙은 표면 규칙과도 맞물린다. 창 최상단 띠는 유리(크롬)여야 하는데(→ [창 표면 계약](design/window-chrome.md)), 탭바가 사라지면 그 자리를 불투명한 종이가 차지해 유리가 끊긴다. +## 최근 파일 + +`recentFiles`는 최근에 연 파일의 canonical 경로 목록이다 — 최신이 앞이다. + +- **올리는 시점** — 사용자가 파일을 열 때(다이얼로그·트리·최근 파일 목록·프리뷰 링크) · Untitled가 첫 저장으로 경로를 얻을 때. 경로는 `open_file`/`save_file`이 반환한 canonical 값이다(탭 신원 규칙과 같다). **세션 복원은 목록을 바꾸지 않는다** — 재시작은 사용자가 파일을 연 것이 아니다. +- 이미 목록에 있는 경로는 맨 앞으로 옮긴다. **10개**를 넘으면 뒤에서 버린다. +- 영속화는 `session.json`이다(→ [Rust 커맨드 계약](rust-commands.md#세션)). `load_session`이 지금 없는 경로를 걸러 돌려주고, 남은 경로를 파일 단위 허용 루트로 등록한다 — 재시작 뒤 다이얼로그 없이 다시 여는 근거다. +- 표시 자리는 빈 상태 안내다(→ [빈 탭](#빈-탭--탭바는-비지-않는다)). + ## 세션 복원 마지막 세션을 재시작 때 되살린다. 저장 자리는 셋이다. ```text -탭 목록·활성 탭·루트 폴더·탭별 자리 session.json — Rust가 소유(→ rust-commands.md#세션) +탭 목록·활성 탭·루트 폴더·탭별 자리·최근 파일 session.json — Rust가 소유(→ rust-commands.md#세션) 창 크기·위치 plugin-window-state 뷰 모드·사이드바 접힘 설정 파일(→ file-lifecycle.md#설정-저장) ``` diff --git a/.claude/docs/rust-commands.md b/.claude/docs/rust-commands.md index 5c92b4a..03738d3 100644 --- a/.claude/docs/rust-commands.md +++ b/.claude/docs/rust-commands.md @@ -220,6 +220,9 @@ struct Session { root_dir: Option, // 사이드바 루트. 없으면 폴더를 열지 않은 세션 tabs: Vec, // 경로 있는 탭만 — Untitled는 복원 대상이 아니다 active: Option, // 활성 탭의 tabs 인덱스 + recent_files: Vec, // 최근 파일, 최신이 앞 — 갱신 시점·상한은 + // document-model.md#최근-파일이 소유한다. + // 필드가 없는 옛 세션 파일은 빈 목록으로 읽는다 } struct SessionTab { @@ -236,6 +239,7 @@ struct SessionTab { 자리 종류 허용하면 열리는 범위 root_dir 디렉터리 그 폴더 아래 전부 tabs[].path 파일 그 파일 하나 +recent_files[] 파일 그 파일 하나 ``` `canonicalize` 후의 실제 종류로 판정한다 — 문서처럼 보이지만 디렉터리를 가리키는 심볼릭 링크도 탭 자리에서 거부된다. diff --git a/apps/desktop/src-tauri/src/session.rs b/apps/desktop/src-tauri/src/session.rs index 6745695..0410bb4 100644 --- a/apps/desktop/src-tauri/src/session.rs +++ b/apps/desktop/src-tauri/src/session.rs @@ -27,6 +27,9 @@ pub struct Session { pub root_dir: Option, pub tabs: Vec, pub active: Option, + /// 옛 세션 파일에는 이 필드가 없다(→ rust-commands.md#세션). + #[serde(default)] + pub recent_files: Vec, } #[tauri::command] @@ -99,10 +102,16 @@ fn remap(session: &Session, map: impl Fn(&str, PathKind) -> Option) -> S ..tab.clone() }); } + let recent_files = session + .recent_files + .iter() + .filter_map(|path| map(path, PathKind::File)) + .collect(); Session { root_dir, tabs, active, + recent_files, } } @@ -166,6 +175,7 @@ mod tests { root_dir: Some(dir.path().to_string_lossy().into_owned()), tabs: vec![tab(doc.to_str().unwrap())], active: Some(0), + recent_files: vec![], }; write_session(&scope, &file, &session).unwrap(); @@ -215,6 +225,7 @@ mod tests { tab(kept.to_str().unwrap()), ], active: Some(1), + recent_files: vec![], }; fs::write(&file, serde_json::to_vec(&stored).unwrap()).unwrap(); @@ -244,6 +255,7 @@ mod tests { root_dir: Some(doc.to_string_lossy().into_owned()), tabs: vec![tab(dir.path().to_str().unwrap())], active: Some(0), + recent_files: vec![], }; fs::write(&file, serde_json::to_vec(&stored).unwrap()).unwrap(); @@ -274,6 +286,7 @@ mod tests { tab(kept.to_str().unwrap()), ], active: Some(0), + recent_files: vec![], }; fs::write(&file, serde_json::to_vec(&stored).unwrap()).unwrap(); @@ -285,11 +298,71 @@ mod tests { root_dir: None, tabs: vec![tab(kept.to_str().unwrap())], active: Some(9), + recent_files: vec![], }; fs::write(&file, serde_json::to_vec(&out_of_range).unwrap()).unwrap(); assert_eq!(read_session(&scope, &file).unwrap().active, None); } + // 집행: rust-commands.md#세션 — recent_files는 파일 경로만, 순서 보존, 허용 등록. + // 왜: 최근 파일은 재시작 뒤 다이얼로그 없이 다시 여는 통로다(→ document-model.md#최근-파일) — + // 허용 등록이 없으면 목록을 눌러도 Permission으로 거부된다. + // 보장: 왕복이 순서를 보존하고 각 경로가 허용된다. 없는 경로·디렉터리는 빠지고 허용되지 않는다. + // 경계: 상한·갱신 시점은 프론트가 강제한다(→ document-model.md#최근-파일). + #[test] + fn 최근_파일이_왕복하고_없는_경로와_디렉터리는_빠진다() { + let (dir, scope) = scoped_tempdir(); + let first = dir.path().join("a.md"); + let second = dir.path().join("b.md"); + fs::write(&first, "본문").unwrap(); + fs::write(&second, "본문").unwrap(); + let file = dir.path().join(SESSION_FILE); + write_session( + &scope, + &file, + &Session { + root_dir: None, + tabs: vec![], + active: None, + recent_files: vec![ + first.to_string_lossy().into_owned(), + second.to_string_lossy().into_owned(), + dir.path().join("사라진.md").to_string_lossy().into_owned(), + dir.path().to_string_lossy().into_owned(), + ], + }, + ) + .unwrap(); + + let fresh = FileScope::default(); + let restored = read_session(&fresh, &file).expect("세션을 읽는다"); + + let canonical_first = fs::canonicalize(&first).unwrap(); + let canonical_second = fs::canonicalize(&second).unwrap(); + assert_eq!( + restored.recent_files, + vec![ + canonical_first.to_string_lossy().into_owned(), + canonical_second.to_string_lossy().into_owned(), + ] + ); + assert!(fresh.ensure_allowed(&canonical_first).is_ok()); + assert!(fresh.ensure_allowed(&canonical_second).is_ok()); + } + + // 왜: recent_files 필드는 기존 사용자의 session.json에 없다 — 없다고 세션 전체를 + // 버리면(read_session의 None) 재시작 때 열려 있던 탭을 잃는다. + // 보장: 필드가 없는 세션 파일도 읽히고 최근 파일은 빈 목록이다. + #[test] + fn 필드가_없는_옛_세션은_빈_목록으로_읽힌다() { + let (dir, scope) = scoped_tempdir(); + let file = dir.path().join(SESSION_FILE); + fs::write(&file, r#"{"rootDir":null,"tabs":[],"active":null}"#).unwrap(); + + let restored = read_session(&scope, &file).expect("세션을 읽는다"); + assert!(restored.recent_files.is_empty()); + } + // 집행: rust-commands.md#권한-capabilities — 허용 루트는 웹뷰가 선언하지 못한다. // 왜: 저장이 아무 경로나 받아 쓰면, 다음 부팅의 load_session이 그것을 허용해 // 웹뷰가 전역 경로 접근을 얻는다(스코프 강제가 무너진다). @@ -314,6 +387,7 @@ mod tests { tab(inside.to_str().unwrap()), ], active: Some(1), + recent_files: vec![outside.to_string_lossy().into_owned()], }, ) .unwrap(); @@ -323,6 +397,7 @@ mod tests { assert_eq!(restored.tabs.len(), 1); assert_eq!(restored.active, Some(0)); assert!(restored.root_dir.is_none()); + assert!(restored.recent_files.is_empty()); assert!(fresh .ensure_allowed(&fs::canonicalize(&outside).unwrap()) .is_err()); diff --git a/apps/desktop/src/shared/ipc/bindings.ts b/apps/desktop/src/shared/ipc/bindings.ts index b0a5fbb..a200f7f 100644 --- a/apps/desktop/src/shared/ipc/bindings.ts +++ b/apps/desktop/src/shared/ipc/bindings.ts @@ -21,6 +21,8 @@ export const commands = { rootDir: string | null, tabs: SessionTab[], active: number | null, + /** 옛 세션 파일에는 이 필드가 없다(→ rust-commands.md#세션). */ + recentFiles?: string[], } | null, AppError>(__TAURI_INVOKE("load_session")), saveSession: (session: Session) => typedError(__TAURI_INVOKE("save_session", { session })), }; @@ -67,6 +69,8 @@ export type Session = { rootDir: string | null, tabs: SessionTab[], active: number | null, + /** 옛 세션 파일에는 이 필드가 없다(→ rust-commands.md#세션). */ + recentFiles?: string[], }; export type SessionTab = { From 2f76f03132c057ac163a84c73a42634422428d63 Mon Sep 17 00:00:00 2001 From: Bori-github Date: Tue, 4 Aug 2026 01:54:30 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat(ipc):=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=EA=B0=80=20?= =?UTF-8?q?=EC=97=B4=EB=A6=B0=20=ED=8F=B4=EB=8D=94=20=EB=A3=A8=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=8B=9C=EC=9E=91=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit show_save_dialog에 start_dir를 추가한다. 존재하는 디렉터리면 다이얼로그 시작 위치로 쓰고, 아니면 OS 기본(마지막 사용 위치)이다. 웹뷰는 사이드바 rootDir를 넘기고, 폴더를 열지 않았으면 null이다. 시작 위치는 허용 루트 등록과 무관하다 — 등록은 사용자가 선택한 경로로만 한다. Co-Authored-By: Claude Fable 5 --- .claude/docs/rust-commands.md | 5 ++++- apps/desktop/src-tauri/src/dialog_commands.rs | 11 +++++++--- .../features/save-file/model/save-tab.test.ts | 21 +++++++++++++++++++ .../src/features/save-file/model/save-tab.ts | 3 +++ apps/desktop/src/shared/ipc/bindings.ts | 2 +- apps/desktop/src/shared/ipc/index.ts | 3 ++- 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/.claude/docs/rust-commands.md b/.claude/docs/rust-commands.md index 03738d3..64eccee 100644 --- a/.claude/docs/rust-commands.md +++ b/.claude/docs/rust-commands.md @@ -174,9 +174,12 @@ async fn watch_tree(root: Option) -> Result<(), AppError>; async fn show_open_dialog() -> Result, AppError>; #[tauri::command] -async fn show_save_dialog(default_name: String) -> Result, AppError>; +async fn show_save_dialog(default_name: String, start_dir: Option) -> Result, AppError>; // 두 다이얼로그 모두 Markdown 필터(.md·.markdown — read_dir 필터와 동일 집합)를 걸고, // 취소하면 None을 반환한다. 선택된 경로는 허용 루트로 등록된다(→ 권한) +// start_dir가 존재하는 디렉터리면 다이얼로그가 거기서 시작한다 — 아니면 OS가 정한 위치에서 +// 연다. 웹뷰는 사이드바 루트를 넘긴다(폴더를 열지 않았으면 None). 시작 위치일 뿐 허용과 +// 무관하다 — 허용 루트 등록은 사용자가 선택한 경로로만 한다 #[tauri::command] async fn show_open_folder_dialog() -> Result, AppError>; diff --git a/apps/desktop/src-tauri/src/dialog_commands.rs b/apps/desktop/src-tauri/src/dialog_commands.rs index 599768d..9c7bc08 100644 --- a/apps/desktop/src-tauri/src/dialog_commands.rs +++ b/apps/desktop/src-tauri/src/dialog_commands.rs @@ -43,13 +43,18 @@ pub async fn show_save_dialog( app: AppHandle, scope: State<'_, FileScope>, default_name: String, + start_dir: Option, ) -> Result, AppError> { - let picked = app + let mut dialog = app .dialog() .file() .add_filter("Markdown", MARKDOWN_EXTENSIONS) - .set_file_name(&default_name) - .blocking_save_file(); + .set_file_name(&default_name); + // 존재하는 디렉터리일 때만 시작 위치로 쓴다(→ rust-commands.md#다이얼로그). + if let Some(dir) = start_dir.map(PathBuf::from).filter(|dir| dir.is_dir()) { + dialog = dialog.set_directory(dir); + } + let picked = dialog.blocking_save_file(); let Some(file_path) = picked else { return Ok(None); }; diff --git a/apps/desktop/src/features/save-file/model/save-tab.test.ts b/apps/desktop/src/features/save-file/model/save-tab.test.ts index f7c4b2f..b41262e 100644 --- a/apps/desktop/src/features/save-file/model/save-tab.test.ts +++ b/apps/desktop/src/features/save-file/model/save-tab.test.ts @@ -30,6 +30,7 @@ vi.mock("@tauri-apps/plugin-log", () => ({ })); import { resetTabTextRegistry, setTabText, useDocumentStore } from "@entities/document"; +import { useWorkspaceStore } from "@entities/workspace"; import type { FileContent } from "@shared/ipc"; import { IpcError } from "@shared/ipc"; import { useConfirmStore, useNoticeStore } from "@shared/ui"; @@ -67,6 +68,7 @@ function openTab(path = "/vault/doc.md"): string { beforeEach(() => { useDocumentStore.setState({ tabs: [], activeTabId: null }); + useWorkspaceStore.setState({ rootDir: null }); useConflictStore.setState({ conflictTabIds: [] }); useConfirmStore.setState({ pending: null }); useNoticeStore.setState({ notices: [] }); @@ -418,4 +420,23 @@ describe("저장 경로 확정과 중복 탭 금지", () => { title: "새 문서.md", }); }); + + // 집행: rust-commands.md#다이얼로그 — 웹뷰는 사이드바 루트를 시작 위치로 넘긴다. + // 왜: 사이드바 루트는 웹뷰만 안다 — 여기서 넘기지 않으면 Rust가 시작 위치로 쓸 수 없다. + // 보장: 폴더가 열려 있으면 rootDir가, 아니면 null이 start_dir로 전달된다. + // 경계: 존재하지 않는 디렉터리의 무시는 Rust가 맡는다(→ dialog_commands.rs). + it("저장 다이얼로그는 열린 폴더 루트를 시작 위치로 넘긴다", async () => { + useWorkspaceStore.setState({ rootDir: "/vault" }); + const untitled = useDocumentStore.getState().addUntitledTab(); + useDocumentStore.getState().setDirty(untitled, true); + showSaveDialog.mockResolvedValueOnce(null); + + await saveTabNow(untitled); + expect(showSaveDialog).toHaveBeenCalledWith(expect.any(String), "/vault"); + + useWorkspaceStore.setState({ rootDir: null }); + showSaveDialog.mockResolvedValueOnce(null); + await saveTabNow(untitled); + expect(showSaveDialog).toHaveBeenLastCalledWith(expect.any(String), null); + }); }); diff --git a/apps/desktop/src/features/save-file/model/save-tab.ts b/apps/desktop/src/features/save-file/model/save-tab.ts index c989613..3996639 100644 --- a/apps/desktop/src/features/save-file/model/save-tab.ts +++ b/apps/desktop/src/features/save-file/model/save-tab.ts @@ -6,6 +6,7 @@ import { useDocumentStore, } from "@entities/document"; import type { Tab } from "@entities/document"; +import { useWorkspaceStore } from "@entities/workspace"; import { STRINGS } from "@shared/config"; import { ipc, isIpcError } from "@shared/ipc"; import { notifyIpcError, useConfirmStore, useNoticeStore } from "@shared/ui"; @@ -119,6 +120,8 @@ async function performSave( try { picked = await ipc.showSaveDialog( path === null ? STRINGS.untitledDefaultFileName : tab.title, + // 폴더가 열려 있으면 다이얼로그가 그 루트에서 시작한다(→ rust-commands.md#다이얼로그). + useWorkspaceStore.getState().rootDir, ); } catch (error) { notifyIpcError(STRINGS.saveFailedTitle, error); diff --git a/apps/desktop/src/shared/ipc/bindings.ts b/apps/desktop/src/shared/ipc/bindings.ts index a200f7f..1fe0512 100644 --- a/apps/desktop/src/shared/ipc/bindings.ts +++ b/apps/desktop/src/shared/ipc/bindings.ts @@ -14,7 +14,7 @@ export const commands = { watchPaths: (paths: string[]) => typedError(__TAURI_INVOKE("watch_paths", { paths })), watchTree: (root: string | null) => typedError(__TAURI_INVOKE("watch_tree", { root })), showOpenDialog: () => typedError(__TAURI_INVOKE("show_open_dialog")), - showSaveDialog: (defaultName: string) => typedError(__TAURI_INVOKE("show_save_dialog", { defaultName })), + showSaveDialog: (defaultName: string, startDir: string | null) => typedError(__TAURI_INVOKE("show_save_dialog", { defaultName, startDir })), showOpenFolderDialog: () => typedError(__TAURI_INVOKE("show_open_folder_dialog")), setWindowBlurRadius: (radius: number) => __TAURI_INVOKE("set_window_blur_radius", { radius }), loadSession: () => typedError<{ diff --git a/apps/desktop/src/shared/ipc/index.ts b/apps/desktop/src/shared/ipc/index.ts index d8931c1..5df92c1 100644 --- a/apps/desktop/src/shared/ipc/index.ts +++ b/apps/desktop/src/shared/ipc/index.ts @@ -29,7 +29,8 @@ export const ipc = { watchPaths: (paths: string[]) => unwrapIpcResult(commands.watchPaths(paths)), watchTree: (root: string | null) => unwrapIpcResult(commands.watchTree(root)), showOpenDialog: () => unwrapIpcResult(commands.showOpenDialog()), - showSaveDialog: (defaultName: string) => unwrapIpcResult(commands.showSaveDialog(defaultName)), + showSaveDialog: (defaultName: string, startDir: string | null) => + unwrapIpcResult(commands.showSaveDialog(defaultName, startDir)), showOpenFolderDialog: () => unwrapIpcResult(commands.showOpenFolderDialog()), // 결과를 돌려주지 않는다 — 흐림이 걸렸는지는 화면으로만 확인된다(→ design/window-chrome.md#검증). setWindowBlurRadius: (radius: number) => commands.setWindowBlurRadius(radius), From 8445fa7a70cd91322b2fad1033ad224d966c62cd Mon Sep 17 00:00:00 2001 From: Bori-github Date: Tue, 4 Aug 2026 01:59:38 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat(shared):=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=EA=B3=BC=20=ED=95=A8=EA=BB=98=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=C2=B7=EB=B3=B5=EC=9B=90=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workspace 스토어에 recentFiles를 둔다 — 맨 앞 삽입, 중복은 앞으로 이동, 상한 10. 파일 열기(새 탭·기존 탭 합류)와 저장 다이얼로그의 새 경로 확정이 canonical 경로를 올린다. 세션 복원은 저장된 목록을 그대로 세우고 순서를 바꾸지 않는다. 표시(빈 상태 안내)는 다음 커밋이 맡는다. Co-Authored-By: Claude Fable 5 --- .claude/docs/document-model.md | 2 +- .../src/app/lib/session-storage.test.ts | 38 ++++++++++++++++++- apps/desktop/src/app/lib/session-storage.ts | 13 ++++++- .../workspace/model/workspace-store.test.ts | 34 ++++++++++++++++- .../workspace/model/workspace-store.ts | 22 +++++++++++ .../open-file/model/open-file.test.ts | 21 ++++++++++ .../src/features/open-file/model/open-file.ts | 5 +++ .../features/save-file/model/save-tab.test.ts | 4 +- .../src/features/save-file/model/save-tab.ts | 2 + apps/desktop/src/shared/config/index.ts | 1 + apps/desktop/src/shared/config/session.ts | 3 ++ 11 files changed, 139 insertions(+), 6 deletions(-) diff --git a/.claude/docs/document-model.md b/.claude/docs/document-model.md index d64cd6b..1fcd5f6 100644 --- a/.claude/docs/document-model.md +++ b/.claude/docs/document-model.md @@ -106,7 +106,7 @@ interface WorkspaceState { `recentFiles`는 최근에 연 파일의 canonical 경로 목록이다 — 최신이 앞이다. -- **올리는 시점** — 사용자가 파일을 열 때(다이얼로그·트리·최근 파일 목록·프리뷰 링크) · Untitled가 첫 저장으로 경로를 얻을 때. 경로는 `open_file`/`save_file`이 반환한 canonical 값이다(탭 신원 규칙과 같다). **세션 복원은 목록을 바꾸지 않는다** — 재시작은 사용자가 파일을 연 것이 아니다. +- **올리는 시점** — 사용자가 파일을 열 때(열기 다이얼로그·파일 트리·최근 파일 목록) · 저장 다이얼로그가 새 경로를 확정할 때(Untitled 첫 저장·다른 이름으로 저장). 경로는 `open_file`/`save_file`이 반환한 canonical 값이다(탭 신원 규칙과 같다). **세션 복원은 목록을 바꾸지 않는다** — 재시작은 사용자가 파일을 연 것이 아니다. - 이미 목록에 있는 경로는 맨 앞으로 옮긴다. **10개**를 넘으면 뒤에서 버린다. - 영속화는 `session.json`이다(→ [Rust 커맨드 계약](rust-commands.md#세션)). `load_session`이 지금 없는 경로를 걸러 돌려주고, 남은 경로를 파일 단위 허용 루트로 등록한다 — 재시작 뒤 다이얼로그 없이 다시 여는 근거다. - 표시 자리는 빈 상태 안내다(→ [빈 탭](#빈-탭--탭바는-비지-않는다)). diff --git a/apps/desktop/src/app/lib/session-storage.test.ts b/apps/desktop/src/app/lib/session-storage.test.ts index ee8da9a..799bfd4 100644 --- a/apps/desktop/src/app/lib/session-storage.test.ts +++ b/apps/desktop/src/app/lib/session-storage.test.ts @@ -73,7 +73,7 @@ beforeEach(() => { loadSession.mockResolvedValue(null); openFile.mockImplementation(async (path: string) => fileContent(path)); useDocumentStore.setState({ tabs: [], activeTabId: null }); - useWorkspaceStore.setState({ rootDir: null, fileTree: [], expandedDirs: [] }); + useWorkspaceStore.setState({ rootDir: null, fileTree: [], expandedDirs: [], recentFiles: [] }); resetTabViewStates(); }); @@ -98,10 +98,28 @@ describe("persistSessionOnChange", () => { rootDir: "/vault", tabs: [{ path: "/vault/a.md", cursorLine: 1, cursorColumn: 1, scrollLine: 1 }], active: 0, + recentFiles: [], }); stop(); }); + // 집행: document-model.md#최근-파일 — 영속화는 session.json이다. + // 왜: 목록이 세션에 실리지 않으면 재시작 때 비고, 그 경로의 허용 등록 통로도 없다. + // 보장: 최근 파일만 바뀌어도 저장되고, 스냅숏에 목록이 담긴다. + // 경계: 목록의 삽입·상한 규칙은 workspace-store 테스트 소관. + it("최근 파일이 바뀌면 저장한다", async () => { + vi.useFakeTimers(); + const stop = persistSessionOnChange(); + + useWorkspaceStore.getState().noteRecentFile("/vault/a.md"); + await vi.advanceTimersByTimeAsync(SESSION_SAVE_DEBOUNCE_MS); + + expect(saveSession).toHaveBeenCalledWith( + expect.objectContaining({ recentFiles: ["/vault/a.md"] }), + ); + stop(); + }); + it("탭별 자리를 함께 담는다", async () => { vi.useFakeTimers(); const stop = persistSessionOnChange(); @@ -185,6 +203,24 @@ describe("restoreSessionWithin", () => { expect(useWorkspaceStore.getState().rootDir).toBe("/vault"); }); + // 집행: document-model.md#최근-파일 — "세션 복원은 목록을 바꾸지 않는다". + // 왜: 복원이 탭 열기를 최근으로 치면 재시작마다 목록이 열린 탭 순서로 뒤섞인다. + // 보장: 저장된 목록이 순서 그대로 스토어에 서고, 탭 복원이 끼어들지 않는다. + // recentFiles가 없는 세션(옛 형식)은 빈 목록이다. + // 경계: 없는 경로의 필터링은 Rust가 한다(→ rust-commands.md#세션). + it("최근 파일을 저장된 순서 그대로 세운다 — 탭 복원이 목록을 바꾸지 않는다", async () => { + loadSession.mockResolvedValue({ + rootDir: null, + tabs: [{ path: "/vault/b.md", cursorLine: 1, cursorColumn: 1, scrollLine: 1 }], + active: 0, + recentFiles: ["/vault/a.md", "/vault/b.md"], + }); + + await restoreSessionWithin(); + + expect(useWorkspaceStore.getState().recentFiles).toEqual(["/vault/a.md", "/vault/b.md"]); + }); + it("열지 못한 탭은 건너뛰고 활성 탭은 그대로 그 문서를 가리킨다", async () => { openFile.mockImplementation(async (path: string) => { if (path === "/vault/깨진.md") { diff --git a/apps/desktop/src/app/lib/session-storage.ts b/apps/desktop/src/app/lib/session-storage.ts index 15e4b7a..78dfc79 100644 --- a/apps/desktop/src/app/lib/session-storage.ts +++ b/apps/desktop/src/app/lib/session-storage.ts @@ -32,6 +32,9 @@ async function restoreSession(): Promise { if (!session) { return; } + // 최근 파일은 탭과 독립이라 본문 읽기를 기다리지 않는다. 복원은 목록을 바꾸지 않고 + // 그대로 세운다(→ document-model.md#최근-파일). + useWorkspaceStore.getState().setRecentFiles(session.recentFiles ?? []); // 본문 읽기는 서로 독립이라 함께 나간다 — 창이 보이기 전이므로 직렬로 읽으면 그만큼 늦다. // 탭은 응답 순서가 아니라 저장된 순서로 세운다. @@ -79,7 +82,7 @@ export async function restoreSessionWithin(timeoutMs = SESSION_RESTORE_TIMEOUT_M function snapshot(): Session { const { tabs, activeTabId } = useDocumentStore.getState(); - const { rootDir } = useWorkspaceStore.getState(); + const { rootDir, recentFiles } = useWorkspaceStore.getState(); const saved = tabs.filter( (tab): tab is typeof tab & { filePath: string } => tab.filePath !== null, ); @@ -97,6 +100,7 @@ function snapshot(): Session { }; }), active: active === -1 ? null : active, + recentFiles, }; } @@ -105,7 +109,12 @@ function snapshot(): Session { * (→ session-storage.test.ts "타이핑 중에는 쓰지 않는다"). */ function structureKey(session: Session): string { - return JSON.stringify([session.rootDir, session.active, session.tabs.map((tab) => tab.path)]); + return JSON.stringify([ + session.rootDir, + session.active, + session.tabs.map((tab) => tab.path), + session.recentFiles, + ]); } let saveTimer: ReturnType | null = null; diff --git a/apps/desktop/src/entities/workspace/model/workspace-store.test.ts b/apps/desktop/src/entities/workspace/model/workspace-store.test.ts index 0b69440..5c2ae9c 100644 --- a/apps/desktop/src/entities/workspace/model/workspace-store.test.ts +++ b/apps/desktop/src/entities/workspace/model/workspace-store.test.ts @@ -13,7 +13,7 @@ function file(path: string): TreeNode { } beforeEach(() => { - useWorkspaceStore.setState({ rootDir: null, fileTree: [], expandedDirs: [] }); + useWorkspaceStore.setState({ rootDir: null, fileTree: [], expandedDirs: [], recentFiles: [] }); }); // 집행: document-model.md#파일-트리-사이드바 — "read_dir(한 단계 목록) 결과를 프론트가 @@ -178,3 +178,35 @@ describe("펼침 상태", () => { expect(useWorkspaceStore.getState().expandedDirs).toEqual([]); }); }); + +// 집행: document-model.md#최근-파일 — "맨 앞 삽입, 중복은 앞으로 이동, 10개 초과분은 +// 뒤에서 버린다". +// 왜: 목록 순서가 최근 여부를 표현한다 — 중복이 남으면 같은 파일이 두 번 표시되고, +// 순서가 어긋나면 최근이 아닌 파일이 앞에 온다. +// 보장: noteRecentFile의 삽입·이동·상한, setRecentFiles의 상한. +// 경계: 어떤 동작이 올리는지(열기·경로 확정)는 feature 테스트가, 영속화는 세션 테스트가 다룬다. +describe("최근 파일", () => { + it("맨 앞에 쌓이고 중복은 앞으로 이동한다", () => { + const store = useWorkspaceStore.getState(); + store.noteRecentFile("/vault/a.md"); + store.noteRecentFile("/vault/b.md"); + expect(useWorkspaceStore.getState().recentFiles).toEqual(["/vault/b.md", "/vault/a.md"]); + + store.noteRecentFile("/vault/a.md"); + expect(useWorkspaceStore.getState().recentFiles).toEqual(["/vault/a.md", "/vault/b.md"]); + }); + + it("상한을 넘으면 가장 오래된 것이 밀려난다", () => { + const store = useWorkspaceStore.getState(); + for (let index = 0; index < 11; index += 1) { + store.noteRecentFile(`/vault/${index}.md`); + } + const list = useWorkspaceStore.getState().recentFiles; + expect(list).toHaveLength(10); + expect(list[0]).toBe("/vault/10.md"); + expect(list).not.toContain("/vault/0.md"); + + store.setRecentFiles(Array.from({ length: 12 }, (_, index) => `/x/${index}.md`)); + expect(useWorkspaceStore.getState().recentFiles).toHaveLength(10); + }); +}); diff --git a/apps/desktop/src/entities/workspace/model/workspace-store.ts b/apps/desktop/src/entities/workspace/model/workspace-store.ts index a2b2a8a..56c0c07 100644 --- a/apps/desktop/src/entities/workspace/model/workspace-store.ts +++ b/apps/desktop/src/entities/workspace/model/workspace-store.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; +import { RECENT_FILES_LIMIT } from "@shared/config"; import type { TreeNode } from "@shared/ipc"; // 파일 트리 상태 — 구조: .claude/docs/document-model.md#파일-트리-사이드바, @@ -16,6 +17,8 @@ interface WorkspaceState { fileTree: FileTreeNode[]; /** 펼쳐진 폴더 경로 — 에디터 표현 상태다. 영속화하지 않는다(→ editor-strategy.md 접힘 영속화와 동일 원칙). */ expandedDirs: string[]; + /** 최근에 연 파일 (→ document-model.md#최근-파일). */ + recentFiles: string[]; } interface WorkspaceActions { @@ -29,6 +32,10 @@ interface WorkspaceActions { * (→ document-model.md#파일-트리-사이드바). dirPath가 rootDir이면 루트 레벨을 갱신한다. */ refreshLevel(dirPath: string, entries: TreeNode[]): void; + /** 최근 파일에 올린다 — 규칙은 document-model.md#최근-파일. */ + noteRecentFile(path: string): void; + /** 세션 복원 전용 — 목록을 통째로 세운다(→ document-model.md#최근-파일). */ + setRecentFiles(paths: string[]): void; } export type WorkspaceStore = WorkspaceState & WorkspaceActions; @@ -112,6 +119,7 @@ export const useWorkspaceStore = create()((set) => ({ rootDir: null, fileTree: [], expandedDirs: [], + recentFiles: [], openRoot(rootDir, entries) { set({ @@ -146,6 +154,20 @@ export const useWorkspaceStore = create()((set) => ({ }); }, + noteRecentFile(path) { + set((state) => { + if (state.recentFiles[0] === path) { + return state; + } + const rest = state.recentFiles.filter((entry) => entry !== path); + return { recentFiles: [path, ...rest].slice(0, RECENT_FILES_LIMIT) }; + }); + }, + + setRecentFiles(paths) { + set({ recentFiles: paths.slice(0, RECENT_FILES_LIMIT) }); + }, + setExpanded(dirPath, expanded) { set((state) => { const isExpanded = state.expandedDirs.includes(dirPath); diff --git a/apps/desktop/src/features/open-file/model/open-file.test.ts b/apps/desktop/src/features/open-file/model/open-file.test.ts index 1368eb0..0164de5 100644 --- a/apps/desktop/src/features/open-file/model/open-file.test.ts +++ b/apps/desktop/src/features/open-file/model/open-file.test.ts @@ -26,6 +26,7 @@ vi.mock("@tauri-apps/plugin-log", () => ({ })); import { getTabText, resetTabTextRegistry, setTabText, useDocumentStore } from "@entities/document"; +import { useWorkspaceStore } from "@entities/workspace"; import type { FileContent } from "@shared/ipc"; import { IpcError } from "@shared/ipc"; import { useConfirmStore, useNoticeStore } from "@shared/ui"; @@ -48,6 +49,7 @@ function fileContent(overrides: Partial = {}): FileContent { beforeEach(() => { useDocumentStore.setState({ tabs: [], activeTabId: null }); + useWorkspaceStore.setState({ recentFiles: [] }); useConfirmStore.setState({ pending: null }); useNoticeStore.setState({ notices: [] }); resetTabTextRegistry(); @@ -74,6 +76,25 @@ describe("openPathInTab", () => { expect(tabs.filter((tab) => tab.filePath !== null)).toHaveLength(1); expect(activeTabId).toBe(existing); }); + + // 집행: document-model.md#최근-파일 — 열기가 목록을 올리고, 경로는 canonical 값이다. + // 왜: 요청 문자열(별칭 표기)을 올리면 같은 파일이 목록에 두 항목으로 남는다. + // 보장: 새로 열기·기존 탭 합류 모두 최근 파일 맨 앞에 올라가고, 값은 열기 결과의 경로다. + // 경계: 삽입·이동·상한 규칙은 workspace-store 테스트가, 열기 실패는 목록을 바꾸지 않는 + // 것을 아래에서 함께 본다. + it("열기는 canonical 경로를 최근 파일에 올리고, 실패는 올리지 않는다", async () => { + openFile.mockResolvedValueOnce(fileContent({ path: "/private/tmp/doc.md" })); + await openPathInTab("/tmp/doc.md"); + expect(useWorkspaceStore.getState().recentFiles).toEqual(["/private/tmp/doc.md"]); + + // 이미 열린 파일 — IPC 없이 합류해도 목록은 갱신된다. + await openPathInTab("/private/tmp/doc.md"); + expect(useWorkspaceStore.getState().recentFiles).toEqual(["/private/tmp/doc.md"]); + + openFile.mockRejectedValueOnce(new IpcError("io", "읽기 실패")); + await openPathInTab("/tmp/broken.md"); + expect(useWorkspaceStore.getState().recentFiles).toEqual(["/private/tmp/doc.md"]); + }); }); // 집행: file-lifecycle.md#인코딩-정책(수동 재해석) — 배너에서 다른 인코딩으로 다시 연다. diff --git a/apps/desktop/src/features/open-file/model/open-file.ts b/apps/desktop/src/features/open-file/model/open-file.ts index 80c8703..dc5f33f 100644 --- a/apps/desktop/src/features/open-file/model/open-file.ts +++ b/apps/desktop/src/features/open-file/model/open-file.ts @@ -1,4 +1,5 @@ import { findTab, setTabText, useDocumentStore } from "@entities/document"; +import { useWorkspaceStore } from "@entities/workspace"; import { STRINGS } from "@shared/config"; import { ipc } from "@shared/ipc"; import { notifyIpcError, useConfirmStore } from "@shared/ui"; @@ -35,11 +36,15 @@ export async function openPathInTab( const existing = store.tabs.find((tab) => tab.filePath === path); if (existing) { store.activateTab(existing.id, focusEditor); + useWorkspaceStore.getState().noteRecentFile(path); return; } try { const file = await ipc.openFile(path); useDocumentStore.getState().openFileTab(file, focusEditor); + // 최근 파일은 canonical 경로다 — 요청 문자열이 아니라 열기 결과를 올린다 + // (→ document-model.md#최근-파일). + useWorkspaceStore.getState().noteRecentFile(file.path); } catch (error) { // 바이너리·손상 파일의 열기 거부도 이 경로로 안내된다 — 파일은 건드리지 않았다. notifyIpcError(STRINGS.openFailedTitle, error); diff --git a/apps/desktop/src/features/save-file/model/save-tab.test.ts b/apps/desktop/src/features/save-file/model/save-tab.test.ts index b41262e..6e6ae34 100644 --- a/apps/desktop/src/features/save-file/model/save-tab.test.ts +++ b/apps/desktop/src/features/save-file/model/save-tab.test.ts @@ -68,7 +68,7 @@ function openTab(path = "/vault/doc.md"): string { beforeEach(() => { useDocumentStore.setState({ tabs: [], activeTabId: null }); - useWorkspaceStore.setState({ rootDir: null }); + useWorkspaceStore.setState({ rootDir: null, recentFiles: [] }); useConflictStore.setState({ conflictTabIds: [] }); useConfirmStore.setState({ pending: null }); useNoticeStore.setState({ notices: [] }); @@ -419,6 +419,8 @@ describe("저장 경로 확정과 중복 탭 금지", () => { filePath: "/private/tmp/새 문서.md", title: "새 문서.md", }); + // 경로 확정은 최근 파일에도 올라간다 — 값은 같은 canonical 경로다(→ document-model.md#최근-파일). + expect(useWorkspaceStore.getState().recentFiles).toEqual(["/private/tmp/새 문서.md"]); }); // 집행: rust-commands.md#다이얼로그 — 웹뷰는 사이드바 루트를 시작 위치로 넘긴다. diff --git a/apps/desktop/src/features/save-file/model/save-tab.ts b/apps/desktop/src/features/save-file/model/save-tab.ts index 3996639..85318ca 100644 --- a/apps/desktop/src/features/save-file/model/save-tab.ts +++ b/apps/desktop/src/features/save-file/model/save-tab.ts @@ -163,6 +163,8 @@ async function performSave( // 탭 신원은 저장이 실제로 쓴 canonical 경로다(→ document-model.md#다중-탭-규칙). if (tab.filePath !== result.path) { useDocumentStore.getState().assignPath(tabId, result.path); + // 이 분기는 다이얼로그가 새 경로를 확정했을 때만 탄다(→ document-model.md#최근-파일). + useWorkspaceStore.getState().noteRecentFile(result.path); } commitSaveSuccess(tabId, tab, text, result.hash); return "saved"; diff --git a/apps/desktop/src/shared/config/index.ts b/apps/desktop/src/shared/config/index.ts index 106b4db..a681897 100644 --- a/apps/desktop/src/shared/config/index.ts +++ b/apps/desktop/src/shared/config/index.ts @@ -3,6 +3,7 @@ export { EDITOR_COLORS } from "./editor-colors"; export { BLUR_RADIUS_DEFAULT, BLUR_RADIUS_MAX, GLASS_OPACITY_DEFAULT } from "./glass"; export { CLOSE_FLUSH_TIMEOUT_MS, + RECENT_FILES_LIMIT, SESSION_RESTORE_TIMEOUT_MS, SESSION_SAVE_DEBOUNCE_MS, } from "./session"; diff --git a/apps/desktop/src/shared/config/session.ts b/apps/desktop/src/shared/config/session.ts index cc8fcdf..bae36ad 100644 --- a/apps/desktop/src/shared/config/session.ts +++ b/apps/desktop/src/shared/config/session.ts @@ -8,3 +8,6 @@ export const SESSION_RESTORE_TIMEOUT_MS = 3000; /** 종료 시 저장 플러시 상한(ms) — 답이 오지 않아도 창은 닫힌다. */ export const CLOSE_FLUSH_TIMEOUT_MS = 3000; + +/** 최근 파일 상한 — 정책은 document-model.md#최근-파일이 소유한다. */ +export const RECENT_FILES_LIMIT = 10; From 88d8875958c4bd53eb98d6086d6b5525aeda956b Mon Sep 17 00:00:00 2001 From: Bori-github Date: Tue, 4 Aug 2026 02:02:03 +0900 Subject: [PATCH 04/18] =?UTF-8?q?refactor(shared):=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=ED=97=AC=ED=8D=BC=202=EB=B2=8C=EC=9D=84?= =?UTF-8?q?=20entryNameOf=20=ED=95=98=EB=82=98=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit document-store의 fileNameOf와 sidebar의 folderNameOf는 같은 구현이었다. shared/lib(path-segments)로 옮겨 탭 제목·사이드바 헤더·최근 파일 목록이 같은 규칙으로 이름을 만들게 한다. 백로그의 해당 항목을 지운다. Co-Authored-By: Claude Fable 5 --- .claude/docs/implementation-plan.md | 2 +- .../entities/document/model/document-store.ts | 13 ++++--------- apps/desktop/src/shared/lib/index.ts | 2 +- .../src/shared/lib/path-segments.test.ts | 18 +++++++++++++++++- apps/desktop/src/shared/lib/path-segments.ts | 10 ++++++++++ .../desktop/src/widgets/sidebar/ui/sidebar.tsx | 8 ++------ 6 files changed, 35 insertions(+), 18 deletions(-) diff --git a/.claude/docs/implementation-plan.md b/.claude/docs/implementation-plan.md index 186b717..ca78bb9 100644 --- a/.claude/docs/implementation-plan.md +++ b/.claude/docs/implementation-plan.md @@ -88,7 +88,7 @@ norii의 구현 순서·**구현 상태**와 열린 결정의 단일 출처다. 결정이 아니라 **실행만 남은** 작은 후속 작업을 추적한다 — 결정이 필요하면 위 [열린 결정](#열린-결정-open-decisions)으로, 마일스톤 규모면 [마일스톤](#마일스톤) 표로 간다. 완료하면 줄을 지운다. 항목은 방향만 적는다 — 상세 맥락은 화살표의 소유 문서·코드가 담당한다. -- **중복 정리** — 경로→파일명 헬퍼 2벌(document-store·sidebar)을 `shared/lib`로 통합 · 테스트의 IpcError 목 6곳을 공용 헬퍼로 (→ [프론트엔드 아키텍처](frontend-architecture.md)) +- **중복 정리** — 테스트의 IpcError 목 6곳을 공용 헬퍼로 (→ [프론트엔드 아키텍처](frontend-architecture.md)) - **들여쓰기 상수화** — 트리 들여쓰기 `12`/`8`px에 이름 부여 — M8 간격 토큰 확정과 함께 - **이벤트 이름 상수화** — `dir-changed` 등 이벤트명이 Rust·프론트에 문자열로 중복 — 공유 상수 또는 바인딩 생성으로 (→ [Rust 커맨드 계약](rust-commands.md#이벤트-계약-rust--웹뷰)) - **트리 감시 정리 신호** — 앱 셸 정리 함수가 `watchTree(null)`을 보내지 않는다 — 앱 수명과 같아 실영향 없음, 선언적 교체 계약의 완결용 diff --git a/apps/desktop/src/entities/document/model/document-store.ts b/apps/desktop/src/entities/document/model/document-store.ts index fb53a33..f132ea1 100644 --- a/apps/desktop/src/entities/document/model/document-store.ts +++ b/apps/desktop/src/entities/document/model/document-store.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { STRINGS } from "@shared/config"; +import { entryNameOf } from "@shared/lib"; import type { FileContent } from "@shared/ipc"; import { clearTabText, setInitialText } from "./text-access"; @@ -48,12 +49,6 @@ interface DocumentActions { export type DocumentStore = DocumentState & DocumentActions; -function fileNameOf(path: string): string { - // Windows canonical 경로(\\?\C:\...)까지 고려해 양쪽 구분자를 다룬다(→ platform-strategy.md). - const name = path.split(/[/\\]/).at(-1); - return name && name.length > 0 ? name : path; -} - // 경로 구분자 경계에서만 하위로 친다 — 문자열 접두어로 보면 /vault/묶음2가 /vault/묶음의 // 하위가 된다. function movedPath(filePath: string | null, oldPath: string, newPath: string): string | null { @@ -87,7 +82,7 @@ export const useDocumentStore = create()((set, get) => ({ const tab: Tab = { id, filePath: file.path, - title: fileNameOf(file.path), + title: entryNameOf(file.path), isDirty: false, sourceEncoding: file.encoding, hasBom: file.hasBom, @@ -178,7 +173,7 @@ export const useDocumentStore = create()((set, get) => ({ assignPath(tabId, path) { set((state) => ({ - tabs: updateTab(state.tabs, tabId, { filePath: path, title: fileNameOf(path) }), + tabs: updateTab(state.tabs, tabId, { filePath: path, title: entryNameOf(path) }), })); }, @@ -206,7 +201,7 @@ export const useDocumentStore = create()((set, get) => ({ return tab; } changed = true; - return { ...tab, filePath: moved, title: fileNameOf(moved) }; + return { ...tab, filePath: moved, title: entryNameOf(moved) }; }); return changed ? { tabs } : state; }); diff --git a/apps/desktop/src/shared/lib/index.ts b/apps/desktop/src/shared/lib/index.ts index f9dd12e..dfcfd88 100644 --- a/apps/desktop/src/shared/lib/index.ts +++ b/apps/desktop/src/shared/lib/index.ts @@ -2,7 +2,7 @@ export { hasWindowGlass, isMac, isPrimaryModifier } from "./platform"; export { countChars } from "./char-count"; export { logger } from "./logger"; export { within } from "./within"; -export { pathSegmentsWithinRoot } from "./path-segments"; +export { entryNameOf, pathSegmentsWithinRoot } from "./path-segments"; export { AA_NON_TEXT, AA_TEXT, diff --git a/apps/desktop/src/shared/lib/path-segments.test.ts b/apps/desktop/src/shared/lib/path-segments.test.ts index 5613ceb..e563dbd 100644 --- a/apps/desktop/src/shared/lib/path-segments.test.ts +++ b/apps/desktop/src/shared/lib/path-segments.test.ts @@ -1,6 +1,22 @@ import { describe, expect, it } from "vitest"; -import { pathSegmentsWithinRoot } from "./path-segments"; +import { entryNameOf, pathSegmentsWithinRoot } from "./path-segments"; + +// 왜: 탭 제목·사이드바 헤더·최근 파일 목록이 같은 규칙으로 이름을 만들어야 한다 — +// 따로 구현하면 같은 파일이 자리마다 다른 이름으로 보일 수 있다. +// 보장: 마지막 구분자 뒤가 이름이고, 양쪽 구분자(/·\)를 다루며, 이름이 비면 경로 그대로다. +// 경계: 확장자 표시 여부는 사용처가 정한다 — 이 함수는 항목 이름만 만든다. +describe("경로의 마지막 항목 이름", () => { + it("파일명과 폴더명을 돌려준다", () => { + expect(entryNameOf("/글/회고/회고.md")).toBe("회고.md"); + expect(entryNameOf("/글/회고")).toBe("회고"); + expect(entryNameOf("\\\\?\\C:\\글\\회고.md")).toBe("회고.md"); + }); + + it("이름이 비면 경로 그대로 돌려준다", () => { + expect(entryNameOf("/")).toBe("/"); + }); +}); // 왜: 상태 바의 경로는 "연 폴더 안 어디"만 답한다. 기준이 없거나 맞지 않을 때 다른 규칙으로 // 답하면 같은 표기가 상황마다 다른 뜻이 된다. diff --git a/apps/desktop/src/shared/lib/path-segments.ts b/apps/desktop/src/shared/lib/path-segments.ts index a2ee6d4..38285a7 100644 --- a/apps/desktop/src/shared/lib/path-segments.ts +++ b/apps/desktop/src/shared/lib/path-segments.ts @@ -1,3 +1,13 @@ +/** + * 경로의 마지막 항목 이름(파일명·폴더명). + * + * Windows canonical 경로(\\?\C:\...)까지 고려해 양쪽 구분자를 다룬다(→ platform-strategy.md). + */ +export function entryNameOf(path: string): string { + const name = path.split(/[/\\]/).at(-1); + return name && name.length > 0 ? name : path; +} + /** * 연 폴더와 파일 사이의 폴더 이름을 순서대로 돌려준다. * diff --git a/apps/desktop/src/widgets/sidebar/ui/sidebar.tsx b/apps/desktop/src/widgets/sidebar/ui/sidebar.tsx index 172db1f..81ba925 100644 --- a/apps/desktop/src/widgets/sidebar/ui/sidebar.tsx +++ b/apps/desktop/src/widgets/sidebar/ui/sidebar.tsx @@ -6,6 +6,7 @@ import { openPathInTab } from "@features/open-file"; import { openFolderInteractive, toggleDir } from "@features/open-folder"; import { SettingsButton } from "@features/toggle-settings"; import { STRINGS } from "@shared/config"; +import { entryNameOf } from "@shared/lib"; import { Button, FilePlusIcon, FolderPlusIcon, IconButton } from "@shared/ui"; import { openEntryMenu, useContextMenuStore } from "../model/context-menu-store"; @@ -87,11 +88,6 @@ const emptyClass = css({ textAlign: "center", }); -function folderNameOf(path: string): string { - const name = path.split(/[/\\]/).at(-1); - return name && name.length > 0 ? name : path; -} - // 정지점을 index로 옮기고 포커스한다(범위는 [0, 끝]으로 물린다). function focusAt(list: HTMLElement[], index: number): void { const el = list[Math.max(0, Math.min(index, list.length - 1))]; @@ -220,7 +216,7 @@ export function Sidebar() {