Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ module.exports = {
ecmaVersion: 12,
sourceType: 'module',
},
plugins: ['@typescript-eslint', 'simple-import-sort', 'jest-dom', 'testing-library'],
plugins: ['@typescript-eslint', 'simple-import-sort', 'jest-dom', 'testing-library', 'eslint-plugin-import'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
Expand All @@ -16,7 +16,8 @@ module.exports = {
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
'plugin:jest-dom/recommended',
'plugin:testing-library/react'
'plugin:testing-library/react',
'plugin:import/typescript',
],
settings: {
react: {
Expand Down Expand Up @@ -133,7 +134,17 @@ module.exports = {
"testing-library/prefer-explicit-assert": "error",
"testing-library/prefer-presence-queries": "error",
"testing-library/prefer-screen-queries": "error",
"testing-library/prefer-wait-for": "error"

"testing-library/prefer-wait-for": "error",
'import/no-restricted-paths': [
'error',
{
zones: [
{
from: './src/atoms/core',
target: './src/!(atoms)/**/*'
}
]
}
]
},
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"@vitest/coverage-c8": "^0.31.4",
"@vitest/ui": "^0.31.4",
"eslint": "^8.41.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jest-dom": "^5.0.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
Expand Down
32 changes: 17 additions & 15 deletions src/TipTapEditor/TipTapEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,23 @@ export function TipTapEditor({ editorRef, editorContent }: EditorProps) {
const setSelection = useSetAtom(selectionAtom);

useEffect(() => {
setEditor(
new Editor({
extensions: EDITOR_EXTENSIONS,
content: editorContent ?? INITIAL_CONTENT,
onSelectionUpdate(update) {
const newEditor = update.editor;
const { from, to } = newEditor.view.state.selection;
const text = newEditor.view.state.doc.textBetween(from, to);
setSelection(text);
},
editorProps: {
transformPasted,
},
}),
);
const newEditor = new Editor({
extensions: EDITOR_EXTENSIONS,
content: editorContent ?? INITIAL_CONTENT,
onSelectionUpdate(update) {
const updatedEditor = update.editor;
const { from, to } = updatedEditor.view.state.selection;
const text = updatedEditor.view.state.doc.textBetween(from, to);
setSelection(text);
},
editorProps: {
transformPasted,
},
});
setEditor(newEditor);
return () => {
newEditor.destroy();
};
}, [editorContent, setSelection]);

useEffect(() => {
Expand Down
6 changes: 6 additions & 0 deletions src/atoms/core/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Core atoms

This directory contains "core" atoms.
These are primitive atoms, along with some useful action atoms to update their value.

Using these atoms directly can lead to invalid states, therefore they should never been used outside of the `atoms` directory of this project.
16 changes: 16 additions & 0 deletions src/atoms/core/activePane.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/* eslint-disable no-underscore-dangle */
import { atom } from 'jotai';

import { PaneId, PaneState } from '../types/PaneGroup';
import { paneGroupAtom } from './paneGroup';

const DEFAULT_PANE: PaneId = 'LEFT';

/** This core atom contains the id of the currently active pane. */
export const activePaneIdAtom = atom<PaneId>(DEFAULT_PANE);

/** Read-only composed atom to get the active pane along with its id. */
export const activePaneAtom = atom<PaneState & { id: PaneId }>((get) => {
const activePaneId = get(activePaneIdAtom);
return { ...get(paneGroupAtom)[activePaneId], id: activePaneId };
});
39 changes: 39 additions & 0 deletions src/atoms/core/fileContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* eslint-disable no-underscore-dangle */
import { Atom, atom } from 'jotai';
import { loadable } from 'jotai/utils';
import { Loadable } from 'jotai/vanilla/utils/loadable';

import { readFileContent } from '../../filesystem';
import { FileContent } from '../types/FileContent';
import { FileFileEntry, FileId } from '../types/FileEntry';

type FileContentState = ReadonlyMap<FileId, Atom<Loadable<FileContent>>>;

/**
* This atom stores the atoms containing the content of open files.
* Each fileContent atom is a loadable atom, asynchronously reading files
*/
export const fileContentAtom = atom<FileContentState>(new Map());

/** Loads file content in memory when opening a file */
export const loadFile = atom(null, (get, set, file: FileFileEntry) => {
const currentOpenFiles = get(fileContentAtom);
const updatedMap = new Map(currentOpenFiles);
const fileAtom = loadable(atom(() => readFileContent(file)));
updatedMap.set(file.path, fileAtom);
set(fileContentAtom, updatedMap);
});

/** Removes the content from memory when closing the file */
export const unloadFile = atom(null, (get, set, fileId: FileId) => {
const currentOpenFiles = get(fileContentAtom);

if (!currentOpenFiles.has(fileId)) {
console.warn('File is not open ', fileId);
return;
}

const updatedMap = new Map(currentOpenFiles);
updatedMap.delete(fileId);
set(fileContentAtom, updatedMap);
});
27 changes: 27 additions & 0 deletions src/atoms/core/fileEntry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/* eslint-disable no-underscore-dangle */
import { atom } from 'jotai';

import { FileFileEntry, FileId } from '../types/FileEntry';

/** This atom contains data about open files */
export const fileEntryAtom = atom<Map<FileId, FileFileEntry>>(new Map());

/** Stores data for a file when opening it */
export const addFileEntry = atom(null, (get, set, file: FileFileEntry) => {
const updatedFileEntries = new Map(get(fileEntryAtom));
updatedFileEntries.set(file.path, file);
set(fileEntryAtom, updatedFileEntries);
});

/** Removes data from memory when closing the file */
export const removeFileEntry = atom(null, (get, set, fileId: FileId) => {
const updatedFileEntries = new Map(get(fileEntryAtom));

if (!updatedFileEntries.has(fileId)) {
console.warn('File is not open ', fileId);
return;
}

updatedFileEntries.delete(fileId);
set(fileEntryAtom, updatedFileEntries);
});
96 changes: 96 additions & 0 deletions src/atoms/core/paneGroup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/* eslint-disable no-underscore-dangle */
import { atom, Getter } from 'jotai';

import { isNonNullish } from '../../lib/isNonNullish';
import { PaneContent, PaneFileId, PaneId, PaneState } from '../types/PaneGroup';
import { activePaneIdAtom } from './activePane';
import { fileContentAtom } from './fileContent';
import { fileEntryAtom } from './fileEntry';

type PaneGroupState = Record<PaneId, PaneState>;

/** This atom contains data about the panes: the list of open files and the active file */
export const paneGroupAtom = atom<PaneGroupState>({
LEFT: {
openFiles: [],
},
RIGHT: {
openFiles: [],
},
});

export function getPane(get: Getter, paneId: PaneId): PaneContent {
const panes = get(paneGroupAtom);
const fileEntries = get(fileEntryAtom);
const openFiles = get(fileContentAtom);
const pane = panes[paneId];
return {
id: paneId,
files: pane.openFiles.map((id) => fileEntries.get(id)).filter(isNonNullish),
activeFile: pane.activeFile ? fileEntries.get(pane.activeFile) : undefined,
activeFileContent: pane.activeFile ? openFiles.get(pane.activeFile) : undefined,
};
}

/**
* Updates a given pane with partial attributes
*/
export const updatePaneGroup = atom(
null,
(get, set, { paneId, ...update }: { paneId: PaneId } & Partial<PaneState>) => {
const panes = get(paneGroupAtom);
set(paneGroupAtom, {
...panes,
[paneId]: {
...panes[paneId],
...update,
},
});
},
);

/**
* Adds a file to the list of open files of the given pane
* Please note that this atom does not check that file exists or is loaded in memory
* */
export const addFileToPane = atom(null, (get, set, { fileId, paneId }: PaneFileId) => {
const panes = get(paneGroupAtom);
set(updatePaneGroup, {
paneId,
openFiles: panes[paneId].openFiles.includes(fileId)
? panes[paneId].openFiles // File was already open
: [...panes[paneId].openFiles, fileId], // Add file to the list of open files
});
});

/** Removes a file from the list of open files */
export const removeFileFromPane = atom(null, (get, set, { fileId, paneId }: PaneFileId) => {
const panes = get(paneGroupAtom);

if (!panes[paneId].openFiles.includes(fileId)) {
console.warn('File is not open in the given pane ', fileId, paneId);
return;
}

const updatedOpenFiles = panes[paneId].openFiles.filter((_fileId) => _fileId !== fileId);
set(updatePaneGroup, {
paneId,
openFiles: updatedOpenFiles,
});
});

/** Updates the active file of the pane */
export const selectFileInPaneAtom = atom(null, (get, set, { fileId, paneId }: PaneFileId) => {
const panes = get(paneGroupAtom);

if (!panes[paneId].openFiles.includes(fileId)) {
console.warn('File not open in the given pane ', fileId, paneId);
return;
}

set(activePaneIdAtom, paneId);
set(updatePaneGroup, {
paneId,
activeFile: fileId,
});
});
79 changes: 79 additions & 0 deletions src/atoms/fileActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { atom } from 'jotai';

import { activePaneAtom, activePaneIdAtom } from './core/activePane';
import { fileContentAtom, loadFile, unloadFile } from './core/fileContent';
import { addFileEntry, removeFileEntry } from './core/fileEntry';
import { addFileToPane, getPane, paneGroupAtom, removeFileFromPane, selectFileInPaneAtom } from './core/paneGroup';
import { FileEntry, FileId } from './types/FileEntry';
import { PaneFileId, PaneId } from './types/PaneGroup';

export { selectFileInPaneAtom } from './core/paneGroup';

/** Open a file in the active pane */
export const openFileAtom = atom(null, (get, set, file: FileEntry) => {
if (file.isFolder) {
console.warn('Cannot open directory ', file.path);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary for this PR, but user-visible error handling is something we should get a story in place for sooner rather than later. (From a user perspective, this will silently fail and no one will see the console.warn.)

return;
}
// Load file in memory
const currentOpenFiles = get(fileContentAtom);
if (!currentOpenFiles.has(file.path)) {
set(loadFile, file);
}

// Add to file entries atom
set(addFileEntry, file);

// Add file to panes state
const activePane = get(activePaneAtom);
const fileId = file.path;
if (!activePane.openFiles.includes(fileId)) {
set(addFileToPane, { fileId, paneId: activePane.id });
}

// Select file in pane
set(selectFileInPaneAtom, { fileId, paneId: activePane.id });
});

/** Removes file from the given pane and unload content from memory if the file is not open in another pane */
export const closeFileFromPaneAtom = atom(null, (get, set, { fileId, paneId }: PaneFileId) => {
const panes = get(paneGroupAtom);

set(removeFileFromPane, { fileId, paneId });

// Unload file from memory if the file is no longer open anywhere
if (
Object.entries(panes)
.filter(([_paneId]) => _paneId !== paneId) // Keep only other panes
.every(([, pane]) => !pane.openFiles.includes(fileId)) // Check that the file was not open in any other pane
) {
set(removeFileEntry, fileId);
set(unloadFile, fileId);
}

// Select another file in the pane, if there are any
const updatedPanes = get(paneGroupAtom);
const updatedOpenFiles = updatedPanes[paneId].openFiles;
if (updatedOpenFiles.length > 0) {
set(selectFileInPaneAtom, { fileId: updatedOpenFiles[updatedOpenFiles.length - 1], paneId });
}
});

export const leftPaneAtom = atom((get) => getPane(get, 'LEFT'));
export const rightPaneAtom = atom((get) => getPane(get, 'RIGHT'));

interface SplitFilePayload {
fileId: FileId;
fromPaneId: PaneId;
toPaneId: PaneId;
}

export const splitFileToPaneAtom = atom(null, (_get, set, { fileId, fromPaneId, toPaneId }: SplitFilePayload) => {
set(removeFileFromPane, { paneId: fromPaneId, fileId });
set(addFileToPane, { paneId: toPaneId, fileId });
set(selectFileInPaneAtom, { paneId: toPaneId, fileId });
});

export const focusPaneAtom = atom(null, (_get, set, paneId: PaneId) => {
set(activePaneIdAtom, paneId);
});
Loading