diff --git a/app/src/components/layout/Header.test.tsx b/app/src/components/layout/Header.test.tsx
new file mode 100644
index 0000000..a48e1ca
--- /dev/null
+++ b/app/src/components/layout/Header.test.tsx
@@ -0,0 +1,62 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render } from '../../test/utils';
+import Header from './Header';
+
+// Mock the child components
+vi.mock('../navigation/UserMenu', () => ({
+ default: () =>
User Menu
,
+}));
+
+vi.mock('../navigation/WorkspaceSwitcher', () => ({
+ default: () => Workspace Switcher
,
+}));
+
+vi.mock('../settings/workspace/WorkspaceSettings', () => ({
+ default: () => Workspace Settings
,
+}));
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('Header', () => {
+ it('renders the app title', () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ expect(getByText('Lemma')).toBeInTheDocument();
+ });
+
+ it('renders user menu component', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('user-menu')).toBeInTheDocument();
+ });
+
+ it('renders workspace switcher component', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('workspace-switcher')).toBeInTheDocument();
+ });
+
+ it('renders workspace settings component', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('workspace-settings')).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/layout/Layout.test.tsx b/app/src/components/layout/Layout.test.tsx
new file mode 100644
index 0000000..7cc0097
--- /dev/null
+++ b/app/src/components/layout/Layout.test.tsx
@@ -0,0 +1,159 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render } from '../../test/utils';
+import Layout from './Layout';
+import { Theme, type FileNode } from '../../types/models';
+
+// Mock child components
+vi.mock('./Header', () => ({
+ default: () => Header
,
+}));
+
+vi.mock('./Sidebar', () => ({
+ default: () => Sidebar
,
+}));
+
+vi.mock('./MainContent', () => ({
+ default: () => Main Content
,
+}));
+
+// Mock hooks
+vi.mock('../../hooks/useFileNavigation', () => ({
+ useFileNavigation: vi.fn(),
+}));
+
+vi.mock('../../hooks/useFileList', () => ({
+ useFileList: vi.fn(),
+}));
+
+vi.mock('../../hooks/useWorkspace', () => ({
+ useWorkspace: vi.fn(),
+}));
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('Layout', () => {
+ const mockHandleFileSelect = vi.fn();
+ const mockLoadFileList = vi.fn();
+
+ const mockCurrentWorkspace = {
+ id: 1,
+ name: 'Test Workspace',
+ createdAt: '2024-01-01T00:00:00Z',
+ gitEnabled: true,
+ gitAutoCommit: false,
+ theme: Theme.Light,
+ autoSave: true,
+ showHiddenFiles: false,
+ gitUrl: '',
+ gitBranch: 'main',
+ gitUsername: '',
+ gitEmail: '',
+ gitToken: '',
+ gitUser: '',
+ gitCommitMsgTemplate: '',
+ gitCommitName: '',
+ gitCommitEmail: '',
+ };
+
+ const mockFiles: FileNode[] = [
+ {
+ id: '1',
+ name: 'README.md',
+ path: 'README.md',
+ },
+ ];
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+
+ const { useWorkspace } = await import('../../hooks/useWorkspace');
+ vi.mocked(useWorkspace).mockReturnValue({
+ currentWorkspace: mockCurrentWorkspace,
+ workspaces: [],
+ settings: mockCurrentWorkspace,
+ updateSettings: vi.fn(),
+ loading: false,
+ colorScheme: 'light',
+ updateColorScheme: vi.fn(),
+ switchWorkspace: vi.fn(),
+ deleteCurrentWorkspace: vi.fn(),
+ });
+
+ const { useFileNavigation } = await import('../../hooks/useFileNavigation');
+ vi.mocked(useFileNavigation).mockReturnValue({
+ selectedFile: 'README.md',
+ isNewFile: false,
+ handleFileSelect: mockHandleFileSelect,
+ });
+
+ const { useFileList } = await import('../../hooks/useFileList');
+ vi.mocked(useFileList).mockReturnValue({
+ files: mockFiles,
+ loadFileList: mockLoadFileList,
+ });
+ });
+
+ it('renders all layout components when workspace is loaded', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('header')).toBeInTheDocument();
+ expect(getByTestId('sidebar')).toBeInTheDocument();
+ expect(getByTestId('main-content')).toBeInTheDocument();
+ });
+
+ it('shows loading spinner when workspace is loading', async () => {
+ const { useWorkspace } = await import('../../hooks/useWorkspace');
+ vi.mocked(useWorkspace).mockReturnValue({
+ currentWorkspace: mockCurrentWorkspace,
+ workspaces: [],
+ settings: mockCurrentWorkspace,
+ updateSettings: vi.fn(),
+ loading: true,
+ colorScheme: 'light',
+ updateColorScheme: vi.fn(),
+ switchWorkspace: vi.fn(),
+ deleteCurrentWorkspace: vi.fn(),
+ });
+
+ const { getByRole } = render(
+
+
+
+ );
+
+ expect(
+ getByRole('status', { name: 'Loading workspace' })
+ ).toBeInTheDocument();
+ });
+
+ it('shows no workspace message when no current workspace', async () => {
+ const { useWorkspace } = await import('../../hooks/useWorkspace');
+ vi.mocked(useWorkspace).mockReturnValue({
+ currentWorkspace: null,
+ workspaces: [],
+ settings: mockCurrentWorkspace,
+ updateSettings: vi.fn(),
+ loading: false,
+ colorScheme: 'light',
+ updateColorScheme: vi.fn(),
+ switchWorkspace: vi.fn(),
+ deleteCurrentWorkspace: vi.fn(),
+ });
+
+ const { getByText } = render(
+
+
+
+ );
+
+ expect(
+ getByText('No workspace found. Please create a workspace.')
+ ).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/layout/Layout.tsx b/app/src/components/layout/Layout.tsx
index 98e97b8..670dcfc 100644
--- a/app/src/components/layout/Layout.tsx
+++ b/app/src/components/layout/Layout.tsx
@@ -14,7 +14,11 @@ const Layout: React.FC = () => {
if (workspaceLoading) {
return (
-
+
);
diff --git a/app/src/components/layout/MainContent.test.tsx b/app/src/components/layout/MainContent.test.tsx
new file mode 100644
index 0000000..2972091
--- /dev/null
+++ b/app/src/components/layout/MainContent.test.tsx
@@ -0,0 +1,159 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render } from '../../test/utils';
+import MainContent from './MainContent';
+
+// Mock child components
+vi.mock('../editor/ContentView', () => ({
+ default: ({
+ activeTab,
+ selectedFile,
+ }: {
+ activeTab: string;
+ selectedFile: string | null;
+ }) => (
+
+ Content View - {activeTab} - {selectedFile || 'No file'}
+
+ ),
+}));
+
+vi.mock('../modals/file/CreateFileModal', () => ({
+ default: () => Create File Modal
,
+}));
+
+vi.mock('../modals/file/DeleteFileModal', () => ({
+ default: () => Delete File Modal
,
+}));
+
+vi.mock('../modals/git/CommitMessageModal', () => ({
+ default: () => (
+ Commit Message Modal
+ ),
+}));
+
+// Mock hooks
+vi.mock('../../hooks/useFileContent', () => ({
+ useFileContent: vi.fn(),
+}));
+
+vi.mock('../../hooks/useFileOperations', () => ({
+ useFileOperations: vi.fn(),
+}));
+
+vi.mock('../../hooks/useGitOperations', () => ({
+ useGitOperations: vi.fn(),
+}));
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('MainContent', () => {
+ const mockHandleFileSelect = vi.fn();
+ const mockLoadFileList = vi.fn();
+ const mockHandleContentChange = vi.fn();
+ const mockSetHasUnsavedChanges = vi.fn();
+ const mockHandleSave = vi.fn();
+ const mockHandleCreate = vi.fn();
+ const mockHandleDelete = vi.fn();
+ const mockHandleCommitAndPush = vi.fn();
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+
+ const { useFileContent } = await import('../../hooks/useFileContent');
+ vi.mocked(useFileContent).mockReturnValue({
+ content: 'Test content',
+ setContent: vi.fn(),
+ hasUnsavedChanges: false,
+ setHasUnsavedChanges: mockSetHasUnsavedChanges,
+ loadFileContent: vi.fn(),
+ handleContentChange: mockHandleContentChange,
+ });
+
+ const { useFileOperations } = await import('../../hooks/useFileOperations');
+ vi.mocked(useFileOperations).mockReturnValue({
+ handleSave: mockHandleSave,
+ handleCreate: mockHandleCreate,
+ handleDelete: mockHandleDelete,
+ });
+
+ const { useGitOperations } = await import('../../hooks/useGitOperations');
+ vi.mocked(useGitOperations).mockReturnValue({
+ handlePull: vi.fn(),
+ handleCommitAndPush: mockHandleCommitAndPush,
+ });
+ });
+
+ it('shows breadcrumbs for selected file', () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ expect(getByText('docs')).toBeInTheDocument();
+ expect(getByText('guide.md')).toBeInTheDocument();
+ });
+
+ it('shows unsaved changes indicator when file has changes', async () => {
+ const { useFileContent } = await import('../../hooks/useFileContent');
+ vi.mocked(useFileContent).mockReturnValue({
+ content: 'Test content',
+ setContent: vi.fn(),
+ hasUnsavedChanges: true,
+ setHasUnsavedChanges: mockSetHasUnsavedChanges,
+ loadFileContent: vi.fn(),
+ handleContentChange: mockHandleContentChange,
+ });
+
+ const { container } = render(
+
+
+
+ );
+
+ // Should show unsaved changes indicator (yellow dot)
+ const indicator = container.querySelector('svg[style*="yellow"]');
+ expect(indicator).toBeInTheDocument();
+ });
+
+ it('renders all modal components', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ expect(getByTestId('create-file-modal')).toBeInTheDocument();
+ expect(getByTestId('delete-file-modal')).toBeInTheDocument();
+ expect(getByTestId('commit-message-modal')).toBeInTheDocument();
+ });
+
+ it('handles no selected file', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ const contentView = getByTestId('content-view');
+ expect(contentView).toHaveTextContent('Content View - source - No file');
+ });
+});
diff --git a/app/src/components/layout/Sidebar.test.tsx b/app/src/components/layout/Sidebar.test.tsx
new file mode 100644
index 0000000..608b499
--- /dev/null
+++ b/app/src/components/layout/Sidebar.test.tsx
@@ -0,0 +1,181 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render } from '../../test/utils';
+import Sidebar from './Sidebar';
+import { Theme, type FileNode } from '../../types/models';
+
+// Mock the child components
+vi.mock('../files/FileActions', () => ({
+ default: ({ selectedFile }: { selectedFile: string | null }) => (
+
+ File Actions - {selectedFile || 'No file'}
+
+ ),
+}));
+
+vi.mock('../files/FileTree', () => ({
+ default: ({
+ files,
+ showHiddenFiles,
+ }: {
+ files: FileNode[];
+ showHiddenFiles: boolean;
+ }) => (
+
+ File Tree - {files.length} files - Hidden: {showHiddenFiles.toString()}
+
+ ),
+}));
+
+// Mock the hooks
+vi.mock('../../hooks/useGitOperations', () => ({
+ useGitOperations: vi.fn(),
+}));
+
+vi.mock('../../hooks/useWorkspace', () => ({
+ useWorkspace: vi.fn(),
+}));
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+describe('Sidebar', () => {
+ const mockHandleFileSelect = vi.fn();
+ const mockLoadFileList = vi.fn();
+ const mockHandlePull = vi.fn();
+
+ const mockFiles: FileNode[] = [
+ {
+ id: '1',
+ name: 'README.md',
+ path: 'README.md',
+ },
+ {
+ id: '2',
+ name: 'docs',
+ path: 'docs',
+ children: [],
+ },
+ ];
+
+ const mockSettings = {
+ gitEnabled: true,
+ gitAutoCommit: false,
+ theme: Theme.Light,
+ autoSave: true,
+ showHiddenFiles: false,
+ gitUrl: '',
+ gitBranch: 'main',
+ gitUsername: '',
+ gitEmail: '',
+ gitToken: '',
+ gitUser: '',
+ gitCommitMsgTemplate: '',
+ gitCommitName: '',
+ gitCommitEmail: '',
+ };
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+
+ const { useGitOperations } = await import('../../hooks/useGitOperations');
+ vi.mocked(useGitOperations).mockReturnValue({
+ handlePull: mockHandlePull,
+ handleCommitAndPush: vi.fn(),
+ });
+
+ const { useWorkspace } = await import('../../hooks/useWorkspace');
+ vi.mocked(useWorkspace).mockReturnValue({
+ currentWorkspace: null,
+ workspaces: [],
+ settings: mockSettings,
+ updateSettings: vi.fn(),
+ loading: false,
+ colorScheme: 'light',
+ updateColorScheme: vi.fn(),
+ switchWorkspace: vi.fn(),
+ deleteCurrentWorkspace: vi.fn(),
+ });
+ });
+
+ it('renders child components', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ const fileActions = getByTestId('file-actions');
+ expect(fileActions).toBeInTheDocument();
+ expect(fileActions).toHaveTextContent('File Actions - test.md');
+
+ const fileTree = getByTestId('file-tree');
+ expect(fileTree).toBeInTheDocument();
+ expect(fileTree).toHaveTextContent('File Tree - 2 files - Hidden: false');
+ });
+
+ it('passes showHiddenFiles setting to file tree', async () => {
+ const { useWorkspace } = await import('../../hooks/useWorkspace');
+ vi.mocked(useWorkspace).mockReturnValue({
+ currentWorkspace: null,
+ workspaces: [],
+ settings: { ...mockSettings, showHiddenFiles: true },
+ updateSettings: vi.fn(),
+ loading: false,
+ colorScheme: 'light',
+ updateColorScheme: vi.fn(),
+ switchWorkspace: vi.fn(),
+ deleteCurrentWorkspace: vi.fn(),
+ });
+
+ const { getByTestId } = render(
+
+
+
+ );
+
+ const fileTree = getByTestId('file-tree');
+ expect(fileTree).toHaveTextContent('File Tree - 2 files - Hidden: true');
+ });
+
+ it('shows no file selected when selectedFile is null', () => {
+ const { getByTestId } = render(
+
+
+
+ );
+
+ const fileActions = getByTestId('file-actions');
+ expect(fileActions).toHaveTextContent('File Actions - No file');
+ });
+
+ it('calls loadFileList on mount', () => {
+ render(
+
+
+
+ );
+
+ expect(mockLoadFileList).toHaveBeenCalledOnce();
+ });
+});