Skip to content

Commit 5bc3dea

Browse files
authored
feat: save files as markdown (#341)
* feat: implement markdown serializer * Add menu item to save file as markdown * feat: implement custom bullet list * fix: run prettier * fix: updated test files * feat: serialize references along with document * fix: update tests * feat: add tests for serializers * fix: add missing whitespaces * chore: delay exports directory creation * fix: address review comments * Merge remote-tracking branch 'origin/main' into 214-export-files-as-markdown * fix: remove duplicated key
1 parent e4bcd3e commit 5bc3dea

35 files changed

Lines changed: 1045 additions & 143 deletions

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"tailwindcss": "^3.3.2",
7272
"tauri-settings": "^0.3.2",
7373
"tiptap-markdown": "^0.7.2",
74+
"turndown": "^7.1.2",
7475
"usehooks-ts": "^2.9.1"
7576
},
7677
"devDependencies": {
@@ -84,6 +85,7 @@
8485
"@types/react": "^18.0.15",
8586
"@types/react-dom": "^18.0.6",
8687
"@types/react-pdf": "^6.2.0",
88+
"@types/turndown": "^5.0.1",
8789
"@typescript-eslint/eslint-plugin": "^5.59.6",
8890
"@typescript-eslint/parser": "^5.59.6",
8991
"@vitejs/plugin-react": "^3.0.0",

src-tauri/src/core/menu.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const MENU_REFERENCES_OPEN: &str = /* */ "refstudio://menu/references/open";
77
const MENU_REFERENCES_UPLOAD: &str = /* */ "refstudio://menu/references/upload";
88
const MENU_FILE_SAVE: &str = /* */ "refstudio://menu/file/save";
99
const MENU_FILE_NEW: &str = /* */ "refstudio://menu/file/new";
10+
const MENU_FILE_SAVE_AS_MD: &str = /* */ "refstudio://menu/file/markdown";
1011
const MENU_FILE_CLOSE: &str = /* */ "refstudio://menu/file/close";
1112
const MENU_FILE_PROJECT_NEW: &str = /* */ "refstudio://menu/file/project/new";
1213
const MENU_FILE_PROJECT_OPEN: &str = /* */ "refstudio://menu/file/project/open";
@@ -45,8 +46,11 @@ impl AppMenu {
4546
.add_item(
4647
CustomMenuItem::new(MENU_FILE_NEW, "New File").accelerator("cmdOrControl+N"),
4748
)
48-
.add_native_item(MenuItem::Separator)
4949
.add_item(CustomMenuItem::new(MENU_FILE_SAVE, "Save").accelerator("cmdOrControl+S"))
50+
.add_item(CustomMenuItem::new(
51+
MENU_FILE_SAVE_AS_MD,
52+
"Save File as Markdown...",
53+
))
5054
.add_native_item(MenuItem::Separator)
5155
.add_item(CustomMenuItem::new(MENU_FILE_PROJECT_NEW, "New Project"))
5256
.add_item(CustomMenuItem::new(MENU_FILE_PROJECT_OPEN, "Open Project"))

src-tauri/tauri.conf.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,7 @@
5050
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/[email protected]", "icons/icon.icns", "icons/icon.ico"],
5151
"externalBin": [],
5252
"identifier": "studio.ref.desktop",
53-
"resources": [
54-
"bin/*"
55-
],
53+
"resources": ["bin/*"],
5654
"targets": "all"
5755
},
5856
"security": {

src/application/components/MainPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ function MainPaneViewContent({ activeEditorAtoms }: MainPaneViewContentProps) {
9090
const { data } = loadableEditorContent;
9191

9292
switch (data.type) {
93-
case 'xml':
93+
case 'text':
9494
return <TextView file={data} />;
9595
case 'json':
9696
return <TextView file={data} textFormatter={(input) => JSON.stringify(JSON.parse(input), null, 2)} />;

src/application/views/TextView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { JsonEditorContent, XmlEditorContent } from '../../atoms/types/EditorContent';
1+
import { JsonEditorContent, RawTextEditorContent } from '../../atoms/types/EditorContent';
22

33
export function TextView({
44
file,
55
textFormatter = (text) => text,
66
}: {
7-
file: XmlEditorContent | JsonEditorContent;
7+
file: RawTextEditorContent | JsonEditorContent;
88
textFormatter?: (input: string) => string;
99
}) {
1010
return (

src/atoms/__tests__/editorActions.test.ts

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,10 @@ describe('editorActions', () => {
5959
expect(rightPaneActiveEditorId.current).toBeNull();
6060
});
6161

62-
it('should open TXT file in the left pane, and be active', () => {
62+
it('should open REFSTUDIO file in the left pane, and be active', () => {
6363
const openFile = runSetAtomHook(openFileEntryAtom, store);
6464

65-
const { fileEntry: fileToOpen, editorData } = makeFileAndEditor('file.txt');
65+
const { fileEntry: fileToOpen, editorData } = makeFileAndEditor('file.refstudio');
6666
expect(leftPaneOpenEditorsCount.current).toBe(0);
6767
expect(leftPaneActiveEditorId.current).toBeNull();
6868

@@ -74,10 +74,38 @@ describe('editorActions', () => {
7474
expect(leftPaneActiveEditorId.current).toBe(editorData.id);
7575
});
7676

77-
it('should open TXT filePath in the left pane, and be active', () => {
77+
it('should open REFSTUDIO filePath in the left pane, and be active', () => {
7878
const openFile = runSetAtomHook(openFilePathAtom, store);
7979

80-
const filePath = '/some/absolute/path/file.txt';
80+
const filePath = '/some/absolute/path/file.refstudio';
81+
act(() => openFile.current(filePath));
82+
83+
expect(store.get(activePaneIdAtom)).toBe<PaneId>('LEFT');
84+
expect(leftPaneOpenEditorsCount.current).toBe(1);
85+
86+
const editorId = buildEditorId('refstudio', filePath);
87+
expect(leftPaneActiveEditorId.current).toBe(editorId);
88+
});
89+
90+
it('should open file with no extension in the left pane, and be active', () => {
91+
const openFile = runSetAtomHook(openFileEntryAtom, store);
92+
93+
const { fileEntry: fileToOpen, editorData } = makeFileAndEditor('file');
94+
expect(leftPaneOpenEditorsCount.current).toBe(0);
95+
expect(leftPaneActiveEditorId.current).toBeNull();
96+
97+
act(() => openFile.current(fileToOpen));
98+
99+
expect(store.get(activePaneIdAtom)).toBe<PaneId>('LEFT');
100+
expect(leftPaneOpenEditorsCount.current).toBe(1);
101+
expect(leftPaneOpenEditorsData.current[0]).toStrictEqual(editorData);
102+
expect(leftPaneActiveEditorId.current).toBe(editorData.id);
103+
});
104+
105+
it('should open filePath with no extension in the left pane, and be active', () => {
106+
const openFile = runSetAtomHook(openFilePathAtom, store);
107+
108+
const filePath = '/some/absolute/path/file';
81109
act(() => openFile.current(filePath));
82110

83111
expect(store.get(activePaneIdAtom)).toBe<PaneId>('LEFT');
@@ -105,7 +133,7 @@ describe('editorActions', () => {
105133
it('should open file once in same panel', () => {
106134
const openFile = runSetAtomHook(openFileEntryAtom, store);
107135

108-
const { fileEntry: fileToOpen } = makeFileAndEditor('file.txt');
136+
const { fileEntry: fileToOpen } = makeFileAndEditor('file.refstudio');
109137
expect(leftPaneOpenEditorsCount.current).toBe(0);
110138

111139
act(() => {
@@ -149,8 +177,8 @@ describe('editorActions', () => {
149177
const openFile = runSetAtomHook(openFileEntryAtom, store);
150178
const closeFileFromPane = runSetAtomHook(closeEditorFromPaneAtom, store);
151179

152-
const { fileEntry: fileA, editorData: editorDataA } = makeFileAndEditor('fileA.txt');
153-
const { fileEntry: fileB } = makeFileAndEditor('fileB.txt');
180+
const { fileEntry: fileA, editorData: editorDataA } = makeFileAndEditor('fileA.refstudio');
181+
const { fileEntry: fileB } = makeFileAndEditor('fileB.refstudio');
154182

155183
act(() => {
156184
openFile.current(fileA);
@@ -169,7 +197,7 @@ describe('editorActions', () => {
169197
const openFile = runSetAtomHook(openFileEntryAtom, store);
170198
const closeEditorFromAllPanes = runSetAtomHook(closeEditorFromAllPanesAtom, store);
171199

172-
const { fileEntry: fileA, editorData: editorDataA } = makeFileAndEditor('fileA.txt');
200+
const { fileEntry: fileA, editorData: editorDataA } = makeFileAndEditor('fileA.refstudio');
173201
const { fileEntry: fileB, editorData: editorDataB } = makeFileAndEditor('fileB.pdf');
174202

175203
act(() => {
@@ -192,8 +220,8 @@ describe('editorActions', () => {
192220
const openFile = runSetAtomHook(openFileEntryAtom, store);
193221
const closeFileFromPane = runSetAtomHook(closeEditorFromPaneAtom, store);
194222

195-
const { fileEntry: fileOpened, editorData: openedEditorData } = makeFileAndEditor('fileA.txt');
196-
const { editorData: notOpenedEditorData } = makeFileAndEditor('fileB.txt');
223+
const { fileEntry: fileOpened, editorData: openedEditorData } = makeFileAndEditor('fileA.refstudio');
224+
const { editorData: notOpenedEditorData } = makeFileAndEditor('fileB.refstudio');
197225

198226
act(() => {
199227
openFile.current(fileOpened);
@@ -213,7 +241,7 @@ describe('editorActions', () => {
213241
it('should not fail trying to select file that is not opened in pane', () => {
214242
const selectFileInPane = runSetAtomHook(selectEditorInPaneAtom, store);
215243

216-
const { editorData } = makeFileAndEditor('fileA.txt');
244+
const { editorData } = makeFileAndEditor('fileA.refstudio');
217245

218246
act(() => {
219247
selectFileInPane.current({ paneId: 'LEFT', editorId: editorData.id });
@@ -251,7 +279,7 @@ describe('editorActions', () => {
251279
const openFile = runSetAtomHook(openFileEntryAtom, store);
252280
const closeAllFiles = runSetAtomHook(closeAllEditorsAtom, store);
253281

254-
const { fileEntry: fileA, editorData: fileAData } = makeFileAndEditor('file.txt');
282+
const { fileEntry: fileA, editorData: fileAData } = makeFileAndEditor('file.refstudio');
255283
const { fileEntry: fileB, editorData: fileBData } = makeFileAndEditor('file.pdf');
256284

257285
act(() => {
@@ -273,7 +301,7 @@ describe('editorActions', () => {
273301
const openFile = runSetAtomHook(openFileEntryAtom, store);
274302
const moveEditorToPane = runSetAtomHook(moveEditorToPaneAtom, store);
275303

276-
const { fileEntry, editorData } = makeFileAndEditor('file.txt');
304+
const { fileEntry, editorData } = makeFileAndEditor('file.refstudio');
277305

278306
act(() => {
279307
openFile.current(fileEntry);
@@ -294,7 +322,7 @@ describe('editorActions', () => {
294322
const openFile = runSetAtomHook(openFileEntryAtom, store);
295323
const moveEditorToPane = runSetAtomHook(moveEditorToPaneAtom, store);
296324

297-
const { fileEntry: fileA, editorData } = makeFileAndEditor('file.txt');
325+
const { fileEntry: fileA, editorData } = makeFileAndEditor('file.refstudio');
298326

299327
act(() => {
300328
openFile.current(fileA);
@@ -315,8 +343,8 @@ describe('editorActions', () => {
315343
const openFile = runSetAtomHook(openFileEntryAtom, store);
316344
const moveEditorToPane = runSetAtomHook(moveEditorToPaneAtom, store);
317345

318-
const { fileEntry: fileA, editorData: fileAData } = makeFileAndEditor('file1.txt');
319-
const { fileEntry: fileB, editorData: fileBData } = makeFileAndEditor('file2.txt');
346+
const { fileEntry: fileA, editorData: fileAData } = makeFileAndEditor('file1.refstudio');
347+
const { fileEntry: fileB, editorData: fileBData } = makeFileAndEditor('file2.refstudio');
320348

321349
act(() => {
322350
openFile.current(fileA);
@@ -342,7 +370,7 @@ describe('editorActions', () => {
342370
const openFile = runSetAtomHook(openFileEntryAtom, store);
343371
const moveEditorToPane = runSetAtomHook(moveEditorToPaneAtom, store);
344372

345-
const { fileEntry: fileA, editorData } = makeFileAndEditor('file.txt');
373+
const { fileEntry: fileA, editorData } = makeFileAndEditor('file.refstudio');
346374

347375
act(() => {
348376
openFile.current(fileA);
@@ -363,9 +391,9 @@ describe('editorActions', () => {
363391
const openFile = runSetAtomHook(openFileEntryAtom, store);
364392
const closeFileFromPane = runSetAtomHook(closeEditorFromPaneAtom, store);
365393

366-
const { fileEntry: fileA } = makeFileAndEditor('fileA.txt');
367-
const { fileEntry: fileB, editorData: editorDataB } = makeFileAndEditor('fileB.txt');
368-
const { fileEntry: fileC, editorData: editorDataC } = makeFileAndEditor('fileC.txt');
394+
const { fileEntry: fileA } = makeFileAndEditor('fileA.refstudio');
395+
const { fileEntry: fileB, editorData: editorDataB } = makeFileAndEditor('fileB.refstudio');
396+
const { fileEntry: fileC, editorData: editorDataC } = makeFileAndEditor('fileC.refstudio');
369397

370398
act(() => {
371399
openFile.current(fileA);
@@ -386,9 +414,9 @@ describe('editorActions', () => {
386414
const openFile = runSetAtomHook(openFileEntryAtom, store);
387415
const closeFileFromPane = runSetAtomHook(closeEditorFromPaneAtom, store);
388416

389-
const { fileEntry: fileA } = makeFileAndEditor('fileA.txt');
390-
const { fileEntry: fileB, editorData: editorDataB } = makeFileAndEditor('fileB.txt');
391-
const { fileEntry: fileC, editorData: editorDataC } = makeFileAndEditor('fileC.txt');
417+
const { fileEntry: fileA } = makeFileAndEditor('fileA.refstudio');
418+
const { fileEntry: fileB, editorData: editorDataB } = makeFileAndEditor('fileB.refstudio');
419+
const { fileEntry: fileC, editorData: editorDataC } = makeFileAndEditor('fileC.refstudio');
392420

393421
act(() => {
394422
openFile.current(fileA);
@@ -409,8 +437,8 @@ describe('editorActions', () => {
409437
const openFile = runSetAtomHook(openFileEntryAtom, store);
410438
const closeFileFromPane = runSetAtomHook(closeEditorFromPaneAtom, store);
411439

412-
const { fileEntry: fileA, editorData: editorDataA } = makeFileAndEditor('fileA.txt');
413-
const { fileEntry: fileB, editorData: editorDataB } = makeFileAndEditor('fileB.txt');
440+
const { fileEntry: fileA, editorData: editorDataA } = makeFileAndEditor('fileA.refstudio');
441+
const { fileEntry: fileB, editorData: editorDataB } = makeFileAndEditor('fileB.refstudio');
414442

415443
act(() => {
416444
openFile.current(fileA);
@@ -447,7 +475,7 @@ describe('editorActions', () => {
447475
jsonContent: { doc: 'some content' },
448476
});
449477

450-
const { fileEntry: fileA } = makeFileAndEditor('fileA.txt');
478+
const { fileEntry: fileA } = makeFileAndEditor('fileA.refstudio');
451479

452480
act(() => {
453481
openFile.current(fileA);

src/atoms/__tests__/fileEntryActions.test.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ describe('fileEntryActions', () => {
5858
});
5959

6060
it('should delete the file', async () => {
61-
const fileEntry = makeFile('File 1.txt');
61+
const fileEntry = makeFile('File 1.refstudio');
6262
vi.mocked(readAllProjectFiles).mockResolvedValueOnce([fileEntry]);
6363
await store.set(refreshFileTreeAtom);
6464

@@ -72,7 +72,7 @@ describe('fileEntryActions', () => {
7272
});
7373

7474
it('should close any open editor corresponding to the deleted file', async () => {
75-
const { fileEntry, editorData } = makeFileAndEditor('File 1.txt');
75+
const { fileEntry, editorData } = makeFileAndEditor('File 1.refstudio');
7676
vi.mocked(readAllProjectFiles).mockResolvedValue([fileEntry]);
7777
await store.set(refreshFileTreeAtom);
7878
store.set(openFileEntryAtom, fileEntry);
@@ -106,13 +106,13 @@ describe('fileEntryActions', () => {
106106
});
107107

108108
it('should rename the file', async () => {
109-
const fileEntry = makeFile('File.txt');
109+
const fileEntry = makeFile('File.refstudio');
110110
vi.mocked(readAllProjectFiles).mockResolvedValue([fileEntry]);
111111
await store.set(refreshFileTreeAtom);
112112

113113
vi.mocked(renameFileFromDisk).mockResolvedValueOnce({ success: true, newPath: '' });
114114

115-
const newName = 'Updated File.txt';
115+
const newName = 'Updated File.refstudio';
116116

117117
const renameFile = runSetAtomHook(renameFileAtom, store);
118118
await act(async () => {
@@ -124,7 +124,7 @@ describe('fileEntryActions', () => {
124124
});
125125

126126
it('should update editor data when renaming file', async () => {
127-
const fileEntry = makeFile('File.txt');
127+
const fileEntry = makeFile('File.refstudio');
128128
vi.mocked(readAllProjectFiles).mockResolvedValue([fileEntry]);
129129
await store.set(refreshFileTreeAtom);
130130

@@ -136,8 +136,8 @@ describe('fileEntryActions', () => {
136136
expect(leftPaneOpenEditorsData.current[0].title).toBe(fileEntry.name);
137137
expect(leftPaneOpenEditorsData.current[0].isDirty).toBeFalsy();
138138

139-
const newName = 'Updated File.txt';
140-
const newPath = '/new/path/to/file.txt';
139+
const newName = 'Updated File.refstudio';
140+
const newPath = '/new/path/to/file.refstudio';
141141
vi.mocked(renameFileFromDisk).mockResolvedValueOnce({ success: true, newPath });
142142

143143
const renameFile = runSetAtomHook(renameFileAtom, store);
@@ -151,7 +151,7 @@ describe('fileEntryActions', () => {
151151
});
152152

153153
it('should update editor content when renaming file', async () => {
154-
const fileEntry = makeFile('File.txt');
154+
const fileEntry = makeFile('File.refstudio');
155155
vi.mocked(readAllProjectFiles).mockResolvedValue([fileEntry]);
156156
await store.set(refreshFileTreeAtom);
157157

@@ -180,8 +180,8 @@ describe('fileEntryActions', () => {
180180

181181
vi.mocked(writeFileContent).mockClear();
182182

183-
const newName = 'Updated File.txt';
184-
const newPath = '/new/path/to/file.txt';
183+
const newName = 'Updated File.refstudio';
184+
const newPath = '/new/path/to/file.refstudio';
185185
vi.mocked(renameFileFromDisk).mockResolvedValueOnce({ success: true, newPath });
186186

187187
const renameFile = runSetAtomHook(renameFileAtom, store);
@@ -220,15 +220,15 @@ describe('fileEntryActions', () => {
220220
});
221221

222222
it('should do nothing if renaming fails', async () => {
223-
const fileEntry = makeFile('File.txt');
223+
const fileEntry = makeFile('File.refstudio');
224224
vi.mocked(readAllProjectFiles).mockResolvedValue([fileEntry]);
225225
await store.set(refreshFileTreeAtom);
226226

227227
store.set(openFileEntryAtom, fileEntry);
228228

229229
vi.mocked(renameFileFromDisk).mockResolvedValueOnce({ success: false });
230230

231-
const newName = 'Updated File.txt';
231+
const newName = 'Updated File.refstudio';
232232

233233
const leftPaneOpenEditorsData = runHookWithJotaiProvider(() => useOpenEditorsDataForPane('LEFT'), store);
234234
const leftPaneActiveEditorId = runHookWithJotaiProvider(() => useActiveEditorIdForPane('LEFT'), store);

src/atoms/__tests__/paneActions.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ describe('paneActions', () => {
2525
let fileContentAtoms: EditorContentAtoms;
2626

2727
beforeEach(() => {
28-
const file = makeFileAndEditor('file.txt');
28+
const file = makeFileAndEditor('file.refstudio');
2929
fileEntry = file.fileEntry;
3030
editorData = file.editorData;
3131

@@ -66,7 +66,7 @@ describe('paneActions', () => {
6666
const initialPaneId = activePane.current.id;
6767
const otherPaneId: PaneId = initialPaneId === 'LEFT' ? 'RIGHT' : 'LEFT';
6868

69-
const { fileEntry: fileEntry2, editorData: editorData2 } = makeFileAndEditor('File2.txt');
69+
const { fileEntry: fileEntry2, editorData: editorData2 } = makeFileAndEditor('File2.refstudio');
7070

7171
act(() => {
7272
openFileEntry.current(fileEntry2);

src/atoms/__tests__/test-fixtures.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import { FileExplorerEntry, FileExplorerFileEntry, FileExplorerFolderEntry } fro
77

88
export function makeFileAndEditor(name: string, parentPath = ''): { fileEntry: FileFileEntry; editorData: EditorData } {
99
const filePath = `${parentPath}/${name}`;
10+
const nameParts = name.split('.');
1011
return {
1112
fileEntry: {
1213
name,
1314
path: filePath,
14-
fileExtension: name.split('.').pop() ?? '',
15+
fileExtension: nameParts.length > 1 ? nameParts[nameParts.length - 1].toLowerCase() : '',
1516
isFolder: false,
1617
isDotfile: name.startsWith('.'),
1718
isFile: true,

0 commit comments

Comments
 (0)