@jcjeon/integration-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/.gitignore +23 -0
  2. package/.npmignore +21 -0
  3. package/.prettierignore +6 -0
  4. package/.prettierrc +26 -0
  5. package/AGENTS.md +10 -0
  6. package/CLAUDE.md +10 -0
  7. package/README.md +384 -0
  8. package/apps/server/README.md +294 -0
  9. package/apps/server/eslint.config.mjs +20 -0
  10. package/apps/server/nest-cli.json +8 -0
  11. package/apps/server/package.json +89 -0
  12. package/apps/server/scripts/postinstall.js +53 -0
  13. package/apps/server/src/__mocks__/glob.js +6 -0
  14. package/apps/server/src/__mocks__/uuid.js +5 -0
  15. package/apps/server/src/app.controller.spec.ts +24 -0
  16. package/apps/server/src/app.controller.ts +13 -0
  17. package/apps/server/src/app.module.ts +18 -0
  18. package/apps/server/src/app.service.ts +8 -0
  19. package/apps/server/src/common/ji-paths.ts +41 -0
  20. package/apps/server/src/database/database.module.ts +27 -0
  21. package/apps/server/src/database/entities/agent-changelog.entity.ts +39 -0
  22. package/apps/server/src/database/entities/agent-session.entity.ts +29 -0
  23. package/apps/server/src/database/entities/conversation.entity.ts +41 -0
  24. package/apps/server/src/database/entities/session.entity.ts +16 -0
  25. package/apps/server/src/database/entities/task-agent-run.entity.ts +40 -0
  26. package/apps/server/src/database/entities/task-agent.entity.ts +42 -0
  27. package/apps/server/src/database/entities/task-requirement.entity.ts +27 -0
  28. package/apps/server/src/database/entities/task-run.entity.ts +41 -0
  29. package/apps/server/src/database/entities/task.entity.ts +44 -0
  30. package/apps/server/src/main.ts +65 -0
  31. package/apps/server/src/modules/agents/agent-model-settings.spec.ts +80 -0
  32. package/apps/server/src/modules/agents/agents.module.ts +11 -0
  33. package/apps/server/src/modules/agents/claude/claude-auth.manager.ts +83 -0
  34. package/apps/server/src/modules/agents/claude/claude-pty.manager.ts +380 -0
  35. package/apps/server/src/modules/agents/claude/claude.controller.ts +85 -0
  36. package/apps/server/src/modules/agents/claude/claude.gateway.ts +158 -0
  37. package/apps/server/src/modules/agents/claude/claude.module.ts +18 -0
  38. package/apps/server/src/modules/agents/claude/claude.service.ts +67 -0
  39. package/apps/server/src/modules/agents/claude/dto/create-session.dto.ts +24 -0
  40. package/apps/server/src/modules/agents/claude/dto/resize-session.dto.ts +13 -0
  41. package/apps/server/src/modules/agents/claude/dto/send-input.dto.ts +9 -0
  42. package/apps/server/src/modules/agents/claude/interfaces/claude-session.interface.ts +26 -0
  43. package/apps/server/src/modules/agents/claude/interfaces/pty-event.interface.ts +10 -0
  44. package/apps/server/src/modules/agents/claude/interfaces/stream-event.interface.ts +61 -0
  45. package/apps/server/src/modules/agents/codex/codex-auth.manager.ts +107 -0
  46. package/apps/server/src/modules/agents/codex/codex-session.manager.ts +357 -0
  47. package/apps/server/src/modules/agents/codex/codex.controller.ts +64 -0
  48. package/apps/server/src/modules/agents/codex/codex.gateway.ts +97 -0
  49. package/apps/server/src/modules/agents/codex/codex.module.ts +17 -0
  50. package/apps/server/src/modules/agents/codex/dto/configure-auth.dto.ts +7 -0
  51. package/apps/server/src/modules/agents/gemini/dto/configure-auth.dto.ts +15 -0
  52. package/apps/server/src/modules/agents/gemini/dto/create-session.dto.ts +9 -0
  53. package/apps/server/src/modules/agents/gemini/dto/send-input.dto.ts +9 -0
  54. package/apps/server/src/modules/agents/gemini/gemini-auth.manager.ts +157 -0
  55. package/apps/server/src/modules/agents/gemini/gemini-session.manager.ts +287 -0
  56. package/apps/server/src/modules/agents/gemini/gemini.controller.ts +93 -0
  57. package/apps/server/src/modules/agents/gemini/gemini.gateway.ts +149 -0
  58. package/apps/server/src/modules/agents/gemini/gemini.module.ts +17 -0
  59. package/apps/server/src/modules/agents/gemini/interfaces/gemini-session.interface.ts +18 -0
  60. package/apps/server/src/modules/agents/gemini/interfaces/stream-event.interface.ts +14 -0
  61. package/apps/server/src/modules/agents/session-termination.spec.ts +103 -0
  62. package/apps/server/src/modules/changelog/changelog.controller.ts +20 -0
  63. package/apps/server/src/modules/changelog/changelog.module.ts +14 -0
  64. package/apps/server/src/modules/changelog/changelog.service.spec.ts +531 -0
  65. package/apps/server/src/modules/changelog/changelog.service.ts +690 -0
  66. package/apps/server/src/modules/conversations/conversation.controller.spec.ts +106 -0
  67. package/apps/server/src/modules/conversations/conversation.controller.ts +60 -0
  68. package/apps/server/src/modules/conversations/conversation.module.ts +14 -0
  69. package/apps/server/src/modules/conversations/conversation.service.spec.ts +176 -0
  70. package/apps/server/src/modules/conversations/conversation.service.ts +54 -0
  71. package/apps/server/src/modules/conversations/dto/create-conversation.dto.ts +37 -0
  72. package/apps/server/src/modules/conversations/enums/conversation.enum.ts +13 -0
  73. package/apps/server/src/modules/fs/fs.controller.ts +29 -0
  74. package/apps/server/src/modules/fs/fs.module.ts +8 -0
  75. package/apps/server/src/modules/harness/dto/save-harness.dto.ts +9 -0
  76. package/apps/server/src/modules/harness/harness.controller.spec.ts +95 -0
  77. package/apps/server/src/modules/harness/harness.controller.ts +35 -0
  78. package/apps/server/src/modules/harness/harness.module.ts +11 -0
  79. package/apps/server/src/modules/harness/harness.service.spec.ts +217 -0
  80. package/apps/server/src/modules/harness/harness.service.ts +112 -0
  81. package/apps/server/src/modules/sessions/session.controller.spec.ts +68 -0
  82. package/apps/server/src/modules/sessions/session.controller.ts +43 -0
  83. package/apps/server/src/modules/sessions/session.module.ts +14 -0
  84. package/apps/server/src/modules/sessions/session.service.spec.ts +106 -0
  85. package/apps/server/src/modules/sessions/session.service.ts +35 -0
  86. package/apps/server/src/modules/tasks/dto/create-task.dto.ts +54 -0
  87. package/apps/server/src/modules/tasks/dto/execute-task.dto.ts +22 -0
  88. package/apps/server/src/modules/tasks/dto/merge-file.dto.ts +7 -0
  89. package/apps/server/src/modules/tasks/dto/rerun-task.dto.ts +14 -0
  90. package/apps/server/src/modules/tasks/dto/update-task.dto.ts +55 -0
  91. package/apps/server/src/modules/tasks/task-execution.service.ts +978 -0
  92. package/apps/server/src/modules/tasks/task.gateway.ts +140 -0
  93. package/apps/server/src/modules/tasks/tasks.controller.spec.ts +210 -0
  94. package/apps/server/src/modules/tasks/tasks.controller.ts +139 -0
  95. package/apps/server/src/modules/tasks/tasks.module.ts +30 -0
  96. package/apps/server/src/modules/tasks/tasks.service.spec.ts +552 -0
  97. package/apps/server/src/modules/tasks/tasks.service.ts +333 -0
  98. package/apps/server/test/app.e2e-spec.ts +28 -0
  99. package/apps/server/test/jest-e2e.json +9 -0
  100. package/apps/server/tsconfig.build.json +4 -0
  101. package/apps/server/tsconfig.json +13 -0
  102. package/apps/web/AGENTS.md +7 -0
  103. package/apps/web/CLAUDE.md +1 -0
  104. package/apps/web/README.md +36 -0
  105. package/apps/web/eslint.config.mjs +21 -0
  106. package/apps/web/next-env.d.ts +6 -0
  107. package/apps/web/next.config.ts +7 -0
  108. package/apps/web/package.json +49 -0
  109. package/apps/web/postcss.config.mjs +7 -0
  110. package/apps/web/public/file.svg +1 -0
  111. package/apps/web/public/globe.svg +1 -0
  112. package/apps/web/public/next.svg +1 -0
  113. package/apps/web/public/vercel.svg +1 -0
  114. package/apps/web/public/window.svg +1 -0
  115. package/apps/web/src/app/claude/page.tsx +5 -0
  116. package/apps/web/src/app/codex/page.tsx +126 -0
  117. package/apps/web/src/app/favicon.ico +0 -0
  118. package/apps/web/src/app/gemini/page.tsx +130 -0
  119. package/apps/web/src/app/globals.css +149 -0
  120. package/apps/web/src/app/layout.tsx +40 -0
  121. package/apps/web/src/app/login/page.tsx +67 -0
  122. package/apps/web/src/app/page.tsx +497 -0
  123. package/apps/web/src/app/task/[id]/page.tsx +11 -0
  124. package/apps/web/src/app/test/page.tsx +298 -0
  125. package/apps/web/src/components/ui/Modal.tsx +78 -0
  126. package/apps/web/src/components/ui/WorkingDirPicker.tsx +195 -0
  127. package/apps/web/src/components/ui/__tests__/Modal.test.tsx +68 -0
  128. package/apps/web/src/features/auth/api/__tests__/auth.api.test.ts +83 -0
  129. package/apps/web/src/features/auth/api/auth.api.ts +81 -0
  130. package/apps/web/src/features/auth/hooks/__tests__/useClaudeAuth.test.ts +166 -0
  131. package/apps/web/src/features/auth/hooks/__tests__/useCodexAuth.test.ts +127 -0
  132. package/apps/web/src/features/auth/hooks/__tests__/useGeminiAuth.test.ts +120 -0
  133. package/apps/web/src/features/auth/hooks/useClaudeAuth.ts +88 -0
  134. package/apps/web/src/features/auth/hooks/useCodexAuth.ts +149 -0
  135. package/apps/web/src/features/auth/hooks/useGeminiAuth.ts +125 -0
  136. package/apps/web/src/features/auth/ui/CodexLoginPanel.tsx +302 -0
  137. package/apps/web/src/features/auth/ui/GeminiLoginPanel.tsx +316 -0
  138. package/apps/web/src/features/auth/ui/LoginForm.tsx +190 -0
  139. package/apps/web/src/features/auth/ui/LoginPanel.tsx +114 -0
  140. package/apps/web/src/features/auth/ui/__tests__/LoginPanel.test.tsx +105 -0
  141. package/apps/web/src/features/chat/api/__tests__/sessions.api.test.ts +187 -0
  142. package/apps/web/src/features/chat/api/sessions.api.ts +161 -0
  143. package/apps/web/src/features/chat/container/ClaudePageContainer.tsx +152 -0
  144. package/apps/web/src/features/chat/hooks/__tests__/useCodexSessions.test.ts +131 -0
  145. package/apps/web/src/features/chat/hooks/__tests__/useGeminiSessions.test.ts +130 -0
  146. package/apps/web/src/features/chat/hooks/useAgentModelSettings.ts +54 -0
  147. package/apps/web/src/features/chat/hooks/useClaudeSessions.ts +323 -0
  148. package/apps/web/src/features/chat/hooks/useCodexSessions.ts +275 -0
  149. package/apps/web/src/features/chat/hooks/useGeminiSessions.ts +255 -0
  150. package/apps/web/src/features/chat/hooks/useSessionCommand.ts +66 -0
  151. package/apps/web/src/features/chat/hooks/useSessionRename.ts +61 -0
  152. package/apps/web/src/features/chat/hooks/useSessionWorkingDirectories.ts +34 -0
  153. package/apps/web/src/features/chat/hooks/useUnifiedSessions.ts +156 -0
  154. package/apps/web/src/features/chat/lib/agentModelOptions.ts +72 -0
  155. package/apps/web/src/features/chat/ui/AgentModelPicker.tsx +134 -0
  156. package/apps/web/src/features/chat/ui/AgentSelectModal.tsx +236 -0
  157. package/apps/web/src/features/chat/ui/ChatInput.tsx +162 -0
  158. package/apps/web/src/features/chat/ui/ChatMessage.tsx +204 -0
  159. package/apps/web/src/features/chat/ui/ChatWorkspace.tsx +207 -0
  160. package/apps/web/src/features/chat/ui/CheckingSkeleton.tsx +44 -0
  161. package/apps/web/src/features/chat/ui/ClaudeLoginView.tsx +44 -0
  162. package/apps/web/src/features/chat/ui/PermissionCard.tsx +37 -0
  163. package/apps/web/src/features/chat/ui/SessionSidebar.tsx +280 -0
  164. package/apps/web/src/features/chat/ui/__tests__/AgentSelectModal.test.tsx +58 -0
  165. package/apps/web/src/features/chat/ui/__tests__/ChatInput.test.tsx +134 -0
  166. package/apps/web/src/features/chat/ui/__tests__/ChatMessage.test.tsx +106 -0
  167. package/apps/web/src/features/chat/ui/__tests__/ChatWorkspace.test.tsx +66 -0
  168. package/apps/web/src/features/diff/ui/DiffFileRow.tsx +73 -0
  169. package/apps/web/src/features/diff/ui/DiffHunk.tsx +61 -0
  170. package/apps/web/src/features/diff/ui/FileChangeBadge.tsx +23 -0
  171. package/apps/web/src/features/diff/ui/__tests__/DiffFileRow.test.tsx +40 -0
  172. package/apps/web/src/features/diff/ui/__tests__/DiffHunk.test.tsx +24 -0
  173. package/apps/web/src/features/diff/ui/__tests__/FileChangeBadge.test.tsx +16 -0
  174. package/apps/web/src/features/fs/api/fs.api.ts +14 -0
  175. package/apps/web/src/features/fs/hooks/useDirBrowser.ts +50 -0
  176. package/apps/web/src/features/harness/api/__tests__/harness.api.test.ts +73 -0
  177. package/apps/web/src/features/harness/api/harness.api.ts +46 -0
  178. package/apps/web/src/features/harness/hooks/__tests__/useHarness.test.ts +65 -0
  179. package/apps/web/src/features/harness/hooks/useHarness.ts +66 -0
  180. package/apps/web/src/features/harness/ui/HarnessModal.tsx +171 -0
  181. package/apps/web/src/features/harness/ui/__tests__/HarnessModal.test.tsx +46 -0
  182. package/apps/web/src/features/status/ui/AgentStatusModal.tsx +267 -0
  183. package/apps/web/src/features/status/ui/__tests__/AgentStatusModal.test.tsx +71 -0
  184. package/apps/web/src/features/tasks/api/__tests__/changelog.api.test.ts +89 -0
  185. package/apps/web/src/features/tasks/api/__tests__/tasks.api.test.ts +282 -0
  186. package/apps/web/src/features/tasks/api/changelog.api.ts +52 -0
  187. package/apps/web/src/features/tasks/api/tasks.api.ts +175 -0
  188. package/apps/web/src/features/tasks/container/TaskDetailPageContainer.tsx +69 -0
  189. package/apps/web/src/features/tasks/hooks/__tests__/useChangelogCodeCopy.test.ts +48 -0
  190. package/apps/web/src/features/tasks/hooks/__tests__/useTaskChangelog.test.ts +48 -0
  191. package/apps/web/src/features/tasks/hooks/__tests__/useTaskCreate.test.ts +217 -0
  192. package/apps/web/src/features/tasks/hooks/__tests__/useTaskEdit.test.ts +152 -0
  193. package/apps/web/src/features/tasks/hooks/__tests__/useTaskExecution.test.ts +143 -0
  194. package/apps/web/src/features/tasks/hooks/__tests__/useTaskList.test.ts +168 -0
  195. package/apps/web/src/features/tasks/hooks/__tests__/useTaskNotification.test.ts +125 -0
  196. package/apps/web/src/features/tasks/hooks/__tests__/useTaskRuns.test.ts +51 -0
  197. package/apps/web/src/features/tasks/hooks/useChangelogCodeCopy.ts +52 -0
  198. package/apps/web/src/features/tasks/hooks/useCopyToClipboard.ts +47 -0
  199. package/apps/web/src/features/tasks/hooks/useTaskChangelog.ts +32 -0
  200. package/apps/web/src/features/tasks/hooks/useTaskCreate.ts +137 -0
  201. package/apps/web/src/features/tasks/hooks/useTaskDetail.ts +217 -0
  202. package/apps/web/src/features/tasks/hooks/useTaskEdit.ts +130 -0
  203. package/apps/web/src/features/tasks/hooks/useTaskExecution.ts +137 -0
  204. package/apps/web/src/features/tasks/hooks/useTaskList.ts +159 -0
  205. package/apps/web/src/features/tasks/hooks/useTaskNotification.ts +80 -0
  206. package/apps/web/src/features/tasks/hooks/useTaskRuns.ts +32 -0
  207. package/apps/web/src/features/tasks/ui/AgentOutputPanel.tsx +203 -0
  208. package/apps/web/src/features/tasks/ui/AgentRoleSelect.tsx +97 -0
  209. package/apps/web/src/features/tasks/ui/ChangelogPanel.tsx +321 -0
  210. package/apps/web/src/features/tasks/ui/RunHistoryPanel.tsx +193 -0
  211. package/apps/web/src/features/tasks/ui/TaskCreateModal.tsx +205 -0
  212. package/apps/web/src/features/tasks/ui/TaskDetailView.tsx +413 -0
  213. package/apps/web/src/features/tasks/ui/TaskEditModal.tsx +165 -0
  214. package/apps/web/src/features/tasks/ui/TaskListModal.tsx +591 -0
  215. package/apps/web/src/features/tasks/ui/__tests__/AgentRoleSelect.test.tsx +91 -0
  216. package/apps/web/src/features/tasks/ui/__tests__/ChangelogPanel.test.tsx +94 -0
  217. package/apps/web/src/features/tasks/ui/__tests__/RunHistoryPanel.test.tsx +71 -0
  218. package/apps/web/src/features/tasks/ui/__tests__/TaskCreateModal.test.tsx +153 -0
  219. package/apps/web/src/features/tasks/ui/__tests__/TaskEditModal.test.tsx +75 -0
  220. package/apps/web/src/features/tasks/ui/__tests__/TaskListModal.test.tsx +243 -0
  221. package/apps/web/src/hooks/useWorkingDir.ts +28 -0
  222. package/apps/web/src/lib/__tests__/ansi.test.ts +88 -0
  223. package/apps/web/src/lib/ansi.ts +105 -0
  224. package/apps/web/src/lib/constants.ts +4 -0
  225. package/apps/web/src/lib/quota.ts +22 -0
  226. package/apps/web/src/lib/theme.tsx +78 -0
  227. package/apps/web/src/lib/toast.tsx +175 -0
  228. package/apps/web/src/store/agentStatusStore.ts +38 -0
  229. package/apps/web/tsconfig.json +18 -0
  230. package/apps/web/vitest.config.ts +25 -0
  231. package/apps/web/vitest.setup.ts +10 -0
  232. package/package.json +85 -0
  233. package/packages/cli/dist/commands/check.d.ts +1 -0
  234. package/packages/cli/dist/commands/check.js +89 -0
  235. package/packages/cli/dist/commands/init.d.ts +5 -0
  236. package/packages/cli/dist/commands/init.js +183 -0
  237. package/packages/cli/dist/commands/start.d.ts +4 -0
  238. package/packages/cli/dist/commands/start.js +188 -0
  239. package/packages/cli/dist/index.d.ts +2 -0
  240. package/packages/cli/dist/index.js +71 -0
  241. package/packages/cli/dist/utils/agent-tools.d.ts +28 -0
  242. package/packages/cli/dist/utils/agent-tools.js +193 -0
  243. package/packages/cli/dist/utils/project-init.d.ts +12 -0
  244. package/packages/cli/dist/utils/project-init.js +258 -0
  245. package/packages/cli/dist/utils/proxy.d.ts +8 -0
  246. package/packages/cli/dist/utils/proxy.js +138 -0
  247. package/packages/cli/package.json +30 -0
  248. package/packages/cli/src/commands/check.ts +77 -0
  249. package/packages/cli/src/commands/init.ts +209 -0
  250. package/packages/cli/src/commands/start.ts +183 -0
  251. package/packages/cli/src/index.ts +91 -0
  252. package/packages/cli/src/utils/agent-tools.ts +201 -0
  253. package/packages/cli/src/utils/project-init.ts +252 -0
  254. package/packages/cli/src/utils/proxy.ts +123 -0
  255. package/packages/cli/tsconfig.json +14 -0
  256. package/packages/eslint-config/base.mjs +31 -0
  257. package/packages/eslint-config/nest.mjs +55 -0
  258. package/packages/eslint-config/next.mjs +23 -0
  259. package/packages/eslint-config/package.json +20 -0
  260. package/packages/typescript-config/base.json +16 -0
  261. package/packages/typescript-config/nestjs.json +17 -0
  262. package/packages/typescript-config/nextjs.json +15 -0
  263. package/packages/typescript-config/package.json +11 -0
  264. package/turbo.json +28 -0
@@ -0,0 +1,298 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useState } from "react";
5
+
6
+ import type { DiffFile } from "@/features/diff/ui/DiffFileRow";
7
+ import { DiffFileRow } from "@/features/diff/ui/DiffFileRow";
8
+
9
+ // ─── Mock Data ────────────────────────────────────────────────────────────────
10
+
11
+ const MOCK_FILES: DiffFile[] = [
12
+ {
13
+ filePath: "src/features/auth/hooks/useLogin.ts",
14
+ changeType: "added",
15
+ additions: 42,
16
+ deletions: 0,
17
+ lines: [
18
+ { type: "hunk", content: "@@ -0,0 +1,42 @@" },
19
+ { type: "added", content: '"use client";', newLineNo: 1 },
20
+ { type: "added", content: "", newLineNo: 2 },
21
+ { type: "added", content: "import { useState } from \"react\";", newLineNo: 3 },
22
+ { type: "added", content: "", newLineNo: 4 },
23
+ { type: "added", content: "export function useLogin() {", newLineNo: 5 },
24
+ { type: "added", content: " const [loading, setLoading] = useState(false);", newLineNo: 6 },
25
+ { type: "added", content: " const [error, setError] = useState<string | null>(null);", newLineNo: 7 },
26
+ { type: "added", content: "", newLineNo: 8 },
27
+ { type: "added", content: " const login = async (email: string, password: string) => {", newLineNo: 9 },
28
+ { type: "added", content: " setLoading(true);", newLineNo: 10 },
29
+ { type: "added", content: " try {", newLineNo: 11 },
30
+ { type: "added", content: " await authApi.login({ email, password });", newLineNo: 12 },
31
+ { type: "added", content: " } catch (e) {", newLineNo: 13 },
32
+ { type: "added", content: " setError(e instanceof Error ? e.message : \"로그인 실패\");", newLineNo: 14 },
33
+ { type: "added", content: " } finally {", newLineNo: 15 },
34
+ { type: "added", content: " setLoading(false);", newLineNo: 16 },
35
+ { type: "added", content: " }", newLineNo: 17 },
36
+ { type: "added", content: " };", newLineNo: 18 },
37
+ { type: "added", content: "", newLineNo: 19 },
38
+ { type: "added", content: " return { loading, error, login };", newLineNo: 20 },
39
+ { type: "added", content: "}", newLineNo: 21 },
40
+ ],
41
+ },
42
+ {
43
+ filePath: "src/features/auth/ui/LoginForm.tsx",
44
+ changeType: "modified",
45
+ additions: 15,
46
+ deletions: 8,
47
+ lines: [
48
+ { type: "hunk", content: "@@ -12,18 +12,25 @@" },
49
+ { type: "context", content: "export function LoginForm() {", oldLineNo: 12, newLineNo: 12 },
50
+ { type: "context", content: " const { loading, error, login } = useLogin();", oldLineNo: 13, newLineNo: 13 },
51
+ { type: "context", content: "", oldLineNo: 14, newLineNo: 14 },
52
+ { type: "removed", content: " const [email, setEmail] = useState(\"\");", oldLineNo: 15 },
53
+ { type: "removed", content: " const [password, setPassword] = useState(\"\");", oldLineNo: 16 },
54
+ { type: "added", content: " const [form, setForm] = useState({ email: \"\", password: \"\" });", newLineNo: 15 },
55
+ { type: "added", content: "", newLineNo: 16 },
56
+ { type: "added", content: " const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {", newLineNo: 17 },
57
+ { type: "added", content: " setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));", newLineNo: 18 },
58
+ { type: "added", content: " };", newLineNo: 19 },
59
+ { type: "context", content: "", oldLineNo: 17, newLineNo: 20 },
60
+ { type: "context", content: " const handleSubmit = async (e: React.FormEvent) => {", oldLineNo: 18, newLineNo: 21 },
61
+ { type: "context", content: " e.preventDefault();", oldLineNo: 19, newLineNo: 22 },
62
+ { type: "removed", content: " await login(email, password);", oldLineNo: 20 },
63
+ { type: "added", content: " await login(form.email, form.password);", newLineNo: 23 },
64
+ { type: "context", content: " };", oldLineNo: 21, newLineNo: 24 },
65
+ ],
66
+ },
67
+ {
68
+ filePath: "src/features/auth/api/auth.api.ts",
69
+ changeType: "modified",
70
+ additions: 6,
71
+ deletions: 2,
72
+ lines: [
73
+ { type: "hunk", content: "@@ -28,10 +28,14 @@" },
74
+ { type: "context", content: "export async function login(dto: LoginDto) {", oldLineNo: 28, newLineNo: 28 },
75
+ { type: "context", content: " const res = await fetch(`${SERVER_URL}/auth/login`, {", oldLineNo: 29, newLineNo: 29 },
76
+ { type: "context", content: " method: \"POST\",", oldLineNo: 30, newLineNo: 30 },
77
+ { type: "removed", content: " body: JSON.stringify(dto),", oldLineNo: 31 },
78
+ { type: "added", content: " headers: { \"Content-Type\": \"application/json\" },", newLineNo: 31 },
79
+ { type: "added", content: " body: JSON.stringify(dto),", newLineNo: 32 },
80
+ { type: "context", content: " });", oldLineNo: 32, newLineNo: 33 },
81
+ { type: "removed", content: " if (!res.ok) throw new Error(\"HTTP \" + res.status);", oldLineNo: 33 },
82
+ { type: "added", content: " if (!res.ok) {", newLineNo: 34 },
83
+ { type: "added", content: " const body = await res.json().catch(() => ({}));", newLineNo: 35 },
84
+ { type: "added", content: " throw new Error(body.message ?? `HTTP ${res.status}`);", newLineNo: 36 },
85
+ { type: "added", content: " }", newLineNo: 37 },
86
+ { type: "context", content: " return res.json();", oldLineNo: 34, newLineNo: 38 },
87
+ { type: "context", content: "}", oldLineNo: 35, newLineNo: 39 },
88
+ ],
89
+ },
90
+ {
91
+ filePath: "src/features/auth/utils/validators.ts",
92
+ changeType: "deleted",
93
+ additions: 0,
94
+ deletions: 18,
95
+ lines: [
96
+ { type: "hunk", content: "@@ -1,18 +0,0 @@" },
97
+ { type: "removed", content: "export function validateEmail(email: string) {", oldLineNo: 1 },
98
+ { type: "removed", content: " return /^[^@]+@[^@]+\\.[^@]+$/.test(email);", oldLineNo: 2 },
99
+ { type: "removed", content: "}", oldLineNo: 3 },
100
+ { type: "removed", content: "", oldLineNo: 4 },
101
+ { type: "removed", content: "export function validatePassword(password: string) {", oldLineNo: 5 },
102
+ { type: "removed", content: " return password.length >= 8;", oldLineNo: 6 },
103
+ { type: "removed", content: "}", oldLineNo: 7 },
104
+ ],
105
+ },
106
+ {
107
+ filePath: "src/features/auth/utils/validation.ts",
108
+ changeType: "renamed",
109
+ additions: 4,
110
+ deletions: 0,
111
+ lines: [
112
+ { type: "hunk", content: "@@ -0,0 +1,12 @@ (validators.ts → validation.ts)" },
113
+ { type: "context", content: "export function validateEmail(email: string) {", oldLineNo: 1, newLineNo: 1 },
114
+ { type: "context", content: " return /^[^@]+@[^@]+\\.[^@]+$/.test(email);", oldLineNo: 2, newLineNo: 2 },
115
+ { type: "context", content: "}", oldLineNo: 3, newLineNo: 3 },
116
+ { type: "context", content: "", oldLineNo: 4, newLineNo: 4 },
117
+ { type: "context", content: "export function validatePassword(password: string) {", oldLineNo: 5, newLineNo: 5 },
118
+ { type: "context", content: " return password.length >= 8;", oldLineNo: 6, newLineNo: 6 },
119
+ { type: "context", content: "}", oldLineNo: 7, newLineNo: 7 },
120
+ { type: "added", content: "", newLineNo: 8 },
121
+ { type: "added", content: "export function validateRequired(value: string) {", newLineNo: 9 },
122
+ { type: "added", content: " return value.trim().length > 0;", newLineNo: 10 },
123
+ { type: "added", content: "}", newLineNo: 11 },
124
+ ],
125
+ },
126
+ ];
127
+
128
+ const STATS = MOCK_FILES.reduce(
129
+ (acc, f) => ({ additions: acc.additions + f.additions, deletions: acc.deletions + f.deletions }),
130
+ { additions: 0, deletions: 0 },
131
+ );
132
+
133
+ // ─── DiffSummaryBar ───────────────────────────────────────────────────────────
134
+
135
+ function DiffSummaryBar() {
136
+ const total = STATS.additions + STATS.deletions;
137
+ const addRatio = total > 0 ? (STATS.additions / total) * 100 : 50;
138
+
139
+ return (
140
+ <div className="flex items-center gap-3">
141
+ <span className="text-[11px] font-mono text-emerald-600 dark:text-emerald-400">
142
+ +{STATS.additions}
143
+ </span>
144
+ <div className="h-1.5 flex-1 overflow-hidden rounded-full bg-gray-900/[0.06] dark:bg-white/[0.06]">
145
+ <div
146
+ className="h-full rounded-full bg-emerald-500"
147
+ style={{ width: `${addRatio}%` }}
148
+ />
149
+ </div>
150
+ <span className="text-[11px] font-mono text-red-600 dark:text-red-400">
151
+ -{STATS.deletions}
152
+ </span>
153
+ </div>
154
+ );
155
+ }
156
+
157
+ // ─── FilterBar ────────────────────────────────────────────────────────────────
158
+
159
+ type FilterType = "all" | "added" | "modified" | "deleted" | "renamed";
160
+
161
+ const FILTER_LABELS: Record<FilterType, string> = {
162
+ all: "전체",
163
+ added: "추가",
164
+ modified: "수정",
165
+ deleted: "삭제",
166
+ renamed: "이동",
167
+ };
168
+
169
+ interface FilterBarProps {
170
+ active: FilterType;
171
+ onChange: (f: FilterType) => void;
172
+ }
173
+
174
+ function FilterBar({ active, onChange }: FilterBarProps) {
175
+ const filters = (Object.keys(FILTER_LABELS) as FilterType[]);
176
+ return (
177
+ <div className="flex gap-1 rounded-lg border border-gray-900/[0.06] bg-gray-900/[0.02] p-0.5 dark:border-white/[0.06] dark:bg-white/[0.02]">
178
+ {filters.map((f) => (
179
+ <button
180
+ key={f}
181
+ type="button"
182
+ onClick={() => onChange(f)}
183
+ className={[
184
+ "rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors",
185
+ active === f
186
+ ? "bg-white text-gray-900/70 shadow-sm dark:bg-white/[0.08] dark:text-white/70"
187
+ : "text-gray-900/35 hover:text-gray-900/60 dark:text-white/35 dark:hover:text-white/60",
188
+ ].join(" ")}
189
+ >
190
+ {FILTER_LABELS[f]}
191
+ {f !== "all" && (
192
+ <span className="ml-1 opacity-50">
193
+ {MOCK_FILES.filter((file) => file.changeType === f).length}
194
+ </span>
195
+ )}
196
+ </button>
197
+ ))}
198
+ </div>
199
+ );
200
+ }
201
+
202
+ // ─── Page ─────────────────────────────────────────────────────────────────────
203
+
204
+ export default function DiffTestPage() {
205
+ const [filter, setFilter] = useState<FilterType>("all");
206
+ const [expandAll, setExpandAll] = useState(false);
207
+
208
+ const filtered = filter === "all"
209
+ ? MOCK_FILES
210
+ : MOCK_FILES.filter((f) => f.changeType === filter);
211
+
212
+ return (
213
+ <div className="min-h-screen bg-[#faf8f5] px-4 py-8 dark:bg-[#07090e]">
214
+ <div className="mx-auto max-w-3xl">
215
+
216
+ {/* 헤더 */}
217
+ <div className="mb-6 flex items-center justify-between">
218
+ <div className="flex items-center gap-3">
219
+ <Link
220
+ href="/"
221
+ className="flex h-7 w-7 items-center justify-center rounded-lg border border-gray-900/[0.08] text-gray-900/30 transition-colors hover:border-gray-900/[0.15] hover:text-gray-900/60 dark:border-white/[0.08] dark:text-white/30 dark:hover:border-white/[0.15] dark:hover:text-white/60"
222
+ >
223
+ <svg viewBox="0 0 16 16" fill="currentColor" className="h-3.5 w-3.5">
224
+ <path fillRule="evenodd" d="M9.78 4.22a.75.75 0 010 1.06L7.06 8l2.72 2.72a.75.75 0 11-1.06 1.06L5.47 8.53a.75.75 0 010-1.06l3.25-3.25a.75.75 0 011.06 0z" clipRule="evenodd" />
225
+ </svg>
226
+ </Link>
227
+ <div>
228
+ <h1 className="text-sm font-semibold text-gray-900/80 dark:text-white/80">
229
+ Diff 뷰어 테스트
230
+ </h1>
231
+ <p className="text-[11px] text-gray-900/30 dark:text-white/30">
232
+ feat/auth-refactor → main
233
+ </p>
234
+ </div>
235
+ </div>
236
+
237
+ <div className="flex items-center gap-1.5 rounded-lg border border-gray-900/[0.07] bg-gray-900/[0.03] px-2.5 py-1.5 dark:border-white/[0.07] dark:bg-white/[0.03]">
238
+ <span className="text-[11px] text-gray-900/40 dark:text-white/40">
239
+ {MOCK_FILES.length}개 파일
240
+ </span>
241
+ <span className="text-gray-900/10 dark:text-white/10">·</span>
242
+ <span className="font-mono text-[11px] text-emerald-600 dark:text-emerald-400">
243
+ +{STATS.additions}
244
+ </span>
245
+ <span className="font-mono text-[11px] text-red-600 dark:text-red-400">
246
+ -{STATS.deletions}
247
+ </span>
248
+ </div>
249
+ </div>
250
+
251
+ {/* 통계 바 */}
252
+ <div className="mb-4 rounded-xl border border-gray-900/[0.07] bg-white/60 p-4 dark:border-white/[0.07] dark:bg-white/[0.03]">
253
+ <p className="mb-2 text-[11px] font-medium text-gray-900/40 dark:text-white/40">변경 통계</p>
254
+ <DiffSummaryBar />
255
+ </div>
256
+
257
+ {/* 툴바 */}
258
+ <div className="mb-3 flex items-center justify-between gap-3">
259
+ <FilterBar active={filter} onChange={setFilter} />
260
+ <button
261
+ type="button"
262
+ onClick={() => setExpandAll((p) => !p)}
263
+ className="flex items-center gap-1.5 rounded-lg border border-gray-900/[0.07] px-2.5 py-1 text-[11px] font-medium text-gray-900/35 transition-colors hover:border-gray-900/[0.13] hover:text-gray-900/70 dark:border-white/[0.07] dark:text-white/35 dark:hover:border-white/[0.13] dark:hover:text-white/70"
264
+ >
265
+ <svg viewBox="0 0 16 16" fill="currentColor" className="h-3 w-3">
266
+ {expandAll ? (
267
+ <path fillRule="evenodd" d="M4.22 6.22a.75.75 0 011.06 0L8 8.94l2.72-2.72a.75.75 0 111.06 1.06l-3.25 3.25a.75.75 0 01-1.06 0L4.22 7.28a.75.75 0 010-1.06z" clipRule="evenodd" />
268
+ ) : (
269
+ <path fillRule="evenodd" d="M6.22 4.22a.75.75 0 011.06 0l3.25 3.25a.75.75 0 010 1.06l-3.25 3.25a.75.75 0 01-1.06-1.06L8.94 8 6.22 5.28a.75.75 0 010-1.06z" clipRule="evenodd" />
270
+ )}
271
+ </svg>
272
+ {expandAll ? "모두 접기" : "모두 펼치기"}
273
+ </button>
274
+ </div>
275
+
276
+ {/* 파일 목록 */}
277
+ {filtered.length === 0 ? (
278
+ <div className="flex flex-col items-center gap-2 rounded-xl border border-gray-900/[0.06] py-12 text-center dark:border-white/[0.06]">
279
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-8 w-8 text-gray-900/15 dark:text-white/15">
280
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12" />
281
+ </svg>
282
+ <p className="text-xs text-gray-900/30 dark:text-white/30">해당 유형의 변경사항이 없습니다</p>
283
+ </div>
284
+ ) : (
285
+ <div className="flex flex-col gap-1.5">
286
+ {filtered.map((file) => (
287
+ <DiffFileRow
288
+ key={`${file.filePath}-${String(expandAll)}`}
289
+ file={file}
290
+ defaultOpen={expandAll}
291
+ />
292
+ ))}
293
+ </div>
294
+ )}
295
+ </div>
296
+ </div>
297
+ );
298
+ }
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+
5
+ interface ModalProps {
6
+ open: boolean;
7
+ onClose: () => void;
8
+ title: string;
9
+ children: React.ReactNode;
10
+ maxWidth?: string;
11
+ hideClose?: boolean;
12
+ zIndex?: string;
13
+ }
14
+
15
+ export function Modal({ open, onClose, title, children, maxWidth = "max-w-lg", hideClose = false, zIndex = "z-50" }: ModalProps) {
16
+ const overlayRef = useRef<HTMLDivElement>(null);
17
+
18
+ useEffect(() => {
19
+ if (!open) return;
20
+ const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
21
+ window.addEventListener("keydown", handler);
22
+ return () => window.removeEventListener("keydown", handler);
23
+ }, [open, onClose]);
24
+
25
+ useEffect(() => {
26
+ if (open) document.body.style.overflow = "hidden";
27
+ else document.body.style.overflow = "";
28
+ return () => { document.body.style.overflow = ""; };
29
+ }, [open]);
30
+
31
+ if (!open) return null;
32
+
33
+ return (
34
+ <div
35
+ ref={overlayRef}
36
+ className={`fixed inset-0 flex items-center justify-center bg-black/50 p-4 backdrop-blur-md dark:bg-black/70 ${zIndex}`}
37
+ onMouseDown={(e) => { if (e.target === overlayRef.current) onClose(); }}
38
+ >
39
+ <div
40
+ className={[
41
+ "relative flex w-full flex-col rounded-2xl",
42
+ "border border-gray-900/[0.08] bg-white dark:border-white/[0.08] dark:bg-[#0a0c10]",
43
+ "shadow-[0_24px_80px_rgba(0,0,0,0.15)] dark:shadow-[0_24px_80px_rgba(0,0,0,0.7)]",
44
+ maxWidth,
45
+ ].join(" ")}
46
+ style={{ animation: "modal-in 0.18s cubic-bezier(0.16,1,0.3,1) both" }}
47
+ >
48
+ {/* 헤더 */}
49
+ <div className="flex items-center justify-between border-b border-gray-900/[0.06] px-5 py-4 dark:border-white/[0.06]">
50
+ <h2 className="text-sm font-semibold text-gray-900/85 dark:text-white/85">{title}</h2>
51
+ {!hideClose && (
52
+ <button
53
+ onClick={onClose}
54
+ className="flex h-6 w-6 items-center justify-center rounded-md text-gray-900/25 transition-colors hover:bg-gray-900/[0.07] hover:text-gray-900/70 dark:text-white/25 dark:hover:bg-white/[0.07] dark:hover:text-white/70"
55
+ aria-label="닫기"
56
+ >
57
+ <svg viewBox="0 0 16 16" fill="currentColor" className="h-3.5 w-3.5">
58
+ <path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
59
+ </svg>
60
+ </button>
61
+ )}
62
+ </div>
63
+
64
+ {/* 본문 */}
65
+ <div className="overflow-y-auto px-5 py-5" style={{ maxHeight: "calc(90vh - 64px)" }}>
66
+ {children}
67
+ </div>
68
+ </div>
69
+
70
+ <style>{`
71
+ @keyframes modal-in {
72
+ from { opacity: 0; transform: scale(0.96) translateY(6px); }
73
+ to { opacity: 1; transform: scale(1) translateY(0); }
74
+ }
75
+ `}</style>
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,195 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+
5
+ import { useDirBrowser } from "@/features/fs/hooks/useDirBrowser";
6
+
7
+ interface WorkingDirPickerProps {
8
+ value: string;
9
+ onChange: (path: string) => void;
10
+ /** "inline" = 채팅 푸터용 한 줄 레이아웃, "field" = 모달 내 폼 필드 */
11
+ variant?: "inline" | "field";
12
+ }
13
+
14
+ function FolderIcon({ className }: { className?: string }) {
15
+ return (
16
+ <svg viewBox="0 0 16 16" fill="currentColor" className={className}>
17
+ <path d="M1.75 1A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25v-8.5A1.75 1.75 0 0014.25 3H7.5a.25.25 0 01-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z" />
18
+ </svg>
19
+ );
20
+ }
21
+
22
+ function DirDropdown({
23
+ currentPath,
24
+ dirs,
25
+ loading,
26
+ direction = "down",
27
+ onNavigate,
28
+ onNavigateUp,
29
+ onSelect,
30
+ }: {
31
+ currentPath: string;
32
+ dirs: string[];
33
+ loading: boolean;
34
+ direction?: "up" | "down";
35
+ onNavigate: (path: string) => void;
36
+ onNavigateUp: () => void;
37
+ onSelect: () => void;
38
+ }) {
39
+ const sep = currentPath.includes("/") ? "/" : "\\";
40
+ const isRoot = !currentPath || currentPath === sep || !currentPath.slice(1).includes(sep);
41
+ const positionClass = direction === "up" ? "bottom-full mb-1" : "top-full mt-1";
42
+
43
+ return (
44
+ <div className={`absolute left-0 z-50 w-80 rounded-xl border border-gray-900/[0.1] bg-white shadow-lg dark:border-white/[0.1] dark:bg-gray-900 ${positionClass}`}>
45
+ {/* 현재 경로 + 위로 버튼 */}
46
+ <div className="flex items-center gap-1.5 border-b border-gray-900/[0.07] px-3 py-2 dark:border-white/[0.07]">
47
+ <button
48
+ type="button"
49
+ onClick={onNavigateUp}
50
+ disabled={isRoot || loading}
51
+ title="상위 폴더"
52
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-gray-900/40 transition-colors hover:bg-gray-900/[0.06] hover:text-gray-900/70 disabled:opacity-30 dark:text-white/40 dark:hover:bg-white/[0.06] dark:hover:text-white/70"
53
+ >
54
+ <svg viewBox="0 0 16 16" fill="currentColor" className="h-3.5 w-3.5">
55
+ <path d="M3.22 10.53a.75.75 0 001.06 0L8 6.81l3.72 3.72a.75.75 0 101.06-1.06L8.53 5.22a.75.75 0 00-1.06 0L3.22 9.47a.75.75 0 000 1.06z" />
56
+ </svg>
57
+ </button>
58
+ <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-gray-900/60 dark:text-white/60">
59
+ {currentPath || "…"}
60
+ </span>
61
+ </div>
62
+
63
+ {/* 디렉토리 목록 */}
64
+ <ul className="max-h-52 overflow-y-auto py-1">
65
+ {loading && (
66
+ <li className="px-3 py-2 text-xs text-gray-900/30 dark:text-white/30">로딩 중…</li>
67
+ )}
68
+ {!loading && dirs.length === 0 && (
69
+ <li className="px-3 py-2 text-xs text-gray-900/30 dark:text-white/30">하위 폴더 없음</li>
70
+ )}
71
+ {!loading &&
72
+ dirs.map((dir) => (
73
+ <li key={dir}>
74
+ <button
75
+ type="button"
76
+ onClick={() => onNavigate(`${currentPath}${sep}${dir}`)}
77
+ className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-gray-900/75 transition-colors hover:bg-gray-900/[0.04] dark:text-white/75 dark:hover:bg-white/[0.05]"
78
+ >
79
+ <FolderIcon className="h-3.5 w-3.5 shrink-0 text-gray-900/30 dark:text-white/30" />
80
+ <span className="truncate">{dir}</span>
81
+ </button>
82
+ </li>
83
+ ))}
84
+ </ul>
85
+
86
+ {/* 선택 확정 */}
87
+ <div className="border-t border-gray-900/[0.07] px-3 py-2 dark:border-white/[0.07]">
88
+ <button
89
+ type="button"
90
+ onClick={onSelect}
91
+ disabled={!currentPath}
92
+ className="w-full rounded-lg bg-orange-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-orange-600 disabled:opacity-40"
93
+ >
94
+ 여기 선택
95
+ </button>
96
+ </div>
97
+ </div>
98
+ );
99
+ }
100
+
101
+ export function WorkingDirPicker({ value, onChange, variant = "field" }: WorkingDirPickerProps) {
102
+ const containerRef = useRef<HTMLDivElement>(null);
103
+ const { open, currentPath, dirs, loading, navigate, openBrowser, closeBrowser, navigateUp } =
104
+ useDirBrowser();
105
+
106
+ useEffect(() => {
107
+ if (!open) return;
108
+ const handler = (e: MouseEvent) => {
109
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
110
+ closeBrowser();
111
+ }
112
+ };
113
+ document.addEventListener("mousedown", handler);
114
+ return () => document.removeEventListener("mousedown", handler);
115
+ }, [open, closeBrowser]);
116
+
117
+ const handleOpenBrowser = () => openBrowser(value || undefined);
118
+
119
+ const handleSelect = () => {
120
+ onChange(currentPath);
121
+ closeBrowser();
122
+ };
123
+
124
+ const dropdown = open ? (
125
+ <DirDropdown
126
+ currentPath={currentPath}
127
+ dirs={dirs}
128
+ loading={loading}
129
+ direction={variant === "inline" ? "up" : "down"}
130
+ onNavigate={navigate}
131
+ onNavigateUp={navigateUp}
132
+ onSelect={handleSelect}
133
+ />
134
+ ) : null;
135
+
136
+ if (variant === "inline") {
137
+ return (
138
+ <div ref={containerRef} className="relative flex items-center gap-1.5">
139
+ <button
140
+ type="button"
141
+ onClick={handleOpenBrowser}
142
+ title="워크 디렉토리 선택"
143
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-900/30 transition-colors hover:bg-gray-900/[0.05] hover:text-gray-900/60 dark:text-white/30 dark:hover:bg-white/[0.06] dark:hover:text-white/60"
144
+ >
145
+ <FolderIcon className="h-3.5 w-3.5" />
146
+ </button>
147
+
148
+ {value ? (
149
+ <div className="flex min-w-0 flex-1 items-center gap-1">
150
+ <span className="min-w-0 truncate font-mono text-[11px] text-orange-600 dark:text-orange-400">
151
+ {value}
152
+ </span>
153
+ <button
154
+ type="button"
155
+ onClick={() => onChange("")}
156
+ title="지우기"
157
+ className="shrink-0 text-gray-900/20 transition-colors hover:text-gray-900/50 dark:text-white/20 dark:hover:text-white/50"
158
+ >
159
+ <svg viewBox="0 0 16 16" fill="currentColor" className="h-3 w-3">
160
+ <path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
161
+ </svg>
162
+ </button>
163
+ </div>
164
+ ) : (
165
+ <span className="text-[11px] text-gray-900/25 dark:text-white/25">워크 디렉토리 없음</span>
166
+ )}
167
+
168
+ {dropdown}
169
+ </div>
170
+ );
171
+ }
172
+
173
+ // variant === "field"
174
+ return (
175
+ <div ref={containerRef} className="relative flex gap-2">
176
+ <input
177
+ type="text"
178
+ value={value}
179
+ onChange={(e) => onChange(e.target.value)}
180
+ placeholder="~/IdeaProjects/project 또는 IdeaProjects/project"
181
+ className="flex-1 rounded-xl border border-gray-900/[0.08] bg-gray-900/[0.03] px-3 py-2 font-mono text-sm text-gray-900/65 placeholder-gray-900/20 outline-none transition-colors focus:border-orange-500/50 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-white/65 dark:placeholder-white/20"
182
+ />
183
+ <button
184
+ type="button"
185
+ onClick={handleOpenBrowser}
186
+ title="디렉토리 선택"
187
+ className="flex items-center justify-center rounded-xl border border-gray-900/[0.08] bg-gray-900/[0.03] px-3 text-gray-900/40 transition-colors hover:border-gray-900/[0.14] hover:bg-gray-900/[0.05] hover:text-gray-900/70 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-white/40 dark:hover:border-white/[0.14] dark:hover:bg-white/[0.05] dark:hover:text-white/70"
188
+ >
189
+ <FolderIcon className="h-4 w-4" />
190
+ </button>
191
+
192
+ {dropdown}
193
+ </div>
194
+ );
195
+ }
@@ -0,0 +1,68 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { Modal } from "../Modal";
6
+
7
+ function setup(open = true, onClose = vi.fn()) {
8
+ render(
9
+ <Modal open={open} onClose={onClose} title="Test Modal">
10
+ <p>Modal content</p>
11
+ </Modal>,
12
+ );
13
+ return { onClose };
14
+ }
15
+
16
+ describe("Modal", () => {
17
+ it("renders title and children when open", () => {
18
+ setup();
19
+ expect(screen.getByText("Test Modal")).toBeInTheDocument();
20
+ expect(screen.getByText("Modal content")).toBeInTheDocument();
21
+ });
22
+
23
+ it("renders nothing when closed", () => {
24
+ setup(false);
25
+ expect(screen.queryByText("Test Modal")).not.toBeInTheDocument();
26
+ });
27
+
28
+ it("calls onClose when close button is clicked", async () => {
29
+ const user = userEvent.setup();
30
+ const { onClose } = setup();
31
+ await user.click(screen.getByRole("button", { name: "닫기" }));
32
+ expect(onClose).toHaveBeenCalledTimes(1);
33
+ });
34
+
35
+ it("calls onClose when ESC key is pressed", async () => {
36
+ const user = userEvent.setup();
37
+ const { onClose } = setup();
38
+ await user.keyboard("{Escape}");
39
+ expect(onClose).toHaveBeenCalledTimes(1);
40
+ });
41
+
42
+ it("hides close button when hideClose=true", () => {
43
+ const onClose = vi.fn();
44
+ render(
45
+ <Modal open={true} onClose={onClose} title="No Close" hideClose>
46
+ content
47
+ </Modal>,
48
+ );
49
+ expect(screen.queryByRole("button", { name: "닫기" })).not.toBeInTheDocument();
50
+ });
51
+
52
+ it("applies custom maxWidth class", () => {
53
+ render(
54
+ <Modal open={true} onClose={vi.fn()} title="Wide" maxWidth="max-w-3xl">
55
+ content
56
+ </Modal>,
57
+ );
58
+ const dialog = screen.getByText("Wide").closest(".max-w-3xl");
59
+ expect(dialog).toBeInTheDocument();
60
+ });
61
+
62
+ it("does not call onClose when clicking inside the modal content", async () => {
63
+ const user = userEvent.setup();
64
+ const { onClose } = setup();
65
+ await user.click(screen.getByText("Modal content"));
66
+ expect(onClose).not.toHaveBeenCalled();
67
+ });
68
+ });