refactor: split edit workspace UI code

This commit is contained in:
linonetwo 2025-12-07 20:58:22 +08:00
parent baa7eb3a33
commit fb41fd3688
6 changed files with 668 additions and 542 deletions

View file

@ -0,0 +1,84 @@
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { AccordionDetails, Divider, Tooltip } from '@mui/material';
import React from 'react';
import { useTranslation } from 'react-i18next';
import defaultIcon from '../../images/default-icon.png';
import { wikiPictureExtensions } from '@/constants/fileNames';
import { IWorkspace } from '@services/workspaces/interface';
import { Avatar, AvatarFlex, AvatarLeft, AvatarPicture, AvatarRight, OptionsAccordion, OptionsAccordionSummary, PictureButton, TextField } from './styles';
interface AppearanceOptionsProps {
workspace: IWorkspace;
workspaceSetter: (newValue: IWorkspace) => void;
}
const getValidIconPath = (iconPath?: string | null): string => {
if (typeof iconPath === 'string') {
return `file:///${iconPath}`;
}
return defaultIcon;
};
export function AppearanceOptions(props: AppearanceOptionsProps): React.JSX.Element {
const { t } = useTranslation();
const { workspace, workspaceSetter } = props;
const { name, picturePath } = workspace;
return (
<OptionsAccordion defaultExpanded>
<Tooltip title={t('EditWorkspace.ClickToExpand')}>
<OptionsAccordionSummary expandIcon={<ExpandMoreIcon />}>
{t('EditWorkspace.AppearanceOptions')}
</OptionsAccordionSummary>
</Tooltip>
<AccordionDetails>
<TextField
id='outlined-full-width'
label={t('EditWorkspace.Name')}
helperText={t('EditWorkspace.NameDescription')}
placeholder='Optional'
value={name}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, name: event.target.value });
}}
/>
<Divider />
<AvatarFlex>
<AvatarLeft>
<Avatar transparentBackground={false}>
<AvatarPicture alt='Icon' src={getValidIconPath(picturePath)} />
</Avatar>
</AvatarLeft>
<AvatarRight>
<Tooltip title={wikiPictureExtensions.join(', ')} placement='top'>
<PictureButton
variant='outlined'
size='small'
onClick={async () => {
const filePaths = await window.service.native.pickFile([{ name: 'Images', extensions: wikiPictureExtensions }]);
if (filePaths.length > 0) {
workspaceSetter({ ...workspace, picturePath: filePaths[0] });
}
}}
>
{t('EditWorkspace.SelectLocal')}
</PictureButton>
</Tooltip>
<Tooltip title={t('EditWorkspace.NoRevert') ?? ''} placement='bottom'>
<PictureButton
onClick={() => {
workspaceSetter({ ...workspace, picturePath: null });
}}
disabled={!picturePath}
>
{t('EditWorkspace.ResetDefaultIcon')}
</PictureButton>
</Tooltip>
</AvatarRight>
</AvatarFlex>
</AccordionDetails>
</OptionsAccordion>
);
}

View file

@ -0,0 +1,134 @@
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { AccordionDetails, Divider, List, ListItem, ListItemText, Switch, Tooltip } from '@mui/material';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { isWikiWorkspace, IWorkspace } from '@services/workspaces/interface';
import { OptionsAccordion, OptionsAccordionSummary, TextField } from './styles';
interface MiscOptionsProps {
workspace: IWorkspace;
workspaceSetter: (newValue: IWorkspace, requestSaveAndRestart?: boolean) => void;
rememberLastPageVisited: boolean | undefined;
}
export function MiscOptions(props: MiscOptionsProps): React.JSX.Element {
const { t } = useTranslation();
const { workspace, workspaceSetter, rememberLastPageVisited } = props;
const isWiki = isWikiWorkspace(workspace);
const {
disableAudio = false,
disableNotifications = false,
enableFileSystemWatch = false,
hibernateWhenUnused = false,
homeUrl = '',
isSubWiki = false,
lastUrl = null,
} = isWiki ? workspace : {
disableAudio: false,
disableNotifications: false,
enableFileSystemWatch: false,
hibernateWhenUnused: false,
homeUrl: '',
isSubWiki: false,
lastUrl: null,
};
return (
<OptionsAccordion>
<Tooltip title={t('EditWorkspace.ClickToExpand')}>
<OptionsAccordionSummary expandIcon={<ExpandMoreIcon />} data-testid='preference-section-miscOptions'>
{t('EditWorkspace.MiscOptions')}
</OptionsAccordionSummary>
</Tooltip>
<AccordionDetails>
{!isSubWiki && (
<List>
<Divider />
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={hibernateWhenUnused}
data-testid='hibernate-when-unused-switch'
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, hibernateWhenUnused: event.target.checked });
}}
/>
}
>
<ListItemText primary={t('EditWorkspace.HibernateTitle')} secondary={t('EditWorkspace.HibernateDescription')} />
</ListItem>
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={disableNotifications}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, disableNotifications: event.target.checked });
}}
/>
}
>
<ListItemText primary={t('EditWorkspace.DisableNotificationTitle')} secondary={t('EditWorkspace.DisableNotification')} />
</ListItem>
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={disableAudio}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, disableAudio: event.target.checked });
}}
/>
}
>
<ListItemText primary={t('EditWorkspace.DisableAudioTitle')} secondary={t('EditWorkspace.DisableAudio')} />
</ListItem>
<Divider />
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={enableFileSystemWatch}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, enableFileSystemWatch: event.target.checked }, true);
}}
/>
}
>
<ListItemText
primary={t('EditWorkspace.EnableFileSystemWatchTitle')}
secondary={t('EditWorkspace.EnableFileSystemWatchDescription')}
/>
</ListItem>
</List>
)}
{!isSubWiki && rememberLastPageVisited && (
<TextField
id='outlined-full-width'
label={t('EditWorkspace.LastVisitState')}
helperText={t('Preference.RememberLastVisitState')}
placeholder={homeUrl}
value={lastUrl}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({
...workspace,
lastUrl: (event.target.value || homeUrl) ?? '',
});
}}
/>
)}
</AccordionDetails>
</OptionsAccordion>
);
}

View file

@ -0,0 +1,203 @@
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { AccordionDetails, Button, Divider, List, ListItem, ListItemText, Switch, Tooltip } from '@mui/material';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { TokenForm } from '@/components/TokenForm';
import { SupportedStorageServices } from '@services/types';
import { isWikiWorkspace, IWorkspace } from '@services/workspaces/interface';
import { SyncedWikiDescription } from '../AddWorkspace/Description';
import { GitRepoUrlForm } from '../AddWorkspace/GitRepoUrlForm';
import { OptionsAccordion, OptionsAccordionSummary, TextField } from './styles';
interface SaveAndSyncOptionsProps {
workspace: IWorkspace;
workspaceSetter: (newValue: IWorkspace, requestSaveAndRestart?: boolean) => void;
rememberLastPageVisited: boolean | undefined;
}
export function SaveAndSyncOptions(props: SaveAndSyncOptionsProps): React.JSX.Element {
const { t } = useTranslation();
const { workspace, workspaceSetter, rememberLastPageVisited: _rememberLastPageVisited } = props;
const isWiki = isWikiWorkspace(workspace);
const {
gitUrl = null,
homeUrl: _homeUrl = '',
isSubWiki = false,
lastUrl: _lastUrl = null,
storageService = SupportedStorageServices.github,
syncOnInterval = false,
syncOnStartup = false,
backupOnInterval = false,
userName = '',
wikiFolderLocation = '',
} = isWiki ? workspace : {
gitUrl: null,
homeUrl: '',
isSubWiki: false,
lastUrl: null,
storageService: SupportedStorageServices.github,
syncOnInterval: false,
syncOnStartup: false,
backupOnInterval: false,
userName: '',
wikiFolderLocation: '',
};
const fallbackUserName = '';
const isCreateSyncedWorkspace = storageService !== SupportedStorageServices.local;
return (
<OptionsAccordion>
<Tooltip title={t('EditWorkspace.ClickToExpand')}>
<OptionsAccordionSummary expandIcon={<ExpandMoreIcon />} data-testid='preference-section-saveAndSyncOptions'>
{t('EditWorkspace.SaveAndSyncOptions')}
</OptionsAccordionSummary>
</Tooltip>
<AccordionDetails>
<TextField
fullWidth
id='outlined-full-width'
label={t('EditWorkspace.Path')}
helperText={t('EditWorkspace.PathDescription')}
placeholder='Optional'
disabled
value={wikiFolderLocation}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, wikiFolderLocation: event.target.value });
}}
/>
<Tooltip title={t('EditWorkspace.MoveWorkspaceTooltip') ?? ''} placement='top'>
<Button
variant='outlined'
size='small'
onClick={async () => {
const directories = await window.service.native.pickDirectory();
if (directories.length > 0) {
const newLocation = directories[0];
try {
await window.service.wikiGitWorkspace.moveWorkspaceLocation(workspace.id, newLocation);
} catch (error) {
const errorMessage = (error as Error).message;
void window.service.native.log('error', `Failed to move workspace: ${errorMessage}`, { error, workspaceID: workspace.id, newLocation });
void window.service.notification.show({
title: t('EditWorkspace.MoveWorkspaceFailed'),
body: t('EditWorkspace.MoveWorkspaceFailedMessage', { name: workspace.name, error: errorMessage }),
});
}
}
}}
>
{t('EditWorkspace.MoveWorkspace')}
</Button>
</Tooltip>
{isSubWiki && workspace && isWikiWorkspace(workspace) && workspace.mainWikiToLink && (
<TextField
fullWidth
id='outlined-full-width'
label={t('EditWorkspace.MainWorkspacePath')}
helperText={t('EditWorkspace.PathDescription')}
value={workspace.mainWikiToLink}
disabled
/>
)}
{!isSubWiki && (
<TextField
helperText={t('AddWorkspace.WorkspaceUserNameDetail')}
fullWidth
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, userName: event.target.value }, true);
}}
label={t('AddWorkspace.WorkspaceUserName')}
placeholder={fallbackUserName}
value={userName}
/>
)}
<Divider />
<SyncedWikiDescription
isCreateSyncedWorkspace={isCreateSyncedWorkspace}
isCreateSyncedWorkspaceSetter={(isSynced: boolean) => {
workspaceSetter({ ...workspace, storageService: isSynced ? SupportedStorageServices.github : SupportedStorageServices.local });
}}
/>
{isCreateSyncedWorkspace && (
<TokenForm
storageProvider={storageService}
storageProviderSetter={(nextStorageService) => {
workspaceSetter({ ...workspace, storageService: nextStorageService });
}}
/>
)}
{storageService !== SupportedStorageServices.local && (
<GitRepoUrlForm
storageProvider={storageService}
gitRepoUrl={gitUrl ?? ''}
gitRepoUrlSetter={(nextGitUrl: string) => {
workspaceSetter({ ...workspace, gitUrl: nextGitUrl });
}}
isCreateMainWorkspace={!isSubWiki}
/>
)}
{storageService !== SupportedStorageServices.local && (
<>
<List>
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={syncOnInterval}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, syncOnInterval: event.target.checked });
}}
/>
}
>
<ListItemText primary={t('EditWorkspace.SyncOnInterval')} secondary={t('EditWorkspace.SyncOnIntervalDescription')} />
</ListItem>
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={syncOnStartup}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, syncOnStartup: event.target.checked });
}}
/>
}
>
<ListItemText primary={t('EditWorkspace.SyncOnStartup')} secondary={t('EditWorkspace.SyncOnStartupDescription')} />
</ListItem>
</List>
</>
)}
{storageService === SupportedStorageServices.local && (
<>
<List>
<Divider />
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={backupOnInterval}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, backupOnInterval: event.target.checked });
}}
/>
}
>
<ListItemText primary={t('EditWorkspace.BackupOnInterval')} secondary={t('EditWorkspace.BackupOnIntervalDescription')} />
</ListItem>
</List>
</>
)}
</AccordionDetails>
</OptionsAccordion>
);
}

View file

@ -0,0 +1,117 @@
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { AccordionDetails, Autocomplete, AutocompleteRenderInputParams, List, ListItem, ListItemText, Switch, Tooltip, Typography } from '@mui/material';
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { IWikiWorkspace } from '@services/workspaces/interface';
import { OptionsAccordion, OptionsAccordionSummary, TextField } from './styles';
interface SubWorkspaceRoutingProps {
workspace: IWikiWorkspace;
workspaceSetter: (newValue: IWikiWorkspace, requestSaveAndRestart?: boolean) => void;
availableTags: string[];
isSubWiki: boolean;
}
export function SubWorkspaceRouting(props: SubWorkspaceRoutingProps): React.JSX.Element {
const { t } = useTranslation();
const { workspace, workspaceSetter, availableTags, isSubWiki } = props;
const {
tagNames,
includeTagTree,
fileSystemPathFilterEnable,
fileSystemPathFilter,
} = workspace;
return (
<OptionsAccordion defaultExpanded={isSubWiki}>
<Tooltip title={t('EditWorkspace.ClickToExpand')}>
<OptionsAccordionSummary expandIcon={<ExpandMoreIcon />} data-testid='preference-section-subWorkspaceOptions'>
{t('AddWorkspace.SubWorkspaceOptions')}
</OptionsAccordionSummary>
</Tooltip>
<AccordionDetails>
<Typography variant='body2' color='textSecondary' sx={{ mb: 2 }}>
{isSubWiki ? t('AddWorkspace.SubWorkspaceOptionsDescriptionForSub') : t('AddWorkspace.SubWorkspaceOptionsDescriptionForMain')}
</Typography>
<Autocomplete
multiple
freeSolo
options={availableTags}
value={tagNames}
onChange={(_event: React.SyntheticEvent, newValue: string[]) => {
void _event;
workspaceSetter({ ...workspace, tagNames: newValue }, true);
}}
slotProps={{
chip: {
variant: 'outlined',
},
}}
renderInput={(parameters: AutocompleteRenderInputParams) => (
<TextField
{...parameters}
label={t('AddWorkspace.TagName')}
helperText={isSubWiki ? t('AddWorkspace.TagNameHelp') : t('AddWorkspace.TagNameHelpForMain')}
/>
)}
/>
<List>
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={includeTagTree}
data-testid='include-tag-tree-switch'
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, includeTagTree: event.target.checked }, true);
}}
/>
}
>
<ListItemText
primary={t('AddWorkspace.IncludeTagTree')}
secondary={isSubWiki ? t('AddWorkspace.IncludeTagTreeHelp') : t('AddWorkspace.IncludeTagTreeHelpForMain')}
/>
</ListItem>
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={fileSystemPathFilterEnable}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, fileSystemPathFilterEnable: event.target.checked }, true);
}}
/>
}
>
<ListItemText
primary={t('AddWorkspace.UseFilter')}
secondary={t('AddWorkspace.UseFilterHelp')}
/>
</ListItem>
</List>
{fileSystemPathFilterEnable && (
<TextField
fullWidth
multiline
minRows={2}
maxRows={10}
value={fileSystemPathFilter ?? ''}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, fileSystemPathFilter: event.target.value || null }, true);
}}
label={t('AddWorkspace.FilterExpression')}
helperText={t('AddWorkspace.FilterExpressionHelp')}
sx={{ mb: 2 }}
/>
)}
</AccordionDetails>
</OptionsAccordion>
);
}

View file

@ -1,144 +1,23 @@
import { Helmet } from '@dr.pogodin/react-helmet';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Autocomplete,
AutocompleteRenderInputParams,
Button as ButtonRaw,
Divider,
Paper,
Switch,
TextField as TextFieldRaw,
Tooltip,
Typography,
} from '@mui/material';
import { css, styled } from '@mui/material/styles';
import React, { ReactNode } from 'react';
import { Divider } from '@mui/material';
import React from 'react';
import { useTranslation } from 'react-i18next';
import defaultIcon from '../../images/default-icon.png';
import { usePromiseValue } from '@/helpers/useServiceValue';
import { WindowMeta, WindowNames } from '@services/windows/WindowProperties';
import { useWorkspaceObservable } from '@services/workspaces/hooks';
import { useForm } from './useForm';
import { List, ListItem, ListItemText } from '@/components/ListItem';
import { RestartSnackbarType, useRestartSnackbar } from '@/components/RestartSnackbar';
import { TokenForm } from '@/components/TokenForm';
import { wikiPictureExtensions } from '@/constants/fileNames';
import { SupportedStorageServices } from '@services/types';
import { isWikiWorkspace, nonConfigFields } from '@services/workspaces/interface';
import { isEqual, omit } from 'lodash';
import { SyncedWikiDescription } from '../AddWorkspace/Description';
import { GitRepoUrlForm } from '../AddWorkspace/GitRepoUrlForm';
import { useAvailableTags } from '../AddWorkspace/useAvailableTags';
import { AppearanceOptions } from './AppearanceOptions';
import { MiscOptions } from './MiscOptions';
import { SaveAndSyncOptions } from './SaveAndSyncOptions';
import { ServerOptions } from './server';
const OptionsAccordion = styled((props: React.ComponentProps<typeof Accordion>) => <Accordion {...props} />)`
box-shadow: unset;
background-color: unset;
`;
const OptionsAccordionSummary = styled((props: React.ComponentProps<typeof AccordionSummary>) => <AccordionSummary {...props} />)`
padding: 0;
flex-direction: row-reverse;
`;
const Root = styled((props: React.ComponentProps<typeof Paper>) => <Paper {...props} />)`
height: 100%;
width: 100%;
padding: 20px;
/** for SaveCancelButtonsContainer 's height */
margin-bottom: 40px;
display: flex;
flex-direction: column;
background: ${({ theme }) => theme.palette.background.paper};
`;
const FlexGrow = styled('div')`
flex: 1;
`;
const Button = styled((props: React.ComponentProps<typeof ButtonRaw>) => <ButtonRaw {...props} />)`
float: right;
margin-left: 10px;
`;
const TextField = styled((props: React.ComponentProps<typeof TextFieldRaw>) => (
<TextFieldRaw fullWidth margin='dense' size='small' variant='filled' slotProps={{ inputLabel: { shrink: true } }} {...props} />
))`
margin-bottom: 10px;
`;
const AvatarFlex = styled('div')`
display: flex;
`;
const AvatarLeft = styled('div')`
padding-top: 10px;
padding-bottom: 10px;
padding-left: 0;
padding-right: 10px;
`;
const AvatarRight = styled('div')`
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: flex-start;
padding-top: 10px;
padding-bottom: 10px;
padding-left: 10px;
padding-right: 0;
`;
/**
* border: theme.palette.type === 'dark' ? 'none': '1px solid rgba(0, 0, 0, 0.12)';
*/
const Avatar = styled('div')<{ transparentBackground?: boolean }>`
height: 85px;
width: 85px;
border-radius: 4px;
color: #333;
font-size: 32px;
line-height: 64px;
text-align: center;
font-weight: 500;
text-transform: uppercase;
user-select: none;
overflow: hidden;
${({ transparentBackground }) => {
if (transparentBackground === true) {
return css`
background: transparent;
border: none;
border-radius: 0;
`;
}
}}
`;
const SaveCancelButtonsContainer = styled('div')`
position: fixed;
left: 0;
bottom: 0;
height: auto;
width: 100%;
padding: 5px;
background-color: ${({ theme }) => theme.palette.background.paper};
opacity: 0.9;
backdrop-filter: blur(10px);
`;
const AvatarPicture = styled('img')`
height: 100%;
width: 100%;
`;
const PictureButton = styled((props: React.ComponentProps<typeof ButtonRaw>) => <ButtonRaw variant='outlined' size='small' {...props} />)``;
const _Caption = styled((props: { children?: ReactNode } & React.ComponentProps<typeof Typography>) => <Typography variant='caption' {...props} />)`
display: block;
`;
const getValidIconPath = (iconPath?: string | null): string => {
if (typeof iconPath === 'string') {
return `file:///${iconPath}`;
}
return defaultIcon;
};
import { Button, FlexGrow, Root, SaveCancelButtonsContainer } from './styles';
import { SubWorkspaceRouting } from './SubWorkspaceRouting';
const workspaceID = (window.meta() as WindowMeta[WindowNames.editWorkspace]).workspaceID!;
@ -148,34 +27,11 @@ export default function EditWorkspace(): React.JSX.Element {
const [requestRestartCountDown, RestartSnackbar] = useRestartSnackbar({ waitBeforeCountDown: 0, workspace: originalWorkspace, restartType: RestartSnackbarType.Wiki });
const [workspace, workspaceSetter, onSave] = useForm(originalWorkspace, requestRestartCountDown);
const isWiki = workspace && isWikiWorkspace(workspace);
const {
name,
order,
picturePath,
} = workspace ?? {};
const { order } = workspace ?? {};
const { name } = workspace ?? {};
// Wiki-specific properties with fallbacks
const backupOnInterval = isWiki ? workspace.backupOnInterval : false;
const disableAudio = isWiki ? workspace.disableAudio : false;
const disableNotifications = isWiki ? workspace.disableNotifications : false;
const enableFileSystemWatch = isWiki ? workspace.enableFileSystemWatch : false;
const gitUrl = isWiki ? workspace.gitUrl : null;
const hibernateWhenUnused = isWiki ? workspace.hibernateWhenUnused : false;
const homeUrl = isWiki ? workspace.homeUrl : '';
const isSubWiki = isWiki ? workspace.isSubWiki : false;
const mainWikiToLink = isWiki ? workspace.mainWikiToLink : null;
const storageService = isWiki ? workspace.storageService : SupportedStorageServices.github;
const syncOnInterval = isWiki ? workspace.syncOnInterval : false;
const syncOnStartup = isWiki ? workspace.syncOnStartup : false;
const tagNames = isWiki ? workspace.tagNames : [];
const includeTagTree = isWiki ? workspace.includeTagTree : false;
const fileSystemPathFilterEnable = isWiki ? workspace.fileSystemPathFilterEnable : false;
const fileSystemPathFilter = isWiki ? workspace.fileSystemPathFilter : null;
const transparentBackground = isWiki ? workspace.transparentBackground : false;
const userName = isWiki ? workspace.userName : '';
const lastUrl = isWiki ? workspace.lastUrl : null;
const wikiFolderLocation = isWiki ? workspace.wikiFolderLocation : '';
const fallbackUserName = usePromiseValue<string>(async () => (await window.service.auth.get('userName'))!, '');
// Fetch all tags from main wiki for autocomplete suggestions
const availableTags = useAvailableTags(mainWikiToLink ?? undefined, isSubWiki);
@ -197,7 +53,6 @@ export default function EditWorkspace(): React.JSX.Element {
if (workspace === undefined) {
return <Root>{t('Loading')}</Root>;
}
const isCreateSyncedWorkspace = storageService !== SupportedStorageServices.local;
return (
<Root>
{RestartSnackbar}
@ -214,395 +69,17 @@ export default function EditWorkspace(): React.JSX.Element {
<Divider />
</>
)}
<OptionsAccordion defaultExpanded>
<Tooltip title={t('EditWorkspace.ClickToExpand')}>
<OptionsAccordionSummary expandIcon={<ExpandMoreIcon />}>
{t('EditWorkspace.AppearanceOptions')}
</OptionsAccordionSummary>
</Tooltip>
<AccordionDetails>
<TextField
id='outlined-full-width'
label={t('EditWorkspace.Name')}
helperText={t('EditWorkspace.NameDescription')}
placeholder='Optional'
value={name}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, name: event.target.value });
}}
/>
<Divider />
<AvatarFlex>
<AvatarLeft>
<Avatar transparentBackground={transparentBackground}>
<AvatarPicture alt='Icon' src={getValidIconPath(picturePath)} />
</Avatar>
</AvatarLeft>
<AvatarRight>
<Tooltip title={wikiPictureExtensions.join(', ')} placement='top'>
<PictureButton
variant='outlined'
size='small'
onClick={async () => {
const filePaths = await window.service.native.pickFile([{ name: 'Images', extensions: wikiPictureExtensions }]);
if (filePaths.length > 0) {
workspaceSetter({ ...workspace, picturePath: filePaths[0] });
}
}}
>
{t('EditWorkspace.SelectLocal')}
</PictureButton>
</Tooltip>
<Tooltip title={t('EditWorkspace.NoRevert') ?? ''} placement='bottom'>
<PictureButton
onClick={() => {
workspaceSetter({ ...workspace, picturePath: null });
}}
disabled={!picturePath}
>
{t('EditWorkspace.ResetDefaultIcon')}
</PictureButton>
</Tooltip>
</AvatarRight>
</AvatarFlex>
</AccordionDetails>
</OptionsAccordion>
<OptionsAccordion>
<Tooltip title={t('EditWorkspace.ClickToExpand')}>
<OptionsAccordionSummary expandIcon={<ExpandMoreIcon />} data-testid='preference-section-saveAndSyncOptions'>
{t('EditWorkspace.SaveAndSyncOptions')}
</OptionsAccordionSummary>
</Tooltip>
<AccordionDetails>
<TextField
fullWidth
id='outlined-full-width'
label={t('EditWorkspace.Path')}
helperText={t('EditWorkspace.PathDescription')}
placeholder='Optional'
disabled
value={wikiFolderLocation}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, wikiFolderLocation: event.target.value });
}}
/>
<Tooltip title={t('EditWorkspace.MoveWorkspaceTooltip') ?? ''} placement='top'>
<PictureButton
variant='outlined'
size='small'
onClick={async () => {
const directories = await window.service.native.pickDirectory();
if (directories.length > 0) {
const newLocation = directories[0];
try {
await window.service.wikiGitWorkspace.moveWorkspaceLocation(workspaceID, newLocation);
} catch (error) {
const errorMessage = (error as Error).message;
void window.service.native.log('error', `Failed to move workspace: ${errorMessage}`, { error, workspaceID, newLocation });
// Show error notification
void window.service.notification.show({
title: t('EditWorkspace.MoveWorkspaceFailed'),
body: t('EditWorkspace.MoveWorkspaceFailedMessage', { name: workspace.name, error: errorMessage }),
});
}
}
}}
>
{t('EditWorkspace.MoveWorkspace')}
</PictureButton>
</Tooltip>
{isSubWiki && mainWikiToLink && (
<TextField
fullWidth
id='outlined-full-width'
label={t('EditWorkspace.MainWorkspacePath')}
helperText={t('EditWorkspace.PathDescription')}
value={mainWikiToLink}
disabled
/>
)}
{!isSubWiki && (
<TextField
helperText={t('AddWorkspace.WorkspaceUserNameDetail')}
fullWidth
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, userName: event.target.value }, true);
}}
label={t('AddWorkspace.WorkspaceUserName')}
placeholder={fallbackUserName}
value={userName}
/>
)}
<Divider />
<SyncedWikiDescription
isCreateSyncedWorkspace={isCreateSyncedWorkspace}
isCreateSyncedWorkspaceSetter={(isSynced: boolean) => {
workspaceSetter({ ...workspace, storageService: isSynced ? SupportedStorageServices.github : SupportedStorageServices.local });
}}
/>
{isCreateSyncedWorkspace && (
<TokenForm
storageProvider={storageService}
storageProviderSetter={(nextStorageService) => {
workspaceSetter({ ...workspace, storageService: nextStorageService });
}}
/>
)}
{storageService !== SupportedStorageServices.local && (
<GitRepoUrlForm
storageProvider={storageService}
gitRepoUrl={gitUrl ?? ''}
gitRepoUrlSetter={(nextGitUrl: string) => {
workspaceSetter({ ...workspace, gitUrl: nextGitUrl });
}}
isCreateMainWorkspace={!isSubWiki}
/>
)}
{storageService !== SupportedStorageServices.local && (
<>
<List>
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={syncOnInterval}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, syncOnInterval: event.target.checked });
}}
/>
}
>
<ListItemText primary={t('EditWorkspace.SyncOnInterval')} secondary={t('EditWorkspace.SyncOnIntervalDescription')} />
</ListItem>
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={syncOnStartup}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, syncOnStartup: event.target.checked });
}}
/>
}
>
<ListItemText primary={t('EditWorkspace.SyncOnStartup')} secondary={t('EditWorkspace.SyncOnStartupDescription')} />
</ListItem>
</List>
</>
)}
{storageService === SupportedStorageServices.local && (
<>
<List>
<Divider />
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={backupOnInterval}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, backupOnInterval: event.target.checked });
}}
/>
}
>
<ListItemText primary={t('EditWorkspace.BackupOnInterval')} secondary={t('EditWorkspace.BackupOnIntervalDescription')} />
</ListItem>
</List>
</>
)}
</AccordionDetails>
</OptionsAccordion>
{showSubWorkspaceRouting && (
<OptionsAccordion defaultExpanded={isSubWiki}>
<Tooltip title={t('EditWorkspace.ClickToExpand')}>
<OptionsAccordionSummary expandIcon={<ExpandMoreIcon />} data-testid='preference-section-subWorkspaceOptions'>
{t('AddWorkspace.SubWorkspaceOptions')}
</OptionsAccordionSummary>
</Tooltip>
<AccordionDetails>
<Typography variant='body2' color='textSecondary' sx={{ mb: 2 }}>
{isSubWiki ? t('AddWorkspace.SubWorkspaceOptionsDescriptionForSub') : t('AddWorkspace.SubWorkspaceOptionsDescriptionForMain')}
</Typography>
<Autocomplete
multiple
freeSolo
options={availableTags}
value={tagNames}
onChange={(_event: React.SyntheticEvent, newValue: string[]) => {
void _event;
workspaceSetter({ ...workspace, tagNames: newValue }, true);
}}
slotProps={{
chip: {
variant: 'outlined',
},
}}
renderInput={(parameters: AutocompleteRenderInputParams) => (
<TextField
{...parameters}
label={t('AddWorkspace.TagName')}
helperText={isSubWiki ? t('AddWorkspace.TagNameHelp') : t('AddWorkspace.TagNameHelpForMain')}
/>
)}
/>
<List>
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={includeTagTree}
data-testid='include-tag-tree-switch'
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, includeTagTree: event.target.checked }, true);
}}
/>
}
>
<ListItemText
primary={t('AddWorkspace.IncludeTagTree')}
secondary={isSubWiki ? t('AddWorkspace.IncludeTagTreeHelp') : t('AddWorkspace.IncludeTagTreeHelpForMain')}
/>
</ListItem>
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={fileSystemPathFilterEnable}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, fileSystemPathFilterEnable: event.target.checked }, true);
}}
/>
}
>
<ListItemText
primary={t('AddWorkspace.UseFilter')}
secondary={t('AddWorkspace.UseFilterHelp')}
/>
</ListItem>
</List>
{fileSystemPathFilterEnable && (
<TextField
fullWidth
multiline
minRows={2}
maxRows={10}
value={fileSystemPathFilter ?? ''}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, fileSystemPathFilter: event.target.value || null }, true);
}}
label={t('AddWorkspace.FilterExpression')}
helperText={t('AddWorkspace.FilterExpressionHelp')}
sx={{ mb: 2 }}
/>
)}
</AccordionDetails>
</OptionsAccordion>
<AppearanceOptions workspace={workspace} workspaceSetter={workspaceSetter} />
<SaveAndSyncOptions workspace={workspace} workspaceSetter={workspaceSetter} rememberLastPageVisited={rememberLastPageVisited} />
{showSubWorkspaceRouting && isWiki && (
<SubWorkspaceRouting
workspace={workspace}
workspaceSetter={workspaceSetter}
availableTags={availableTags}
isSubWiki={isSubWiki}
/>
)}
<OptionsAccordion>
<Tooltip title={t('EditWorkspace.ClickToExpand')}>
<OptionsAccordionSummary expandIcon={<ExpandMoreIcon />} data-testid='preference-section-miscOptions'>
{t('EditWorkspace.MiscOptions')}
</OptionsAccordionSummary>
</Tooltip>
<AccordionDetails>
{!isSubWiki && (
<List>
<Divider />
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={hibernateWhenUnused}
data-testid='hibernate-when-unused-switch'
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, hibernateWhenUnused: event.target.checked });
}}
/>
}
>
<ListItemText primary={t('EditWorkspace.HibernateTitle')} secondary={t('EditWorkspace.HibernateDescription')} />
</ListItem>
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={disableNotifications}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, disableNotifications: event.target.checked });
}}
/>
}
>
<ListItemText primary={t('EditWorkspace.DisableNotificationTitle')} secondary={t('EditWorkspace.DisableNotification')} />
</ListItem>
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={disableAudio}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, disableAudio: event.target.checked });
}}
/>
}
>
<ListItemText primary={t('EditWorkspace.DisableAudioTitle')} secondary={t('EditWorkspace.DisableAudio')} />
</ListItem>
<Divider />
<ListItem
disableGutters
secondaryAction={
<Switch
edge='end'
color='primary'
checked={enableFileSystemWatch}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({ ...workspace, enableFileSystemWatch: event.target.checked }, true);
}}
/>
}
>
<ListItemText
primary={t('EditWorkspace.EnableFileSystemWatchTitle')}
secondary={t('EditWorkspace.EnableFileSystemWatchDescription')}
/>
</ListItem>
</List>
)}
{!isSubWiki && rememberLastPageVisited && (
<TextField
id='outlined-full-width'
label={t('EditWorkspace.LastVisitState')}
helperText={t('Preference.RememberLastVisitState')}
placeholder={homeUrl}
value={lastUrl}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
workspaceSetter({
...workspace,
lastUrl: (event.target.value || homeUrl) ?? '',
});
}}
/>
)}
</AccordionDetails>
</OptionsAccordion>
<MiscOptions workspace={workspace} workspaceSetter={workspaceSetter} rememberLastPageVisited={rememberLastPageVisited} />
</FlexGrow>
{!isEqual(omit(workspace, nonConfigFields), omit(originalWorkspace, nonConfigFields)) && (
<SaveCancelButtonsContainer>

View file

@ -0,0 +1,111 @@
import { Accordion, AccordionSummary, Paper } from '@mui/material';
import { Button as ButtonRaw, TextField as TextFieldRaw, Typography } from '@mui/material';
import { css, styled } from '@mui/material/styles';
import React, { ReactNode } from 'react';
export const OptionsAccordion = styled((props: React.ComponentProps<typeof Accordion>) => <Accordion {...props} />)`
box-shadow: unset;
background-color: unset;
`;
export const OptionsAccordionSummary = styled((props: React.ComponentProps<typeof AccordionSummary>) => <AccordionSummary {...props} />)`
padding: 0;
flex-direction: row-reverse;
`;
export const Root = styled((props: React.ComponentProps<typeof Paper>) => <Paper {...props} />)`
height: 100%;
width: 100%;
padding: 20px;
/** for SaveCancelButtonsContainer 's height */
margin-bottom: 40px;
display: flex;
flex-direction: column;
background: ${({ theme }) => theme.palette.background.paper};
`;
export const FlexGrow = styled('div')`
flex: 1;
`;
export const Button = styled((props: React.ComponentProps<typeof ButtonRaw>) => <ButtonRaw {...props} />)`
float: right;
margin-left: 10px;
`;
export const TextField = styled((props: React.ComponentProps<typeof TextFieldRaw>) => (
<TextFieldRaw fullWidth margin='dense' size='small' variant='filled' slotProps={{ inputLabel: { shrink: true } }} {...props} />
))`
margin-bottom: 10px;
`;
export const AvatarFlex = styled('div')`
display: flex;
`;
export const AvatarLeft = styled('div')`
padding-top: 10px;
padding-bottom: 10px;
padding-left: 0;
padding-right: 10px;
`;
export const AvatarRight = styled('div')`
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: flex-start;
padding-top: 10px;
padding-bottom: 10px;
padding-left: 10px;
padding-right: 0;
`;
export const Avatar = styled('div')<{ transparentBackground?: boolean }>`
height: 85px;
width: 85px;
border-radius: 4px;
color: #333;
font-size: 32px;
line-height: 64px;
text-align: center;
font-weight: 500;
text-transform: uppercase;
user-select: none;
overflow: hidden;
${({ transparentBackground }) => {
if (transparentBackground === true) {
return css`
background: transparent;
border: none;
border-radius: 0;
`;
}
}}
`;
export const SaveCancelButtonsContainer = styled('div')`
position: fixed;
left: 0;
bottom: 0;
height: auto;
width: 100%;
padding: 5px;
background-color: ${({ theme }) => theme.palette.background.paper};
opacity: 0.9;
backdrop-filter: blur(10px);
`;
export const AvatarPicture = styled('img')`
height: 100%;
width: 100%;
`;
export const PictureButton = styled((props: React.ComponentProps<typeof ButtonRaw>) => <ButtonRaw variant='outlined' size='small' {...props} />)``;
export const _Caption = styled((props: { children?: ReactNode } & React.ComponentProps<typeof Typography>) => <Typography variant='caption' {...props} />)`
display: block;
`;