@jbrowse/plugin-menus 4.2.1 → 5.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import type { IAnyStateTreeNode } from '@jbrowse/mobx-state-tree';
2
2
  declare const AboutWidget: ({ model, }: {
3
3
  model: IAnyStateTreeNode;
4
- }) => import("react/jsx-runtime").JSX.Element;
4
+ }) => import("react").JSX.Element;
5
5
  export default AboutWidget;
@@ -3,7 +3,10 @@ import { ExternalLink } from '@jbrowse/core/ui';
3
3
  import { getSession } from '@jbrowse/core/util';
4
4
  import { makeStyles } from '@jbrowse/core/util/tss-react';
5
5
  import { getEnv } from '@jbrowse/mobx-state-tree';
6
- import { Typography } from '@mui/material';
6
+ import { effectiveRenderer } from '@jbrowse/render-core/graphicsCapabilities';
7
+ import { useGraphicsCapabilities } from '@jbrowse/render-core/useGraphicsCapabilities';
8
+ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
9
+ import { Accordion, AccordionDetails, AccordionSummary, Typography, } from '@mui/material';
7
10
  import { observer } from 'mobx-react';
8
11
  const useStyles = makeStyles()(theme => ({
9
12
  root: {
@@ -13,27 +16,33 @@ const useStyles = makeStyles()(theme => ({
13
16
  subtitle: {
14
17
  margin: theme.spacing(1),
15
18
  },
16
- pluginList: {
17
- margin: theme.spacing(1),
18
- marginTop: theme.spacing(5),
19
+ accordions: {
20
+ marginTop: theme.spacing(3),
21
+ },
22
+ icon: {
23
+ color: theme.palette.tertiary.contrastText || '#fff',
19
24
  },
20
25
  }));
21
26
  const AboutWidget = observer(function AboutWidget({ model, }) {
22
27
  const { classes } = useStyles();
23
- const { version } = getSession(model);
28
+ const session = getSession(model);
29
+ const { version, gitCommit } = session;
24
30
  const { pluginManager } = getEnv(model);
25
31
  const { plugins } = pluginManager;
32
+ const graphicsCapabilities = useGraphicsCapabilities();
33
+ const defaultRpcDriver = session.rpcManager.driverName;
26
34
  const corePlugins = new Set(plugins
27
35
  .filter(p => pluginManager.pluginMetadata[p.name]?.isCore)
28
36
  .map(p => p.name));
29
- return (_jsxs("div", { className: classes.root, children: [_jsx(Typography, { variant: "h4", align: "center", children: "JBrowse 2" }), _jsx(Typography, { variant: "h6", align: "center", className: classes.subtitle, children: version }), _jsxs(Typography, { align: "center", children: ["JBrowse is a ", _jsx(ExternalLink, { href: "http://gmod.org/", children: "GMOD" }), ' ', "project"] }), _jsx("br", {}), _jsx(Typography, { align: "center", children: "\u00A9 2019-2022 The Evolutionary Software Foundation" }), _jsxs("div", { className: classes.pluginList, children: [_jsx(Typography, { children: "External plugins loaded" }), _jsx("ul", { children: plugins
30
- .filter(plugin => !corePlugins.has(plugin.name))
31
- .map(plugin => {
32
- const { url, name, version = '' } = plugin;
33
- const text = `${name} ${version || ''}`;
34
- return (_jsx("li", { children: plugin.url ? (_jsx(ExternalLink, { href: url, children: text })) : (_jsx(Typography, { children: text })) }, plugin.name));
35
- }) }), _jsx(Typography, { children: "Core plugins loaded" }), _jsx("ul", { children: plugins
36
- .filter(plugin => corePlugins.has(plugin.name))
37
- .map(plugin => (_jsx("li", { children: _jsxs(Typography, { children: [plugin.name, " ", plugin.version || ''] }) }, plugin.name))) })] })] }));
37
+ const externalPlugins = plugins.filter(plugin => !corePlugins.has(plugin.name));
38
+ return (_jsxs("div", { className: classes.root, children: [_jsx(Typography, { variant: "h4", align: "center", children: "JBrowse 2" }), _jsx(Typography, { variant: "h6", align: "center", className: classes.subtitle, children: version }), gitCommit ? (_jsxs(Typography, { variant: "body2", align: "center", children: ["Commit: ", gitCommit] })) : null, _jsxs(Typography, { align: "center", children: ["JBrowse is a ", _jsx(ExternalLink, { href: "https://gmod.org/", children: "GMOD" }), ' ', "project"] }), _jsx("br", {}), _jsx(Typography, { align: "center", children: "\u00A9 2019-2026 The Evolutionary Software Foundation" }), _jsxs("div", { className: classes.accordions, children: [_jsxs(Accordion, { defaultExpanded: true, children: [_jsx(AccordionSummary, { expandIcon: _jsx(ExpandMoreIcon, { className: classes.icon }), children: _jsx(Typography, { children: "Browser settings" }) }), _jsx(AccordionDetails, { children: _jsxs("ul", { children: [graphicsCapabilities ? (_jsxs("li", { children: ["Graphics: ", effectiveRenderer(graphicsCapabilities)] })) : null, _jsxs("li", { children: ["Rendering:", ' ', defaultRpcDriver === 'WebWorkerRpcDriver'
39
+ ? 'off main thread'
40
+ : 'on main thread'] })] }) })] }), _jsxs(Accordion, { defaultExpanded: true, children: [_jsx(AccordionSummary, { expandIcon: _jsx(ExpandMoreIcon, { className: classes.icon }), children: _jsx(Typography, { children: "Plugins loaded" }) }), _jsxs(AccordionDetails, { children: [_jsx(Typography, { children: "External plugins" }), externalPlugins.length ? (_jsx("ul", { children: externalPlugins.map(plugin => {
41
+ const { url, name, version = '' } = plugin;
42
+ const text = `${name} ${version || ''}`;
43
+ return (_jsx("li", { children: plugin.url ? (_jsx(ExternalLink, { href: url, children: text })) : (_jsx("span", { children: text })) }, plugin.name));
44
+ }) })) : (_jsx(Typography, { children: "No external plugins loaded" })), _jsx(Typography, { children: "Core plugins" }), _jsx("ul", { children: plugins
45
+ .filter(plugin => corePlugins.has(plugin.name))
46
+ .map(plugin => (_jsxs("li", { children: [plugin.name, " ", plugin.version || ''] }, plugin.name))) })] })] })] })] }));
38
47
  });
39
48
  export default AboutWidget;
@@ -15,7 +15,7 @@ export default function AboutWidgetF(pluginManager) {
15
15
  heading: 'About',
16
16
  configSchema,
17
17
  stateModel,
18
- ReactComponent: lazy(() => import("./components/AboutWidget.js")),
18
+ ReactComponent: lazy(() => import('./components/AboutWidget.js')),
19
19
  });
20
20
  });
21
21
  }
@@ -1,5 +1,5 @@
1
1
  import type { IAnyStateTreeNode } from '@jbrowse/mobx-state-tree';
2
2
  declare const HelpWidget: ({ model, }: {
3
3
  model?: IAnyStateTreeNode;
4
- }) => import("react/jsx-runtime").JSX.Element;
4
+ }) => import("react").JSX.Element;
5
5
  export default HelpWidget;
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ExternalLink } from '@jbrowse/core/ui';
3
3
  import { getSession } from '@jbrowse/core/util';
4
4
  import { makeStyles } from '@jbrowse/core/util/tss-react';
5
- import { Typography } from '@mui/material';
5
+ import { Table, TableBody, TableCell, TableRow, Typography, } from '@mui/material';
6
6
  import { observer } from 'mobx-react';
7
7
  const useStyles = makeStyles()(theme => ({
8
8
  root: {
@@ -11,10 +11,33 @@ const useStyles = makeStyles()(theme => ({
11
11
  subtitle: {
12
12
  margin: theme.spacing(1),
13
13
  },
14
+ section: {
15
+ marginTop: theme.spacing(2),
16
+ },
17
+ key: {
18
+ fontFamily: 'monospace',
19
+ whiteSpace: 'nowrap',
20
+ },
14
21
  }));
22
+ // The keyboard shortcuts have existed since the LGV grew `keyboardHandler.ts`
23
+ // and were, until this list, written down nowhere a user could reach — so in
24
+ // practice they did not exist. They belong here rather than in a dialog of their
25
+ // own: this widget is already what the Help menu opens, and a second surface
26
+ // would be one more thing to find. If the list ever outgrows a paragraph, that
27
+ // is the moment to reconsider, not before.
28
+ //
29
+ // Spelled "Ctrl/Cmd" rather than branched on the platform: every reliable way to
30
+ // detect macOS is deprecated (`navigator.platform`) or a user-agent sniff, and
31
+ // naming both modifiers costs one word.
32
+ const shortcuts = [
33
+ ['Ctrl/Cmd + ←', 'Pan left'],
34
+ ['Ctrl/Cmd + →', 'Pan right'],
35
+ ['Ctrl/Cmd + ↑', 'Zoom in'],
36
+ ['Ctrl/Cmd + ↓', 'Zoom out'],
37
+ ];
15
38
  const HelpWidget = observer(function Help({ model, }) {
16
39
  const { classes } = useStyles();
17
40
  const root = model ? getSession(model) : { version: '' };
18
- return (_jsxs("div", { className: classes.root, children: [_jsx(Typography, { variant: "h4", align: "center", children: "JBrowse 2" }), _jsx(Typography, { variant: "h6", align: "center", className: classes.subtitle, children: root.version }), _jsx(Typography, { children: "Here are some resources to get help. Please report the version number above when asking questions. Thanks!" }), _jsxs("ul", { children: [_jsx("li", { children: _jsx(ExternalLink, { href: "https://github.com/GMOD/jbrowse-components/discussions", children: "Question & answer forum" }) }), _jsx("li", { children: _jsx(ExternalLink, { href: "https://github.com/GMOD/jbrowse-components/issues/new/choose", children: "Report a bug" }) }), _jsx("li", { children: _jsx(ExternalLink, { href: "https://jbrowse.org/jb2/docs/user_guide", children: "User guide" }) }), _jsx("li", { children: _jsx(ExternalLink, { href: "https://jbrowse.org/jb2/docs/", children: "Documentation" }) })] })] }));
41
+ return (_jsxs("div", { className: classes.root, children: [_jsx(Typography, { variant: "h4", align: "center", children: "JBrowse 2" }), _jsx(Typography, { variant: "h6", align: "center", className: classes.subtitle, children: root.version }), _jsx(Typography, { children: "Here are some resources to get help. Please report the version number above when asking questions. Thanks!" }), _jsxs("ul", { children: [_jsx("li", { children: _jsx(ExternalLink, { href: "https://github.com/GMOD/jbrowse-components/discussions", children: "Question & answer forum" }) }), _jsx("li", { children: _jsx(ExternalLink, { href: "https://github.com/GMOD/jbrowse-components/issues/new/choose", children: "Report a bug" }) }), _jsx("li", { children: _jsx(ExternalLink, { href: "https://jbrowse.org/jb2/docs/user_guide", children: "User guide" }) }), _jsx("li", { children: _jsx(ExternalLink, { href: "https://jbrowse.org/jb2/docs/", children: "Documentation" }) })] }), _jsxs("div", { className: classes.section, children: [_jsx(Typography, { variant: "h6", children: "Keyboard shortcuts" }), _jsx(Typography, { children: "These act on the focused view. Tab moves between views, or click one; the focused view is the one with the arrow beside its title." }), _jsx(Table, { size: "small", children: _jsx(TableBody, { children: shortcuts.map(([keys, description]) => (_jsxs(TableRow, { children: [_jsx(TableCell, { className: classes.key, children: keys }), _jsx(TableCell, { children: description })] }, keys))) }) })] })] }));
19
42
  });
20
43
  export default HelpWidget;
@@ -15,7 +15,7 @@ export default function HelpWidgetF(pluginManager) {
15
15
  heading: 'Help',
16
16
  configSchema,
17
17
  stateModel,
18
- ReactComponent: lazy(() => import("./components/HelpWidget.js")),
18
+ ReactComponent: lazy(() => import('./components/HelpWidget.js')),
19
19
  });
20
20
  });
21
21
  }
@@ -1,3 +1,3 @@
1
1
  export default function ImportError({ error }: {
2
2
  error: unknown;
3
- }): import("react/jsx-runtime").JSX.Element;
3
+ }): import("react").JSX.Element;
@@ -18,5 +18,5 @@ const useStyles = makeStyles()(theme => ({
18
18
  }));
19
19
  export default function ImportError({ error }) {
20
20
  const { classes } = useStyles();
21
- return (_jsxs(Paper, { className: classes.error, children: [_jsxs("div", { className: classes.errorHeader, children: [_jsx(ErrorIcon, { color: "inherit", fontSize: "large" }), _jsx("div", { children: _jsx(Typography, { variant: "h6", color: "inherit", align: "center", children: "Import error" }) })] }), _jsx(Typography, { className: classes.errorMessage, children: `${error}` })] }));
21
+ return (_jsxs(Paper, { className: classes.error, children: [_jsxs("div", { className: classes.errorHeader, children: [_jsx(ErrorIcon, { color: "inherit", fontSize: "large" }), _jsx(Typography, { variant: "h6", color: "inherit", align: "center", children: "Import error" })] }), _jsx(Typography, { className: classes.errorMessage, children: `${error}` })] }));
22
22
  }
@@ -1,5 +1,5 @@
1
1
  import type { IAnyStateTreeNode } from '@jbrowse/mobx-state-tree';
2
2
  declare const ImportSessionWidget: ({ model, }: {
3
3
  model: IAnyStateTreeNode;
4
- }) => import("react/jsx-runtime").JSX.Element;
4
+ }) => import("react").JSX.Element;
5
5
  export default ImportSessionWidget;
@@ -1,69 +1,44 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
+ import { FileDropZone } from '@jbrowse/core/ui';
3
4
  import { getSession } from '@jbrowse/core/util';
4
5
  import { makeStyles } from '@jbrowse/core/util/tss-react';
5
- import CloudUploadIcon from '@mui/icons-material/CloudUpload';
6
- import { Button, Paper, Typography, alpha } from '@mui/material';
6
+ import { Button, Paper, Typography } from '@mui/material';
7
7
  import { observer } from 'mobx-react';
8
- import { useDropzone } from 'react-dropzone';
9
- import ImportError from "./ImportError.js";
10
- const MAX_FILE_SIZE = 512 * 1024 ** 2;
8
+ import ImportError from './ImportError.js';
9
+ const MAX_FILE_SIZE = 512 * 1024 ** 2; // 512 MiB
11
10
  const useStyles = makeStyles()(theme => ({
12
- root: {
13
- margin: theme.spacing(1),
14
- },
15
11
  paper: {
16
- display: 'flex',
17
- flexDirection: 'column',
18
- },
19
- dropZone: {
20
- textAlign: 'center',
21
- margin: theme.spacing(2),
22
- padding: theme.spacing(2),
23
- borderWidth: 2,
24
- borderRadius: 2,
25
- borderStyle: 'dashed',
26
- outline: 'none',
27
- transition: 'border .24s ease-in-out, background-color .24s ease-in-out',
28
- '&:focus': {
29
- borderColor: theme.palette.secondary.light,
30
- },
31
- },
32
- dropZoneActive: {
33
- borderColor: theme.palette.secondary.light,
34
- backgroundColor: alpha(theme.palette.text.primary, theme.palette.action.hoverOpacity),
35
- },
36
- dropZoneInactive: {
37
- borderColor: theme.palette.divider,
38
- backgroundColor: theme.palette.background.default,
39
- },
40
- uploadIcon: {
41
- color: theme.palette.text.secondary,
12
+ margin: theme.spacing(1),
13
+ padding: theme.spacing(1),
42
14
  },
43
15
  }));
44
16
  const ImportSessionWidget = observer(function ImportSessionWidget({ model, }) {
45
17
  const [error, setError] = useState();
46
- const { getRootProps, getInputProps, isDragActive } = useDropzone({
47
- accept: 'application/json',
48
- maxSize: MAX_FILE_SIZE,
49
- multiple: false,
50
- onDrop: async (acceptedFiles, rejectedFiles) => {
51
- try {
52
- if (rejectedFiles.length > 0) {
53
- throw new Error(rejectedFiles[0].errors.map(e => `${e}`).join(', '));
54
- }
55
- const sessionText = await acceptedFiles[0].text();
56
- getSession(model).setSession?.(JSON.parse(sessionText).session);
57
- }
58
- catch (e) {
59
- console.error(e);
60
- setError(e);
61
- }
62
- },
63
- });
64
- const { classes, cx } = useStyles();
65
- return (_jsxs("div", { className: classes.root, children: [_jsx(Paper, { className: classes.paper, children: _jsxs("div", { ...getRootProps({
66
- className: cx(classes.dropZone, isDragActive ? classes.dropZoneActive : classes.dropZoneInactive),
67
- }), children: [_jsx("input", { ...getInputProps() }), _jsx(CloudUploadIcon, { className: classes.uploadIcon, fontSize: "large" }), _jsx(Typography, { color: "textSecondary", align: "center", variant: "body1", children: "Drag and drop files here" }), _jsx(Typography, { color: "textSecondary", align: "center", variant: "body2", children: "or" }), _jsx(Button, { color: "primary", variant: "contained", children: "Browse Files" })] }) }), error ? _jsx(ImportError, { error: error }) : null] }));
18
+ const { classes } = useStyles();
19
+ return (_jsxs(_Fragment, { children: [_jsx(Paper, { className: classes.paper, children: _jsxs(FileDropZone, { accept: { 'application/json': ['.json'] }, maxSize: MAX_FILE_SIZE, multiple: false, message: "Drag and drop a session file here", onDrop: async (acceptedFiles, rejectedFiles) => {
20
+ try {
21
+ setError(undefined);
22
+ if (rejectedFiles.length > 0) {
23
+ throw new Error(rejectedFiles[0].errors.map(e => e.message).join(', '));
24
+ }
25
+ const sessionText = await acceptedFiles[0].text();
26
+ const parsed = JSON.parse(sessionText);
27
+ if (!parsed || typeof parsed !== 'object') {
28
+ throw new Error('File does not contain a JSON object');
29
+ }
30
+ // session exports wrap the session under a top-level "session"
31
+ // key; fall back to treating the whole object as the session
32
+ const session = 'session' in parsed ? parsed.session : parsed;
33
+ if (!session || typeof session !== 'object') {
34
+ throw new Error('No session found in file. Expected a JBrowse session export (a JSON file with a top-level "session" key).');
35
+ }
36
+ getSession(model).setSession?.(session);
37
+ }
38
+ catch (e) {
39
+ console.error(e);
40
+ setError(e);
41
+ }
42
+ }, children: [_jsx(Typography, { color: "text.secondary", align: "center", variant: "body2", children: "or" }), _jsx(Button, { color: "primary", variant: "contained", children: "Browse Files" })] }) }), error ? _jsx(ImportError, { error: error }) : null] }));
68
43
  });
69
44
  export default ImportSessionWidget;
@@ -15,7 +15,7 @@ export default function ImportSessionWidgetF(pluginManager) {
15
15
  heading: 'Import session',
16
16
  configSchema,
17
17
  stateModel,
18
- ReactComponent: lazy(() => import("./components/ImportSessionWidget.js")),
18
+ ReactComponent: lazy(() => import('./components/ImportSessionWidget.js')),
19
19
  });
20
20
  });
21
21
  }
@@ -0,0 +1,12 @@
1
+ import type { SessionModel } from './util.ts';
2
+ /**
3
+ * Confirmation for the bulk "delete old sessions" action, which is the one
4
+ * control here that can destroy many sessions at once and was previously a
5
+ * single unconfirmed click. Shows how many sessions the chosen cutoff actually
6
+ * matches, so "delete" is never a guess.
7
+ */
8
+ declare const DeleteOldSessionsDialog: ({ session, onClose, }: {
9
+ session: SessionModel;
10
+ onClose: () => void;
11
+ }) => import("react").JSX.Element;
12
+ export default DeleteOldSessionsDialog;
@@ -0,0 +1,36 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { ConfirmDialog } from '@jbrowse/core/ui';
4
+ import { MenuItem, TextField, Typography } from '@mui/material';
5
+ import { observer } from 'mobx-react';
6
+ import { STALE_CUTOFFS, staleSessions } from './util.js';
7
+ /**
8
+ * Confirmation for the bulk "delete old sessions" action, which is the one
9
+ * control here that can destroy many sessions at once and was previously a
10
+ * single unconfirmed click. Shows how many sessions the chosen cutoff actually
11
+ * matches, so "delete" is never a guess.
12
+ */
13
+ const DeleteOldSessionsDialog = observer(function DeleteOldSessionsDialog({ session, onClose, }) {
14
+ const [days, setDays] = useState(STALE_CUTOFFS[0].days);
15
+ const matched = staleSessions(session.savedSessionMetadata, {
16
+ days,
17
+ openSessionId: session.id,
18
+ });
19
+ return (_jsxs(ConfirmDialog, { open: true, title: "Delete old sessions", submitText: "Delete", submitColor: "error", submitDisabled: matched.length === 0, onCancel: onClose, onSubmit: () => {
20
+ session
21
+ .deleteSavedSessions(matched.map(m => m.id))
22
+ .then(() => {
23
+ session.notify(`${matched.length} session${matched.length === 1 ? '' : 's'} deleted`, 'success');
24
+ })
25
+ .catch((e) => {
26
+ console.error(e);
27
+ session.notifyError(`${e}`, e);
28
+ });
29
+ onClose();
30
+ }, children: [_jsx(TextField, { select: true, fullWidth: true, label: "Delete sessions last used more than", value: days, onChange: event => {
31
+ setDays(+event.target.value);
32
+ }, children: STALE_CUTOFFS.map(cutoff => (_jsxs(MenuItem, { value: cutoff.days, children: [cutoff.label, " ago"] }, cutoff.days))) }), _jsx(Typography, { children: matched.length === 0
33
+ ? 'No sessions match — favorites and the session you have open are never deleted.'
34
+ : `${matched.length} session${matched.length === 1 ? '' : 's'} will be deleted. Favorites and the session you have open are kept.` })] }));
35
+ });
36
+ export default DeleteOldSessionsDialog;
@@ -1,5 +1,5 @@
1
1
  import type { SessionModel } from './util.ts';
2
2
  declare const SessionManager: ({ session, }: {
3
3
  session: SessionModel;
4
- }) => import("react/jsx-runtime").JSX.Element;
4
+ }) => import("react").JSX.Element;
5
5
  export default SessionManager;
@@ -1,92 +1,130 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { LabeledCheckbox } from '@jbrowse/core/ui';
2
4
  import DataGridFlexContainer from '@jbrowse/core/ui/DataGridFlexContainer';
3
- import { measureGridWidth, useLocalStorage } from '@jbrowse/core/util';
5
+ import { useLocalStorage } from '@jbrowse/core/util/hooks';
4
6
  import { makeStyles } from '@jbrowse/core/util/tss-react';
5
- import DeleteIcon from '@mui/icons-material/Delete';
6
- import StarIcon from '@mui/icons-material/Star';
7
- import StarBorderIcon from '@mui/icons-material/StarBorder';
8
- import { Button, Checkbox, FormControlLabel, IconButton, Link, Tooltip, } from '@mui/material';
7
+ import { Button, Typography } from '@mui/material';
9
8
  import { DataGrid } from '@mui/x-data-grid';
10
- import { differenceInDays, formatDistanceToNow } from 'date-fns';
11
9
  import { observer } from 'mobx-react';
10
+ import DeleteOldSessionsDialog from './DeleteOldSessionsDialog.js';
11
+ import { DeleteCell, FavoriteCell, LastUsedCell, NameCell } from './cells.js';
12
+ import { sessionLastUsed } from './util.js';
12
13
  const useStyles = makeStyles()(theme => ({
14
+ // The widget fills its drawer and the grid takes what is left below the
15
+ // controls. Not cosmetic: MUI's DataGrid draws its horizontal scrollbar as an
16
+ // absolutely positioned overlay at the bottom of its own box, so a grid sized
17
+ // to its content puts that 14px bar *on top of the last row* — and z-index 60
18
+ // wins the hit test, so the bottom session silently stops responding to
19
+ // clicks. Given room, the bar lands below every row.
20
+ root: {
21
+ display: 'flex',
22
+ flexDirection: 'column',
23
+ height: '100%',
24
+ },
25
+ grid: {
26
+ flex: 1,
27
+ // a flex child defaults to min-height:auto, which refuses to shrink below
28
+ // its content and would push the grid past the drawer instead of scrolling
29
+ minHeight: 0,
30
+ },
13
31
  mb: {
14
32
  margin: theme.spacing(1),
15
33
  marginBottom: theme.spacing(4),
16
34
  },
17
35
  }));
36
+ /**
37
+ * The rows of the grid: the saved-session metadata the root model read out of
38
+ * IndexedDB, in display shape. `undefined` in, `undefined` out — that is the
39
+ * "database still opening" state, and it is deliberately not the same value as
40
+ * an empty database (see the render below).
41
+ */
42
+ function buildRows(session, showOnlyFavs) {
43
+ return session.savedSessionMetadata
44
+ ?.map(r => {
45
+ const current = r.id === session.id;
46
+ return {
47
+ id: r.id,
48
+ name: r.name,
49
+ label: current ? `${r.name} (current)` : r.name,
50
+ createdAt: r.createdAt,
51
+ lastUsed: sessionLastUsed(r),
52
+ fav: r.favorite,
53
+ current,
54
+ };
55
+ })
56
+ .filter(f => !showOnlyFavs || f.fav);
57
+ }
58
+ /**
59
+ * One entry per column, each deferring to a named cell in cells.tsx so this
60
+ * reads as the shape of the table rather than as four inline components.
61
+ *
62
+ * Name takes the leftover width (`flex`) rather than a width measured from the
63
+ * session names, and the three fixed columns are kept narrow enough that their
64
+ * sum plus Name's minWidth fits a default-width drawer. `measureGridWidth`
65
+ * hands back a *fixed* width — up to 1000px for a long session name — and any
66
+ * total past the panel width grows the horizontal scrollbar this widget's
67
+ * styles exist to keep off the rows (see useStyles).
68
+ */
69
+ function buildColumns(session) {
70
+ return [
71
+ {
72
+ field: 'fav',
73
+ headerName: 'Fav',
74
+ // the header of an icon column is short, and its sort arrow and column
75
+ // menu are what push it into an ellipsis. Only sorting earns its space
76
+ // here -- grouping the favorites is a real thing to want -- so the menu
77
+ // goes and the header reads.
78
+ width: 60,
79
+ disableColumnMenu: true,
80
+ renderCell: ({ row }) => (_jsx(FavoriteCell, { row: row, session: session })),
81
+ },
82
+ {
83
+ field: 'name',
84
+ headerName: 'Name',
85
+ editable: true,
86
+ flex: 1,
87
+ minWidth: 120,
88
+ renderCell: ({ row }) => (_jsx(NameCell, { row: row, session: session })),
89
+ },
90
+ {
91
+ field: 'lastUsed',
92
+ headerName: 'Last used',
93
+ width: 100,
94
+ renderCell: ({ row }) => (_jsx(LastUsedCell, { row: row, session: session })),
95
+ },
96
+ {
97
+ field: 'delete',
98
+ headerName: 'Delete',
99
+ // nothing to sort or filter on a column of buttons, and dropping both
100
+ // gives the header room to say "Delete" instead of "Del..."
101
+ width: 66,
102
+ sortable: false,
103
+ filterable: false,
104
+ disableColumnMenu: true,
105
+ renderCell: ({ row }) => (_jsx(DeleteCell, { row: row, session: session })),
106
+ },
107
+ ];
108
+ }
18
109
  const SessionManager = observer(function SessionManager({ session, }) {
19
110
  const { classes } = useStyles();
111
+ const [deleteOldOpen, setDeleteOldOpen] = useState(false);
20
112
  const [showOnlyFavs, setShowOnlyFavs] = useLocalStorage('sessionManager-showOnlyFavs', false);
21
- const rows = session.savedSessionMetadata
22
- ?.map(r => ({
23
- id: r.id,
24
- name: r.name,
25
- createdAt: r.createdAt,
26
- fav: r.favorite,
27
- }))
28
- .filter(f => (showOnlyFavs ? f.fav : true));
29
- return (_jsxs("div", { children: [_jsxs("div", { className: classes.mb, children: [_jsx(FormControlLabel, { control: _jsx(Checkbox, { checked: showOnlyFavs, onChange: () => {
30
- setShowOnlyFavs(val => !val);
31
- } }), label: "Show favorites only?" }), _jsx(Button, { variant: "contained", onClick: () => {
32
- let i = 0;
33
- if (session.savedSessionMetadata) {
34
- for (const elt of session.savedSessionMetadata) {
35
- if (differenceInDays(Date.now(), elt.createdAt) > 1 &&
36
- !elt.favorite) {
37
- session.deleteSavedSession(elt.id);
38
- i++;
39
- }
40
- }
41
- }
42
- session.notify(`${i} sessions deleted`, 'info');
43
- }, children: "Delete non-fav sessions older than 7 days?" })] }), rows ? (_jsx(DataGridFlexContainer, { children: _jsx(DataGrid, { disableRowSelectionOnClick: true, columnHeaderHeight: 35, rowHeight: 25, hideFooter: rows.length < 100, slotProps: {
44
- toolbar: {
45
- showQuickFilter: true,
46
- },
47
- }, rows: rows, columns: [
48
- {
49
- field: 'fav',
50
- headerName: 'Fav',
51
- width: 20,
52
- renderCell: ({ row }) => (_jsx(IconButton, { onClick: () => {
53
- if (row.fav) {
54
- session.unfavoriteSavedSession(row.id);
55
- }
56
- else {
57
- session.favoriteSavedSession(row.id);
58
- }
59
- }, children: row.fav ? _jsx(StarIcon, {}) : _jsx(StarBorderIcon, {}) })),
60
- },
61
- {
62
- field: 'name',
63
- headerName: 'Name',
64
- editable: true,
65
- width: measureGridWidth(rows.map(r => r.name)),
66
- renderCell: ({ row }) => (_jsxs(_Fragment, { children: [_jsx(Link, { href: "#", onClick: event => {
67
- event.preventDefault();
68
- session.activateSession(row.id);
69
- }, children: row.name }), session.id === row.id ? ' (current)' : ''] })),
70
- },
71
- {
72
- headerName: 'Created at',
73
- field: 'createdAt',
74
- renderCell: ({ row }) => (_jsx(Tooltip, { disableInteractive: true, slotProps: {
75
- transition: {
76
- timeout: 0,
77
- },
78
- }, title: row.createdAt.toLocaleString(), children: _jsx("div", { children: formatDistanceToNow(row.createdAt, {
79
- addSuffix: true,
80
- }) }) })),
81
- },
82
- {
83
- field: 'delete',
84
- width: 10,
85
- headerName: 'Delete',
86
- renderCell: ({ row }) => (_jsx(IconButton, { onClick: () => {
87
- session.deleteSavedSession(row.id);
88
- }, children: _jsx(DeleteIcon, {}) })),
89
- },
90
- ] }) })) : (_jsx("div", { children: "No sessions loaded" }))] }));
113
+ const rows = buildRows(session, showOnlyFavs);
114
+ return (_jsxs("div", { className: classes.root, children: [_jsxs("div", { className: classes.mb, children: [_jsx(LabeledCheckbox, { checked: showOnlyFavs, onChange: val => {
115
+ setShowOnlyFavs(val);
116
+ }, label: "Show favorites only?" }), _jsx(Button, { variant: "contained", disabled: !rows, onClick: () => {
117
+ setDeleteOldOpen(true);
118
+ }, children: "Delete old sessions..." })] }), rows ? (_jsx(DataGridFlexContainer, { className: classes.grid, children: _jsx(DataGrid, { disableRowSelectionOnClick: true, columnHeaderHeight: 35, rowHeight: 25, hideFooter: rows.length < 100, showToolbar: true, slotProps: { toolbar: { showQuickFilter: true } }, rows: rows, columns: buildColumns(session), processRowUpdate: (newRow, oldRow) => {
119
+ if (newRow.name !== oldRow.name) {
120
+ void session.renameSavedSession(newRow.id, newRow.name);
121
+ }
122
+ return newRow;
123
+ }, onProcessRowUpdateError: e => {
124
+ console.error(e);
125
+ session.notifyError(`${e}`, e);
126
+ } }) })) : (_jsx(Typography, { className: classes.mb, children: "Loading saved sessions..." })), deleteOldOpen ? (_jsx(DeleteOldSessionsDialog, { session: session, onClose: () => {
127
+ setDeleteOldOpen(false);
128
+ } })) : null] }));
91
129
  });
92
130
  export default SessionManager;
@@ -0,0 +1,32 @@
1
+ import type { SessionModel } from './util.ts';
2
+ /**
3
+ * One row of the saved-session grid. `current` is precomputed rather than
4
+ * re-derived per cell: two cells turn on it, and it is the fact that decides
5
+ * what each of them is allowed to do (see DeleteCell).
6
+ */
7
+ export interface Row {
8
+ id: string;
9
+ /** the editable value — what a rename writes, never what the cell draws */
10
+ name: string;
11
+ /**
12
+ * what the name cell draws, which is `name` plus a "(current)" suffix on the
13
+ * open session. Precomputed so the column that *measures* the names to pick
14
+ * its width measures the same strings the cells render: measuring `name`
15
+ * alone left the current row — the one that is always at the top — as the one
16
+ * row ellipsed.
17
+ */
18
+ label: string;
19
+ createdAt: Date;
20
+ lastUsed: Date;
21
+ fav: boolean;
22
+ current: boolean;
23
+ }
24
+ interface CellProps {
25
+ row: Row;
26
+ session: SessionModel;
27
+ }
28
+ export declare const FavoriteCell: ({ row, session, }: CellProps) => import("react").JSX.Element;
29
+ export declare const NameCell: ({ row, session, }: CellProps) => import("react").JSX.Element;
30
+ export declare const LastUsedCell: ({ row }: CellProps) => import("react").JSX.Element;
31
+ export declare const DeleteCell: ({ row, session, }: CellProps) => import("react").JSX.Element;
32
+ export {};
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { ActionLink } from '@jbrowse/core/ui';
3
+ import { formatRelativeTime } from '@jbrowse/core/util';
4
+ import DeleteIcon from '@mui/icons-material/Delete';
5
+ import StarIcon from '@mui/icons-material/Star';
6
+ import StarBorderIcon from '@mui/icons-material/StarBorder';
7
+ import { IconButton, Tooltip } from '@mui/material';
8
+ import { observer } from 'mobx-react';
9
+ // Every icon-only control here carries the same string as its tooltip and its
10
+ // aria-label. Without the label a screen reader announces an unnamed button per
11
+ // row; without the tooltip a sighted user has to guess what the icon deletes.
12
+ const IconCell = observer(function IconCell({ label, disabled, onClick, children, }) {
13
+ return (_jsx(Tooltip, { disableInteractive: true, title: label, children: _jsx("span", { children: _jsx(IconButton, { "aria-label": label, disabled: disabled, onClick: onClick, children: children }) }) }));
14
+ });
15
+ export const FavoriteCell = observer(function FavoriteCell({ row, session, }) {
16
+ return (_jsx(IconCell, { label: row.fav ? 'Remove from favorites' : 'Add to favorites', onClick: () => {
17
+ void session.setSavedSessionFavorite(row.id, !row.fav);
18
+ }, children: row.fav ? _jsx(StarIcon, {}) : _jsx(StarBorderIcon, {}) }));
19
+ });
20
+ export const NameCell = observer(function NameCell({ row, session, }) {
21
+ // A native `title`, not a MUI Tooltip: the drawer is narrow enough that a
22
+ // long session name ellipses, and a tooltip here would mean a popper laid
23
+ // over the row underneath — which is exactly how this column lost its clicks
24
+ // once already (see the scrollbar note in SessionManager's useStyles).
25
+ //
26
+ // The open session is not offered as a link: re-opening it from IndexedDB can
27
+ // only lose work, since that row is up to one autosave tick behind the live
28
+ // model. The File menu leaves it out of the recent list for the same reason.
29
+ return row.current ? (_jsx("span", { title: row.label, children: row.label })) : (_jsx(ActionLink, { title: row.label, onClick: () => {
30
+ void session.activateSession(row.id);
31
+ }, children: row.label }));
32
+ });
33
+ // dated by last use, not creation: the rows arrive ordered by last use, so
34
+ // showing createdAt would read as an unsorted list. Both are in the tooltip,
35
+ // since an id survives reloads and the gap between them is often large.
36
+ export const LastUsedCell = observer(function LastUsedCell({ row }) {
37
+ return (_jsx(Tooltip, { disableInteractive: true, slotProps: { transition: { timeout: 0 } }, title: `Last used ${row.lastUsed.toLocaleString()}\nCreated ${row.createdAt.toLocaleString()}`, children: _jsx("div", { children: formatRelativeTime(row.lastUsed) }) }));
38
+ });
39
+ export const DeleteCell = observer(function DeleteCell({ row, session, }) {
40
+ // the open session is rewritten by the autosave autorun every 400ms, so
41
+ // deleting it only makes it vanish until the next edit puts it back — say so
42
+ // on a disabled button rather than on a snackbar after the click
43
+ return (_jsx(IconCell, { label: row.current
44
+ ? 'Cannot delete the session that is currently open'
45
+ : `Delete session ${row.name}`, disabled: row.current, onClick: () => {
46
+ void session.deleteSavedSession(row.id);
47
+ }, children: _jsx(DeleteIcon, {}) }));
48
+ });
@@ -3,24 +3,42 @@ export interface SessionMetadata {
3
3
  id: string;
4
4
  name: string;
5
5
  createdAt: Date;
6
+ updatedAt?: Date;
6
7
  configPath: string;
7
8
  favorite: boolean;
8
9
  }
9
- export interface SessionSnap {
10
- createdAt: Date;
11
- session: {
12
- name: string;
13
- id: string;
14
- views?: {
15
- tracks?: unknown[];
16
- }[];
17
- [key: string]: unknown;
18
- };
19
- }
10
+ export declare function sessionLastUsed(m: SessionMetadata): Date;
20
11
  export interface SessionModel extends AbstractSessionModel {
21
12
  savedSessionMetadata?: SessionMetadata[];
22
- removeSavedSession: (arg: SessionSnap) => void;
23
- activateSession: (arg: string) => void;
24
- loadAutosaveSession: () => void;
25
- previousAutosaveId: string;
13
+ activateSession: (id: string) => Promise<void>;
14
+ deleteSavedSession: (id: string) => Promise<void>;
15
+ deleteSavedSessions: (ids: string[]) => Promise<void>;
16
+ setSavedSessionFavorite: (id: string, favorite: boolean) => Promise<void>;
17
+ renameSavedSession: (id: string, name: string) => Promise<void>;
26
18
  }
19
+ export declare const STALE_CUTOFFS: readonly [{
20
+ readonly label: '1 day';
21
+ readonly days: 1;
22
+ }, {
23
+ readonly label: '1 week';
24
+ readonly days: 7;
25
+ }, {
26
+ readonly label: '1 month';
27
+ readonly days: 30;
28
+ }];
29
+ /**
30
+ * The sessions a bulk "delete old sessions" would remove: unfavorited, not the
31
+ * one currently open, and last *used* — not created — longer ago than the
32
+ * cutoff. An id survives reloads, so a session edited every day still carries
33
+ * the createdAt of the day it was opened, and ageing by that would delete live
34
+ * work out from under the user.
35
+ *
36
+ * The open session is excluded here as well as in the model's
37
+ * deleteSavedSessions, so the count the confirmation quotes is the number of
38
+ * sessions that will actually go.
39
+ */
40
+ export declare function staleSessions(metadata: SessionMetadata[] | undefined, { days, openSessionId, now, }: {
41
+ days: number;
42
+ openSessionId?: string;
43
+ now?: number;
44
+ }): SessionMetadata[];
@@ -1 +1,28 @@
1
- export {};
1
+ // when a session was last touched, see web-core's sessionLastUsed
2
+ export function sessionLastUsed(m) {
3
+ return m.updatedAt ?? m.createdAt;
4
+ }
5
+ // The cutoffs the "delete old sessions" bulk action offers. Days, so the label
6
+ // and the filter can never disagree about what "1 week" means.
7
+ export const STALE_CUTOFFS = [
8
+ { label: '1 day', days: 1 },
9
+ { label: '1 week', days: 7 },
10
+ { label: '1 month', days: 30 },
11
+ ];
12
+ const MS_PER_DAY = 1000 * 60 * 60 * 24;
13
+ /**
14
+ * The sessions a bulk "delete old sessions" would remove: unfavorited, not the
15
+ * one currently open, and last *used* — not created — longer ago than the
16
+ * cutoff. An id survives reloads, so a session edited every day still carries
17
+ * the createdAt of the day it was opened, and ageing by that would delete live
18
+ * work out from under the user.
19
+ *
20
+ * The open session is excluded here as well as in the model's
21
+ * deleteSavedSessions, so the count the confirmation quotes is the number of
22
+ * sessions that will actually go.
23
+ */
24
+ export function staleSessions(metadata, { days, openSessionId, now = Date.now(), }) {
25
+ return (metadata ?? []).filter(m => !m.favorite &&
26
+ m.id !== openSessionId &&
27
+ (now - +sessionLastUsed(m)) / MS_PER_DAY > days);
28
+ }
@@ -2,6 +2,6 @@ import type PluginManager from '@jbrowse/core/PluginManager';
2
2
  export declare const configSchema: import("@jbrowse/core/configuration/configurationSchema").ConfigurationSchemaType<{}, import("@jbrowse/core/configuration/configurationSchema").ConfigurationSchemaOptions<undefined, undefined>>;
3
3
  export declare const stateModel: import("@jbrowse/mobx-state-tree").IModelType<{
4
4
  id: import("@jbrowse/mobx-state-tree").IOptionalIType<import("@jbrowse/mobx-state-tree").ISimpleType<string>, [undefined]>;
5
- type: import("@jbrowse/mobx-state-tree").ISimpleType<"SessionManager">;
5
+ type: import("@jbrowse/mobx-state-tree").ILiteralType<"SessionManager">;
6
6
  }, {}, import("@jbrowse/mobx-state-tree")._NotCustomized, import("@jbrowse/mobx-state-tree")._NotCustomized>;
7
7
  export default function SessionManagerF(pluginManager: PluginManager): void;
@@ -15,7 +15,7 @@ export default function SessionManagerF(pluginManager) {
15
15
  heading: 'Recent sessions',
16
16
  configSchema,
17
17
  stateModel,
18
- ReactComponent: lazy(() => import("./components/SessionManager.js")),
18
+ ReactComponent: lazy(() => import('./components/SessionManager.js')),
19
19
  });
20
20
  });
21
21
  }
package/esm/index.js CHANGED
@@ -2,10 +2,10 @@ import Plugin from '@jbrowse/core/Plugin';
2
2
  import { isAbstractMenuManager } from '@jbrowse/core/util';
3
3
  import HelpIcon from '@mui/icons-material/Help';
4
4
  import InfoIcon from '@mui/icons-material/Info';
5
- import AboutWidgetF from "./AboutWidget/index.js";
6
- import HelpWidgetF from "./HelpWidget/index.js";
7
- import ImportSessionWidgetF from "./ImportSessionWidget/index.js";
8
- import SessionManagerF from "./SessionManager/index.js";
5
+ import AboutWidgetF from './AboutWidget/index.js';
6
+ import HelpWidgetF from './HelpWidget/index.js';
7
+ import ImportSessionWidgetF from './ImportSessionWidget/index.js';
8
+ import SessionManagerF from './SessionManager/index.js';
9
9
  export default class MenusPlugin extends Plugin {
10
10
  name = 'MenusPlugin';
11
11
  install(pluginManager) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbrowse/plugin-menus",
3
- "version": "4.2.1",
3
+ "version": "5.0.0-beta.1",
4
4
  "type": "module",
5
5
  "description": "JBrowse 2 basic menus",
6
6
  "keywords": [
@@ -21,19 +21,18 @@
21
21
  "esm"
22
22
  ],
23
23
  "dependencies": {
24
- "@jbrowse/mobx-state-tree": "^5.6.0",
25
- "@mui/icons-material": "^7.3.10",
26
- "@mui/material": "^7.3.10",
27
- "@mui/x-data-grid": "^8.28.2",
28
- "date-fns": "^4.1.0",
29
- "mobx": "^6.15.0",
30
- "mobx-react": "^9.2.1",
31
- "react-dropzone": "^15.0.0",
32
- "@jbrowse/core": "^4.2.1"
24
+ "@jbrowse/mobx-state-tree": "^6.5.1",
25
+ "@mui/icons-material": "^9.3.1",
26
+ "@mui/material": "^9.3.1",
27
+ "@mui/x-data-grid": "^9.12.0",
28
+ "mobx": "^7.0.3",
29
+ "mobx-react": "^10.0.2",
30
+ "@jbrowse/core": "^5.0.0-beta.1",
31
+ "@jbrowse/render-core": "^5.0.0-beta.1"
33
32
  },
34
33
  "peerDependencies": {
35
- "react": ">=18.0.0",
36
- "react-dom": ">=18.0.0"
34
+ "react": ">=19.0.0",
35
+ "react-dom": ">=19.0.0"
37
36
  },
38
37
  "publishConfig": {
39
38
  "access": "public"
@@ -43,14 +42,11 @@
43
42
  "build": "pnpm run /^build:/",
44
43
  "test": "cd ../..; jest --passWithNoTests plugins/menus",
45
44
  "prebuild": "pnpm clean",
46
- "build:esm": "tsc -p tsconfig.build.esm.json",
45
+ "build:esm": "node ../../node_modules/typescript7/bin/tsc --build tsconfig.build.esm.json",
47
46
  "clean": "rimraf esm *.tsbuildinfo"
48
47
  },
49
48
  "types": "esm/index.d.ts",
50
49
  "exports": {
51
- ".": {
52
- "types": "./esm/index.d.ts",
53
- "import": "./esm/index.js"
54
- }
50
+ ".": "./esm/index.js"
55
51
  }
56
52
  }