diff --git a/locales/en/common.json b/locales/en/common.json index 3c8e7549..c696a8af 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -276,9 +276,12 @@ "projectModal": { "cancel": "Cancel", "clone": "Clone from GitHub", + "cloneFailed": "Clone failed: {error}", "cloneFromGitHub": "Clone from GitHub", "cloneSuccess": "Clone succeeded", "create": "Create", + "createEmptyFailed": "Failed to create empty project: {error}", + "createFailed": "Failed to create project: {error}", "created": "Created", "deleteConfirm": "Are you sure you want to delete this project?", "deleteProject": "Delete Project", @@ -286,6 +289,10 @@ "descriptionOptional": "Description (optional)", "descriptionPlaceholder": "Enter project description...", "editProject": "Edit Project", + "emptyProject": "Empty Project", + "emptyProjectHint": "Create a project without initial template files", + "import": "Import", + "importZip": "Import ZIP", "inputRepoUrl": "Repository URL", "loading": "Loading...", "nameHint": "Use alphanumeric characters and hyphens", @@ -300,8 +307,14 @@ "repoUrl": "Repository URL", "repoUrlPlaceholder": "https://github.com/owner/repo", "save": "Save", + "selectZipFile": "Please select a ZIP file", + "selectedFile": "Selected file", "title": "Project Management", - "updated": "Updated" + "updated": "Updated", + "zipFile": "ZIP File", + "zipImportFailed": "ZIP import failed: {error}", + "zipImportHint": "Import a repository from a ZIP file. If .git directory exists, it will be preserved.", + "zipImportSuccess": "ZIP import succeeded" }, "run": { "clearOutput": "Clear output", diff --git a/locales/ja/common.json b/locales/ja/common.json index 58504dff..9fb87345 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -276,9 +276,12 @@ "projectModal": { "cancel": "キャンセル", "clone": "GitHubからクローン", + "cloneFailed": "クローンに失敗しました: {error}", "cloneFromGitHub": "GitHubからクローン", "cloneSuccess": "クローンに成功しました", "create": "作成", + "createEmptyFailed": "空のプロジェクト作成に失敗しました: {error}", + "createFailed": "プロジェクト作成に失敗しました: {error}", "created": "作成済み", "deleteConfirm": "本当にこのプロジェクトを削除しますか?", "deleteProject": "プロジェクト削除", @@ -286,6 +289,10 @@ "descriptionOptional": "説明(任意)", "descriptionPlaceholder": "プロジェクトの説明を入力してください...", "editProject": "プロジェクト編集", + "emptyProject": "空のプロジェクト", + "emptyProjectHint": "初期テンプレートファイルなしでプロジェクトを作成します", + "import": "インポート", + "importZip": "ZIPインポート", "inputRepoUrl": "リポジトリURLを入力", "loading": "読み込み中...", "nameHint": "英数字とハイフンのみ使用できます", @@ -300,8 +307,14 @@ "repoUrl": "リポジトリURL", "repoUrlPlaceholder": "https://github.com/owner/repo", "save": "保存", + "selectZipFile": "ZIPファイルを選択してください", + "selectedFile": "選択されたファイル", "title": "プロジェクト管理", - "updated": "更新されました" + "updated": "更新されました", + "zipFile": "ZIPファイル", + "zipImportFailed": "ZIPインポートに失敗しました: {error}", + "zipImportHint": "ZIPファイルからリポジトリをインポートします。.gitディレクトリがある場合は保持されます。", + "zipImportSuccess": "ZIPインポートに成功しました" }, "run": { "clearOutput": "出力をクリア", diff --git a/src/components/ProjectModal.tsx b/src/components/ProjectModal.tsx index c0bcfc08..fe98bb5f 100644 --- a/src/components/ProjectModal.tsx +++ b/src/components/ProjectModal.tsx @@ -1,11 +1,16 @@ -import { Edit, Folder, GitBranch, Plus, Trash2, X } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { Archive, Edit, FileText, Folder, GitBranch, Plus, Trash2, X } from 'lucide-react'; +import JSZip from 'jszip'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from '@/context/I18nContext'; import { fileRepository } from '@/engine/core/fileRepository'; +import { gitFileSystem } from '@/engine/core/gitFileSystem'; +import { isLikelyTextFile } from '@/engine/helper/isLikelyTextFile'; import { authRepository } from '@/engine/user/authRepository'; import type { Project } from '@/types'; +type CreationMode = 'none' | 'new' | 'empty' | 'clone' | 'zip'; + interface ProjectModalProps { isOpen: boolean; onClose: () => void; @@ -22,15 +27,17 @@ export default function ProjectModal({ currentProject, }: ProjectModalProps) { const [projects, setProjects] = useState([]); - const [isCreating, setIsCreating] = useState(false); - const [isCloning, setIsCloning] = useState(false); + const [creationMode, setCreationMode] = useState('none'); const [newProjectName, setNewProjectName] = useState(''); const [newProjectDescription, setNewProjectDescription] = useState(''); const [cloneUrl, setCloneUrl] = useState(''); const [cloneProjectName, setCloneProjectName] = useState(''); + const [zipFile, setZipFile] = useState(null); + const [zipProjectName, setZipProjectName] = useState(''); const [loading, setLoading] = useState(false); const [editingProject, setEditingProject] = useState(null); const [isGitHubAuthenticated, setIsGitHubAuthenticated] = useState(false); + const zipInputRef = useRef(null); const { t } = useTranslation(); useEffect(() => { @@ -63,16 +70,19 @@ export default function ProjectModal({ } }; - const handleCreateProject = async () => { - let name = newProjectName.trim(); - if (!name) return; - - // reponame: 英数字・ハイフンのみ、空白は-に置換、日本語不可 - name = name.replace(/\s+/g, '-'); - if (!/^[a-zA-Z0-9-]+$/.test(name)) { + const validateProjectName = (name: string): string | null => { + const sanitized = name.trim().replace(/\s+/g, '-'); + if (!sanitized) return null; + if (!/^[a-zA-Z0-9-]+$/.test(sanitized)) { alert(t('projectModal.nameValidation')); - return; + return null; } + return sanitized; + }; + + const handleCreateProject = async () => { + const name = validateProjectName(newProjectName); + if (!name) return; setLoading(true); try { @@ -87,12 +97,36 @@ export default function ProjectModal({ } setNewProjectName(''); setNewProjectDescription(''); - setIsCreating(false); + setCreationMode('none'); onClose(); await loadProjects(); } catch (error) { console.error('Failed to create project:', error); - alert(`プロジェクト作成に失敗しました: ${(error as Error).message}`); + alert(t('projectModal.createFailed', { params: { error: (error as Error).message } })); + } finally { + setLoading(false); + } + }; + + const handleCreateEmptyProject = async () => { + const name = validateProjectName(newProjectName); + if (!name) return; + + setLoading(true); + try { + const project = await fileRepository.createEmptyProject( + name, + newProjectDescription.trim() || undefined + ); + onProjectSelect(project); + setNewProjectName(''); + setNewProjectDescription(''); + setCreationMode('none'); + onClose(); + await loadProjects(); + } catch (error) { + console.error('Failed to create empty project:', error); + alert(t('projectModal.createEmptyFailed', { params: { error: (error as Error).message } })); } finally { setLoading(false); } @@ -113,17 +147,13 @@ export default function ProjectModal({ name = repoName.replace(/\s+/g, '-'); } - // プロジェクト名のバリデーション - name = name.replace(/\s+/g, '-'); - if (!/^[a-zA-Z0-9-]+$/.test(name)) { - alert(t('projectModal.nameValidation')); - return; - } + const validatedName = validateProjectName(name); + if (!validatedName) return; setLoading(true); try { // 空のプロジェクトを作成(デフォルトファイル無し) - const project = await fileRepository.createEmptyProject(name); + const project = await fileRepository.createEmptyProject(validatedName); // GitCommandsはregistry経由で取得(シングルトン管理) const { terminalCommandRegistry } = await import('@/engine/cmd/terminalRegistry'); @@ -139,14 +169,128 @@ export default function ProjectModal({ setCloneUrl(''); setCloneProjectName(''); - setIsCloning(false); + setCreationMode('none'); onClose(); await loadProjects(); alert(t('projectModal.cloneSuccess')); } catch (error) { console.error('Failed to clone project:', error); - alert(`クローンに失敗しました: ${(error as Error).message}`); + alert(t('projectModal.cloneFailed', { params: { error: (error as Error).message } })); + } finally { + setLoading(false); + } + }; + + const handleZipFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setZipFile(file); + // ZIPファイル名からプロジェクト名を推測 + const suggestedName = file.name.replace(/\.zip$/i, '').replace(/\s+/g, '-'); + if (!zipProjectName) { + setZipProjectName(suggestedName); + } + } + }; + + const handleImportZip = async () => { + if (!zipFile) { + alert(t('projectModal.selectZipFile')); + return; + } + + let name = zipProjectName.trim(); + if (!name) { + name = zipFile.name.replace(/\.zip$/i, '').replace(/\s+/g, '-'); + } + + const validatedName = validateProjectName(name); + if (!validatedName) return; + + setLoading(true); + try { + // ZIPファイルを読み込む + const arrayBuffer = await zipFile.arrayBuffer(); + const zip = await JSZip.loadAsync(arrayBuffer); + + // 空のプロジェクトを作成 + const project = await fileRepository.createEmptyProject(validatedName); + + // ZIPのルート構造を分析(単一フォルダの場合は中身を展開) + const entries = Object.keys(zip.files); + let rootPrefix = ''; + + // 全エントリが同一フォルダ配下かチェック(例: repo-name/src/... の場合) + const topLevelDirs = new Set(); + for (const entry of entries) { + const parts = entry.split('/'); + if (parts[0] && parts.length > 1) { + topLevelDirs.add(parts[0]); + } + } + + // 単一のトップレベルフォルダがあり、他にルート直下ファイルがない場合 + if (topLevelDirs.size === 1) { + const singleDir = [...topLevelDirs][0]; + const hasRootFiles = entries.some(e => !e.startsWith(singleDir + '/') && e !== singleDir + '/'); + if (!hasRootFiles) { + rootPrefix = singleDir + '/'; + } + } + + // ファイルを展開してプロジェクトに登録 + for (const [relativePath, zipEntry] of Object.entries(zip.files)) { + // rootPrefixを除去してルートに展開 + let filePath = rootPrefix ? relativePath.replace(rootPrefix, '') : relativePath; + + // 空のパスやrootPrefix自体はスキップ + if (!filePath || filePath === '/') continue; + + // フォルダはスキップ(ファイル書き込み時に自動作成される) + if (zipEntry.dir) continue; + + // .gitディレクトリはLightningFSに保存(IndexedDBではなく) + if (filePath === '.git' || filePath.startsWith('.git/')) { + // .gitファイルをLightningFSに書き込む + const contentBuffer = await zipEntry.async('uint8array'); + await gitFileSystem.writeFile(project.name, '/' + filePath, contentBuffer); + continue; + } + + // 先頭にスラッシュを付ける(AppPath形式) + if (!filePath.startsWith('/')) { + filePath = '/' + filePath; + } + + // ファイルの場合 + // バイナリファイルかテキストファイルか判断(isLikelyTextFileを使用) + const contentBuffer = await zipEntry.async('uint8array'); + const isText = await isLikelyTextFile(filePath, contentBuffer); + + if (isText) { + const content = new TextDecoder('utf-8').decode(contentBuffer); + await fileRepository.createFile(project.id, filePath, content, 'file'); + } else { + await fileRepository.createFile(project.id, filePath, '', 'file', true, contentBuffer.buffer as ArrayBuffer); + } + } + + onProjectSelect(project); + + setZipFile(null); + setZipProjectName(''); + if (zipInputRef.current) { + zipInputRef.current.value = ''; + } + setCreationMode('none'); + onClose(); + await loadProjects(); + + alert(t('projectModal.zipImportSuccess')); + } catch (error) { + console.error('Failed to import ZIP:', error); + alert(t('projectModal.zipImportFailed', { params: { error: (error as Error).message } })); } finally { setLoading(false); } @@ -204,6 +348,19 @@ export default function ProjectModal({ }).format(date); }; + const resetCreationMode = () => { + setCreationMode('none'); + setNewProjectName(''); + setNewProjectDescription(''); + setCloneUrl(''); + setCloneProjectName(''); + setZipFile(null); + setZipProjectName(''); + if (zipInputRef.current) { + zipInputRef.current.value = ''; + } + }; + if (!isOpen) return null; return ( @@ -218,10 +375,10 @@ export default function ProjectModal({
- {!isCreating && !isCloning ? ( -
+ {creationMode === 'none' ? ( +
+ +
- ) : isCreating ? ( + ) : creationMode === 'new' ? (
+

{t('projectModal.newProject')}

- ) : ( + ) : creationMode === 'empty' ? (
+

{t('projectModal.emptyProject')}

+

{t('projectModal.emptyProjectHint')}

+
+ + setNewProjectName(e.target.value)} + placeholder={t('projectModal.projectNamePlaceholder')} + className="w-full px-3 py-2 bg-background border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary" + autoFocus + /> +
+
+ +