Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add context menu to file tabs with close actions (#162)
Browse files Browse the repository at this point in the history
rahulyadav-57 authored Jan 14, 2025
1 parent 3ca0339 commit c847988
Showing 8 changed files with 305 additions and 141 deletions.
18 changes: 18 additions & 0 deletions src/components/ui/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { MenuProps } from 'antd';
import { Dropdown } from 'antd';
import { FC } from 'react';

interface Props {
menu?: MenuProps;
children: React.ReactNode;
}

const ContextMenu: FC<Props> = ({ menu, children }) => {
return (
<Dropdown menu={menu} trigger={['contextMenu']}>
{children}
</Dropdown>
);
};

export default ContextMenu;
1 change: 1 addition & 0 deletions src/components/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { default as AppLogo } from './AppLogo';
export { default as ContextMenu } from './ContextMenu';
export { default as NonProductionNotice } from './NonProductionNotice';
export { default as Skeleton } from './Skeleton';
export { default as Tooltip } from './Tooltip';
128 changes: 92 additions & 36 deletions src/components/workspace/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,101 @@
import { ContextMenu } from '@/components/ui';
import AppIcon from '@/components/ui/icon';
import { useFileTab } from '@/hooks';
import { useProject } from '@/hooks/projectV2.hooks';
import { Tree } from '@/interfaces/workspace.interface';
import { ITabItems } from '@/state/IDE.context';
import EventEmitter from '@/utility/eventEmitter';
import { delay, fileTypeFromFileName } from '@/utility/utils';
import { FC, useEffect } from 'react';
import { fileTypeFromFileName } from '@/utility/utils';
import type { MenuProps } from 'antd';
import cn from 'clsx';
import type { MenuInfo } from 'rc-menu/lib/interface';
import { FC, useCallback, useEffect } from 'react';
import s from './Tabs.module.scss';

interface IRenameFile {
oldPath: string;
newPath: string;
}

type ContextMenuKeys = 'close' | 'closeOthers' | 'closeAll';

interface ContextMenuItem {
key: ContextMenuKeys;
label: string;
}

interface IContextMenuItems extends MenuProps {
key: ContextMenuKeys;
items: ContextMenuItem[];
}

const contextMenuItems: IContextMenuItems['items'] = [
{
key: 'close',
label: 'Close',
},
{
key: 'closeOthers',
label: 'Close Others',
},
{
key: 'closeAll',
label: 'Close All',
},
];

const Tabs: FC = () => {
const { fileTab, open, close, rename, syncTabSettings, updateFileDirty } =
useFileTab();
const { activeProject } = useProject();
const { fileTab, open, close, rename, updateFileDirty } = useFileTab();

const closeTab = (e: React.MouseEvent, filePath: string) => {
e.preventDefault();
e.stopPropagation();
close(filePath);
};

const onFileSave = ({ filePath }: { filePath: string }) => {
updateFileDirty(filePath, false);
};
const onMenuItemClick = useCallback(
(info: MenuInfo, filePath: Tree['path']) => {
close(filePath, info.key as ContextMenuKeys);
},
[close],
);
const onFileSave = useCallback(
({ filePath }: { filePath: string }) => {
updateFileDirty(filePath, false);
},
[updateFileDirty],
);

const onFileRename = useCallback(
({ oldPath, newPath }: IRenameFile) => {
rename(oldPath, newPath);
},
[rename],
);

const computeTabClassNames = (item: ITabItems) => {
const fileExtension = item.name.split('.').pop() ?? '';
const fileTypeClass = fileTypeFromFileName(item.name);

const onFileRename = ({ oldPath, newPath }: IRenameFile) => {
rename(oldPath, newPath);
return cn(
s.item,
'file-icon',
`${fileExtension}-lang-file-icon`,
`${fileTypeClass}-lang-file-icon`,
{ [s.isActive]: item.path === fileTab.active },
);
};

useEffect(() => {
(async () => {
await delay(200);
syncTabSettings();
})();
}, [activeProject]);
const renderCloseButton = (item: ITabItems) => (
<span
className={cn(s.close, { [s.isDirty]: item.isDirty })}
onClick={(e) => {
closeTab(e, item.path);
}}
>
{item.isDirty && <span className={s.fileDirtyIcon}></span>}
<AppIcon name="Close" className={s.closeIcon} />
</span>
);

useEffect(() => {
EventEmitter.on('FILE_SAVED', onFileSave);
@@ -53,29 +113,25 @@ const Tabs: FC = () => {
<div className={s.container}>
<div className={s.tabList}>
{fileTab.items.map((item) => (
<div
onClick={() => {
open(item.name, item.path);
}}
className={`${s.item}
file-icon
${item.name.split('.').pop()}-lang-file-icon
${fileTypeFromFileName(item.name)}-lang-file-icon
${item.path === fileTab.active ? s.isActive : ''}
`}
<ContextMenu
key={item.path}
menu={{
items: contextMenuItems,
onClick: (info) => {
onMenuItemClick(info, item.path);
},
}}
>
{item.name}
<span
className={`${s.close} ${item.isDirty ? s.isDirty : ''}`}
onClick={(e) => {
closeTab(e, item.path);
<div
onClick={() => {
open(item.name, item.path);
}}
className={computeTabClassNames(item)}
>
<span className={s.fileDirtyIcon}></span>
<AppIcon name="Close" className={s.closeIcon} />
</span>
</div>
{item.name}
{renderCloseButton(item)}
</div>
</ContextMenu>
))}
</div>
</div>
20 changes: 4 additions & 16 deletions src/components/workspace/WorkSpace/WorkSpace.tsx
Original file line number Diff line number Diff line change
@@ -46,15 +46,10 @@ const WorkSpace: FC = () => {
const splitVerticalRef = useRef<SplitInstance | null>(null);

const { tab } = router.query;
const {
activeProject,
setActiveProject,
projectFiles,
loadProjectFiles,
newFileFolder,
} = useProject();
const { activeProject, setActiveProject, loadProjectFiles, newFileFolder } =
useProject();

const { fileTab, open: openTab } = useFileTab();
const { fileTab } = useFileTab();

const { init: initGlobalSetting } = useSettingAction();

@@ -83,7 +78,6 @@ const WorkSpace: FC = () => {
return;
}
await setActiveProject(selectedProjectPath);
await loadProjectFiles(selectedProjectPath);
};

const cachedProjectPath = useMemo(() => {
@@ -114,13 +108,7 @@ const WorkSpace: FC = () => {
createLog(`Project '${activeProject.name}' is opened`);
createSandbox(true).catch(() => {});

if (fileTab.active) return;
// Open main file on project switch
const mainFile = projectFiles.find((file) =>
['main.tact', 'main.fc'].includes(file.name),
);
if (!mainFile) return;
openTab(mainFile.name, mainFile.path);
loadProjectFiles(cachedProjectPath);
}, [cachedProjectPath]);

useEffect(() => {
152 changes: 70 additions & 82 deletions src/hooks/fileTabs.hooks.ts
Original file line number Diff line number Diff line change
@@ -1,112 +1,62 @@
import fileSystem from '@/lib/fs';
import { IDEContext, IFileTab } from '@/state/IDE.context';
import EventEmitter from '@/utility/eventEmitter';
import {
DEFAULT_PROJECT_SETTING,
updateProjectTabSetting,
} from '@/utility/projectSetting';
import cloneDeep from 'lodash.clonedeep';
import { useContext } from 'react';

const useFileTab = () => {
const { fileTab, setFileTab, activeProject } = useContext(IDEContext);

const syncTabSettings = async (updatedTab?: IFileTab) => {
if (!activeProject || Object.keys(activeProject).length === 0) return;

const defaultSetting = {
tab: {
items: [],
active: null,
},
};

try {
const settingPath = `${activeProject.path}/.ide/setting.json`;
if (!(await fileSystem.exists(settingPath))) {
await fileSystem.writeFile(
settingPath,
JSON.stringify(defaultSetting, null, 4),
{
overwrite: true,
},
);
}
const setting = (await fileSystem.readFile(settingPath)) as string;

let parsedSetting = setting ? JSON.parse(setting) : defaultSetting;

if (updatedTab) {
parsedSetting.tab = updatedTab;
} else {
parsedSetting = {
...defaultSetting,
...parsedSetting,
};
}
setFileTab(cloneDeep(parsedSetting.tab));

await fileSystem.writeFile(
settingPath,
JSON.stringify(parsedSetting, null, 2),
{
overwrite: true,
},
);
EventEmitter.emit('FORCE_UPDATE_FILE', settingPath);
} catch (error) {
console.error('Error syncing tab settings:', error);
}
const updateTabs = (tabs: IFileTab) => {
setFileTab(tabs);
};

const open = (name: string, path: string) => {
if (fileTab.active === path) return;
const open = async (name: string, path: string) => {
if (fileTab.active === path || !activeProject?.path) return;

const existingTab = fileTab.items.find((item) => item.path === path);

if (existingTab) {
const updatedTab = { ...fileTab, active: path };
syncTabSettings(updatedTab);
updateTabs(
await updateProjectTabSetting(activeProject.path, {
...fileTab,
active: path,
}),
);
} else {
const newTab = { name, path, isDirty: false };
const updatedTab = {
...fileTab,
items: [...fileTab.items, newTab],
active: path,
};
syncTabSettings(updatedTab);
updateTabs(await updateProjectTabSetting(activeProject.path, updatedTab));
}
};

const close = (filePath: string, closeAll: boolean = false) => {
const close = async (
filePath: string | null,
action: 'close' | 'closeAll' | 'closeOthers' = 'close',
) => {
let updatedTab: IFileTab;

if (closeAll) {
updatedTab = { items: [], active: null };
if (action === 'closeAll') {
updatedTab = DEFAULT_PROJECT_SETTING.tab;
} else if (action === 'closeOthers' && filePath) {
updatedTab = closeOtherTabs(filePath, fileTab);
} else {
const updatedItems = fileTab.items.filter(
(item) => item.path !== filePath,
);

let newActiveTab = fileTab.active;
if (fileTab.active === filePath) {
const closedTabIndex = fileTab.items.findIndex(
(item) => item.path === filePath,
);
if (updatedItems.length > 0) {
if (closedTabIndex > 0) {
newActiveTab = updatedItems[closedTabIndex - 1].path;
} else {
newActiveTab = updatedItems[0].path;
}
} else {
newActiveTab = null; // No more tabs open
}
}

updatedTab = { items: updatedItems, active: newActiveTab };
updatedTab = closeSingleTab(filePath, fileTab);
}

syncTabSettings(updatedTab);
updateTabs(
await updateProjectTabSetting(activeProject?.path as string, updatedTab),
);
};

const rename = (oldPath: string, newPath: string) => {
const rename = async (oldPath: string, newPath: string) => {
const updatedItems = fileTab.items.map((item) => {
if (item.path === oldPath) {
return {
@@ -126,11 +76,13 @@ const useFileTab = () => {
active: isActiveTab ? newPath : fileTab.active, // Set the active tab to the new path if it was renamed
};

syncTabSettings(updatedTab);
updateTabs(
await updateProjectTabSetting(activeProject?.path as string, updatedTab),
);
EventEmitter.emit('FORCE_UPDATE_FILE', newPath);
};

const updateFileDirty = (filePath: string, isDirty: boolean) => {
const updateFileDirty = async (filePath: string, isDirty: boolean) => {
const updatedItems = cloneDeep(fileTab).items.map((item) => {
if (item.path === filePath) {
return { ...item, isDirty: isDirty };
@@ -139,7 +91,9 @@ const useFileTab = () => {
});

const updatedTab = { ...fileTab, items: updatedItems };
syncTabSettings(updatedTab);
updateTabs(
await updateProjectTabSetting(activeProject?.path as string, updatedTab),
);
};

const hasDirtyFiles = () => {
@@ -151,10 +105,44 @@ const useFileTab = () => {
open,
close,
rename,
syncTabSettings,
updateFileDirty,
hasDirtyFiles,
};
};

/**
* Close all tabs except the specified one.
*/
function closeOtherTabs(filePath: string, fileTab: IFileTab): IFileTab {
const updatedItems = fileTab.items.filter((item) => item.path === filePath);
return {
items: updatedItems,
active: updatedItems.length > 0 ? updatedItems[0].path : null,
};
}

/**
* Close a single tab and determine the next active tab.
*/
function closeSingleTab(filePath: string | null, fileTab: IFileTab): IFileTab {
const updatedItems = fileTab.items.filter((item) => item.path !== filePath);

let newActiveTab = fileTab.active;
if (fileTab.active === filePath) {
const closedTabIndex = fileTab.items.findIndex(
(item) => item.path === filePath,
);
if (updatedItems.length > 0) {
newActiveTab =
closedTabIndex > 0
? updatedItems[closedTabIndex - 1].path
: updatedItems[0].path;
} else {
newActiveTab = null; // No more tabs open
}
}

return { items: updatedItems, active: newActiveTab };
}

export default useFileTab;
8 changes: 2 additions & 6 deletions src/hooks/projectV2.hooks.ts
Original file line number Diff line number Diff line change
@@ -277,11 +277,7 @@ export const useProject = () => {
return { success: true, oldPath, newPath };
};

const updateActiveProject = async (
projectPath: string | null,
force = false,
) => {
if (activeProject?.path === projectPath && !force) return;
const updateActiveProject = async (projectPath: string | null) => {
const projectSettingPath = `${projectPath}/.ide/setting.json`;
if (projectPath && (await fileSystem.exists(projectSettingPath))) {
const setting = (await fileSystem.readFile(projectSettingPath)) as string;
@@ -310,7 +306,7 @@ export const useProject = () => {
overwrite: true,
},
);
await updateActiveProject(activeProject.path, true);
await updateActiveProject(activeProject.path);
}
await loadProjectFiles(activeProject.path);
};
20 changes: 19 additions & 1 deletion src/state/IDE.context.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { SettingInterface } from '@/interfaces/setting.interface';
import { ProjectSetting, Tree } from '@/interfaces/workspace.interface';
import { updateProjectTabSetting } from '@/utility/projectSetting';
import { FC, createContext, useEffect, useMemo, useState } from 'react';

interface ITabItems {
export interface ITabItems {
name: string;
path: string;
isDirty: boolean;
@@ -89,6 +90,23 @@ export const IDEProvider: FC<{ children: React.ReactNode }> = ({
}
};

const handleActiveProjectChange = async () => {
const mainFile = projectFiles.find((file) =>
['main.tact', 'main.fc'].includes(file.name),
);

const updatedTabs = await updateProjectTabSetting(
activeProject?.path,
null,
mainFile ? mainFile.path : undefined,
);
setFileTab(updatedTabs);
};

useEffect(() => {
handleActiveProjectChange();
}, [activeProject, handleActiveProjectChange]);

useEffect(() => {
onInit();
setIsLoaded(true);
99 changes: 99 additions & 0 deletions src/utility/projectSetting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Project } from '@/interfaces/workspace.interface';
import fileSystem from '@/lib/fs';
import { IFileTab } from '@/state/IDE.context';
import cloneDeep from 'lodash.clonedeep';
import EventEmitter from './eventEmitter';

export const DEFAULT_PROJECT_SETTING = {
tab: {
items: [],
active: null,
},
};

type ProjectSettingWithTab = Project & IFileTab;

export async function updateProjectTabSetting(
projectPath: string | undefined,
updatedTab: IFileTab | null,
defaultFilePath?: string,
): Promise<IFileTab> {
if (!projectPath) return DEFAULT_PROJECT_SETTING.tab;
const settingPath = `${projectPath}/.ide/setting.json`;

try {
await ensureSettingFileExists(settingPath);

let parsedSetting = await readSettingFile(settingPath);

if (updatedTab) {
parsedSetting.tab = updatedTab;
} else {
parsedSetting = { ...DEFAULT_PROJECT_SETTING, ...parsedSetting };
}

let clonedTab: IFileTab | undefined = cloneDeep(parsedSetting.tab);

if (!clonedTab?.active && defaultFilePath) {
clonedTab = {
active: defaultFilePath,
items: [
{
name: defaultFilePath.split('/').pop() ?? defaultFilePath,
path: defaultFilePath,
isDirty: false,
},
],
};
parsedSetting.tab = clonedTab;
}

await writeSettingFile(settingPath, parsedSetting);
return clonedTab ?? DEFAULT_PROJECT_SETTING.tab;
} catch (error) {
console.error('Error syncing tab settings:', error);
return DEFAULT_PROJECT_SETTING.tab;
}
}

/**
* Ensure the setting file exists and initialize it with default content if it doesn't.
*/
async function ensureSettingFileExists(settingPath: string) {
if (!(await fileSystem.exists(settingPath))) {
await fileSystem.writeFile(
settingPath,
JSON.stringify(DEFAULT_PROJECT_SETTING, null, 4),
{
overwrite: true,
},
);
}
}

/**
* Read and parse the settings file.
*/
async function readSettingFile(settingPath: string) {
const settingContent = (await fileSystem.readFile(settingPath)) as string;
return settingContent
? JSON.parse(settingContent)
: { ...DEFAULT_PROJECT_SETTING };
}

/**
* Write the updated settings to the file.
*/
async function writeSettingFile(
settingPath: string,
parsedSetting: ProjectSettingWithTab,
) {
await fileSystem.writeFile(
settingPath,
JSON.stringify(parsedSetting, null, 2),
{
overwrite: true,
},
);
EventEmitter.emit('FORCE_UPDATE_FILE', settingPath);
}

0 comments on commit c847988

Please sign in to comment.