Skip to content

Commit 1c7e7dc

Browse files
committed
feat(main): add layout service
1 parent a846058 commit 1c7e7dc

9 files changed

+429
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { Mocked } from 'vitest';
2+
import { vi } from 'vitest';
3+
import type { LayoutService } from '../types.js';
4+
5+
export class LayoutServiceMockImpl implements Mocked<LayoutService> {
6+
constructorSpy = vi.fn();
7+
8+
constructor(...args: Array<any>) {
9+
this.constructorSpy(args);
10+
}
11+
12+
get = vi.fn<LayoutService['get']>();
13+
list = vi.fn<LayoutService['list']>();
14+
save = vi.fn<LayoutService['save']>();
15+
delete = vi.fn<LayoutService['delete']>();
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "../../tsconfig.test.json"
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { StoreServiceMockImpl } from '../../store/__mocks__/store-service.mock.js';
3+
import { LayoutServiceImpl } from '../layout.service.js';
4+
5+
vi.mock('../../store/store.instance.ts', () => {
6+
return { Store: new StoreServiceMockImpl() };
7+
});
8+
9+
vi.mock('../../logger/logger.factory.ts');
10+
11+
describe('layout-instance', () => {
12+
afterEach(() => {
13+
vi.clearAllMocks();
14+
vi.clearAllTimers();
15+
});
16+
17+
it('is a layout service', async () => {
18+
const Layouts = (await import('../layout.instance.js')).Layouts;
19+
expect(Layouts).toBeInstanceOf(LayoutServiceImpl);
20+
});
21+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { LayoutServiceImpl } from '../layout.service.js';
3+
import type { Layout, LayoutService } from '../types.js';
4+
5+
type FsExtraModule = typeof import('fs-extra');
6+
type ElectronModule = typeof import('electron');
7+
8+
const { mockFsExtra } = await vi.hoisted(async () => {
9+
const mockFsExtra = {
10+
pathExists: vi.fn<FsExtraModule['pathExists']>(),
11+
remove: vi.fn<FsExtraModule['remove']>(),
12+
writeJson: vi.fn<FsExtraModule['writeJson']>(),
13+
readJson: vi.fn<FsExtraModule['readJson']>(),
14+
readdir: vi.fn<FsExtraModule['readdir']>(),
15+
ensureFile: vi.fn<FsExtraModule['ensureFile']>(),
16+
};
17+
18+
return {
19+
mockFsExtra,
20+
};
21+
});
22+
23+
vi.mock('fs-extra', async () => {
24+
return {
25+
default: mockFsExtra,
26+
};
27+
});
28+
29+
vi.mock('electron', async (importOriginal) => {
30+
const actualModule = await importOriginal<ElectronModule>();
31+
return {
32+
...actualModule,
33+
app: {
34+
...actualModule.app,
35+
getPath: vi.fn(() => 'test-path'),
36+
},
37+
};
38+
});
39+
40+
vi.mock('../../logger/logger.factory.ts');
41+
42+
describe('layout-service', () => {
43+
const mockLayout: Layout = {
44+
window: {
45+
height: 100,
46+
width: 100,
47+
x: 100,
48+
y: 100,
49+
},
50+
streams: [],
51+
};
52+
53+
let layoutService: LayoutService;
54+
55+
beforeEach(() => {
56+
layoutService = new LayoutServiceImpl();
57+
});
58+
59+
afterEach(() => {
60+
vi.clearAllMocks();
61+
vi.clearAllTimers();
62+
});
63+
64+
describe('#get', () => {
65+
it('returns undefined when file does not exist', async () => {
66+
const layout = await layoutService.get('layout-name');
67+
68+
expect(mockFsExtra.pathExists).toBeCalledWith(
69+
'test-path/layouts/layout-name.json'
70+
);
71+
72+
expect(layout).toBeUndefined();
73+
});
74+
75+
it('returns layout json when file exists', async () => {
76+
mockFsExtra.pathExists = vi.fn().mockResolvedValue(true);
77+
mockFsExtra.readJson = vi.fn().mockResolvedValue(mockLayout);
78+
79+
const layout = await layoutService.get('layout-name');
80+
81+
expect(mockFsExtra.pathExists).toBeCalledWith(
82+
'test-path/layouts/layout-name.json'
83+
);
84+
85+
expect(layout).toEqual(mockLayout);
86+
});
87+
});
88+
89+
describe('#list', () => {
90+
it('returns empty array when files not exist', async () => {
91+
mockFsExtra.readdir = vi.fn().mockResolvedValue([]);
92+
93+
const layouts = await layoutService.list();
94+
95+
expect(mockFsExtra.readdir).toBeCalledWith('test-path/layouts');
96+
97+
expect(layouts).toEqual([]);
98+
});
99+
100+
it('returns layout names when files exist', async () => {
101+
mockFsExtra.readdir = vi
102+
.fn()
103+
.mockResolvedValue([
104+
'test-layout-1.json',
105+
'test-layout-2.json',
106+
'not-a-json-file.txt',
107+
]);
108+
109+
const layouts = await layoutService.list();
110+
111+
expect(mockFsExtra.readdir).toBeCalledWith('test-path/layouts');
112+
113+
expect(layouts).toEqual(
114+
expect.arrayContaining(['test-layout-1', 'test-layout-2'])
115+
);
116+
});
117+
});
118+
119+
describe('#save', () => {
120+
it('writes layout json to file', async () => {
121+
await layoutService.save({
122+
name: 'layout-name',
123+
layout: mockLayout,
124+
});
125+
126+
expect(mockFsExtra.ensureFile).toBeCalledWith(
127+
'test-path/layouts/layout-name.json'
128+
);
129+
130+
expect(mockFsExtra.writeJson).toBeCalledWith(
131+
'test-path/layouts/layout-name.json',
132+
mockLayout,
133+
{ spaces: 2 }
134+
);
135+
});
136+
});
137+
138+
describe('#delete', () => {
139+
it('does nothing if layout file does not exist', async () => {
140+
mockFsExtra.pathExists = vi.fn().mockResolvedValue(false);
141+
142+
await layoutService.delete('layout-name');
143+
144+
expect(mockFsExtra.pathExists).toBeCalledWith(
145+
'test-path/layouts/layout-name.json'
146+
);
147+
148+
expect(mockFsExtra.remove).not.toBeCalled();
149+
});
150+
151+
it('deletes layout file if exists', async () => {
152+
mockFsExtra.pathExists = vi.fn().mockResolvedValue(true);
153+
154+
await layoutService.delete('layout-name');
155+
156+
expect(mockFsExtra.pathExists).toBeCalledWith(
157+
'test-path/layouts/layout-name.json'
158+
);
159+
160+
expect(mockFsExtra.remove).toBeCalledWith(
161+
'test-path/layouts/layout-name.json'
162+
);
163+
});
164+
});
165+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "../../tsconfig.test.json"
3+
}
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { LayoutServiceImpl } from './layout.service.js';
2+
3+
// There is exactly one layout instance so that it's
4+
// easy anywhere in the app to manage layouts.
5+
export const Layouts = new LayoutServiceImpl();
+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { app } from 'electron';
2+
import path from 'node:path';
3+
import fs from 'fs-extra';
4+
import type { Maybe } from '../../common/types.js';
5+
import { logger } from './logger.js';
6+
import type { Layout, LayoutService } from './types.js';
7+
8+
export class LayoutServiceImpl implements LayoutService {
9+
public async get(name: string): Promise<Maybe<Layout>> {
10+
const filePath = this.getLayoutPath(name);
11+
const fileExists = await fs.pathExists(filePath);
12+
13+
logger.info('getting layout', {
14+
name,
15+
filePath,
16+
fileExists,
17+
});
18+
19+
if (!fileExists) {
20+
return;
21+
}
22+
23+
const layout = await fs.readJson(filePath);
24+
25+
logger.debug('got layout', {
26+
layout,
27+
});
28+
29+
return layout;
30+
}
31+
32+
public async list(): Promise<Array<string>> {
33+
const fileNames = await fs.readdir(this.getLayoutsBaseDir());
34+
35+
const layoutNames = fileNames
36+
.filter((fileName) => path.extname(fileName) === '.json')
37+
.map((fileName) => path.basename(fileName, '.json'))
38+
.sort();
39+
40+
return layoutNames;
41+
}
42+
43+
public async save(options: { name: string; layout: Layout }): Promise<void> {
44+
const { name, layout } = options;
45+
46+
const filePath = this.getLayoutPath(name);
47+
48+
logger.info('saving layout', {
49+
name,
50+
filePath,
51+
});
52+
53+
await fs.ensureFile(filePath);
54+
55+
await fs.writeJson(filePath, layout, { spaces: 2 });
56+
57+
logger.debug('saved layout', {
58+
layout,
59+
});
60+
}
61+
62+
public async delete(name: string): Promise<void> {
63+
const filePath = this.getLayoutPath(name);
64+
const fileExists = await fs.pathExists(filePath);
65+
66+
logger.info('deleting layout', {
67+
name,
68+
filePath,
69+
fileExists,
70+
});
71+
72+
if (!fileExists) {
73+
return;
74+
}
75+
76+
await fs.remove(filePath);
77+
}
78+
79+
protected getLayoutPath(name: string): string {
80+
return path.join(this.getLayoutsBaseDir(), `${name}.json`);
81+
}
82+
83+
protected getLayoutsBaseDir(): string {
84+
return path.join(app.getPath('userData'), 'layouts');
85+
}
86+
}

electron/main/layout/logger.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { getScopedLogger } from '../logger/logger.factory.js';
2+
3+
const logger = getScopedLogger('main:layout');
4+
5+
export { logger };

0 commit comments

Comments
 (0)