LAUNCHER-10 feat: Custom path input for launcher imports
This commit is contained in:
parent
c74d091b8e
commit
3770d7b896
9 changed files with 269 additions and 105 deletions
82
apps/frontend/src/ui/components/base/FilePicker.tsx
Normal file
82
apps/frontend/src/ui/components/base/FilePicker.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { type DialogFilter, open } from '@tauri-apps/plugin-dialog';
|
||||
import { FilePlus02Icon } from '@untitled-theme/icons-solid';
|
||||
import { createSignal, For, Match, onMount, splitProps, Switch } from 'solid-js';
|
||||
import Button from './Button';
|
||||
|
||||
export interface FilePickerOptions {
|
||||
directory?: boolean;
|
||||
multiple?: boolean;
|
||||
defaultPath?: string;
|
||||
title?: string;
|
||||
filters?: DialogFilter[];
|
||||
};
|
||||
|
||||
export interface FilePickerRef {
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
export default function FilePicker(componentProps: FilePickerOptions & {
|
||||
onPicked?: (paths: string[]) => void;
|
||||
ref?: (ref: FilePickerRef) => void;
|
||||
}) {
|
||||
const [props, options] = splitProps(componentProps, ['onPicked', 'ref']);
|
||||
const [file, setFile] = createSignal<string[]>([]);
|
||||
|
||||
onMount(() => {
|
||||
if (options.defaultPath)
|
||||
setFile([options.defaultPath]);
|
||||
|
||||
props.ref?.({
|
||||
open: openPicker,
|
||||
});
|
||||
});
|
||||
|
||||
async function openPicker() {
|
||||
const path = await open(options) as string[] | string | null; // The type automatically infers from the selected options, but we don't want that here
|
||||
|
||||
if (Array.isArray(path))
|
||||
setFile(path);
|
||||
else if (path === null)
|
||||
setFile([]);
|
||||
else
|
||||
setFile([path]);
|
||||
|
||||
props.onPicked?.(file());
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="max-w-full min-w-0 w-full flex flex-row items-center justify-start gap-2">
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
class="max-w-full w-full"
|
||||
iconLeft={<FilePlus02Icon />}
|
||||
onClick={openPicker}
|
||||
>
|
||||
<div class="box-border max-w-full min-w-0 w-full flex flex-col overflow-x-hidden text-left">
|
||||
<Switch>
|
||||
<Match when={file().length >= 2}>
|
||||
<For each={file()}>
|
||||
{path => (
|
||||
<span class="overflow-x-hidden text-ellipsis">{path.replaceAll('\\', '/').split('/').pop()}</span>
|
||||
)}
|
||||
</For>
|
||||
</Match>
|
||||
<Match when={file().length === 1}>
|
||||
<span class="overflow-x-hidden text-ellipsis">{file()[0]}</span>
|
||||
</Match>
|
||||
<Match when>
|
||||
<span class="overflow-x-hidden text-ellipsis">
|
||||
No
|
||||
{' '}
|
||||
{options.directory ? 'Folder' : 'File'}
|
||||
{options.multiple ? 's' : ''}
|
||||
{' '}
|
||||
Selected
|
||||
</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid';
|
||||
import { type Accessor, type Context, createContext, createEffect, createSignal, type JSX, type ParentProps, type Setter, splitProps, useContext } from 'solid-js';
|
||||
import { type Accessor, type Context, createContext, createEffect, createSignal, type JSX, onMount, type ParentProps, type Setter, splitProps, useContext } from 'solid-js';
|
||||
import styles from './SelectList.module.scss';
|
||||
|
||||
interface SelectListContextHelpers {
|
||||
|
|
@ -83,11 +83,17 @@ function SelectList(props: SelectListProps) {
|
|||
|
||||
export type SelectListRowProps = JSX.HTMLAttributes<HTMLDivElement> & {
|
||||
index: number;
|
||||
selected?: boolean;
|
||||
};
|
||||
|
||||
SelectList.Row = (props: SelectListRowProps) => {
|
||||
const { selected, setSelected, select } = useSelectListContext();
|
||||
const [split, rest] = splitProps(props, ['index', 'class', 'onClick']);
|
||||
const [split, rest] = splitProps(props, ['selected', 'index', 'class', 'onClick']);
|
||||
|
||||
onMount(() => {
|
||||
if (split.selected)
|
||||
select(split.index);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ export type TooltipProps = JSX.HTMLAttributes<HTMLDivElement> & {
|
|||
};
|
||||
|
||||
function Tooltip(props: TooltipProps) {
|
||||
const [split, rest] = splitProps(props, ['class']);
|
||||
const [split, rest] = splitProps(props, ['class', 'text']);
|
||||
|
||||
return (
|
||||
<div class={`${styles.tooltip} ${split.class || ''}`} data-text={props.text} {...rest} />
|
||||
<div class={`${styles.tooltip} ${split.class || ''}`} data-text={split.text} {...rest} />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import type { ImportType } from '@onelauncher/client/bindings';
|
||||
import { RefreshCw01Icon } from '@untitled-theme/icons-solid';
|
||||
import { bridge } from '~imports';
|
||||
import Button from '~ui/components/base/Button';
|
||||
import FilePicker from '~ui/components/base/FilePicker';
|
||||
import SelectList from '~ui/components/base/SelectList';
|
||||
import { tryResult } from '~ui/hooks/useCommand';
|
||||
import useNotifications from '~ui/hooks/useNotifications';
|
||||
import { type Accessor, createEffect, createResource, createSignal, For, type Setter, untrack } from 'solid-js';
|
||||
import Spinner from '../Spinner';
|
||||
|
||||
export interface LauncherImportInformation {
|
||||
importType: ImportType;
|
||||
path: string | null;
|
||||
instances: string[];
|
||||
};
|
||||
|
||||
interface LauncherImportComponentProps {
|
||||
multiple: boolean;
|
||||
setCanImport?: Setter<boolean>;
|
||||
importInformation: Accessor<LauncherImportInformation>;
|
||||
setImportInformation: Setter<LauncherImportInformation>;
|
||||
};
|
||||
|
||||
export default function LauncherImportComponent(props: LauncherImportComponentProps) {
|
||||
const notifications = useNotifications();
|
||||
|
||||
// eslint-disable-next-line solid/reactivity -- Its fine
|
||||
const [selected, setSelected] = createSignal<string[]>(props.importInformation().instances || []);
|
||||
// eslint-disable-next-line solid/reactivity -- Its fine
|
||||
const [customPath, setCustomPath] = createSignal<string | null>(props.importInformation().path || null);
|
||||
const [pathChanged, setPathChanged] = createSignal<boolean>(false);
|
||||
|
||||
const [instances, { refetch: refetchInstances }] = createResource(async () => {
|
||||
const info = props.importInformation();
|
||||
const importType = info.importType;
|
||||
const path = untrack(customPath) || null; // empty path
|
||||
|
||||
try {
|
||||
const result = await tryResult(() => bridge.commands.getLauncherInstances(importType, path));
|
||||
setCustomPath(result[0]);
|
||||
|
||||
return result[1];
|
||||
}
|
||||
catch (err) {
|
||||
// If the path is not null this means that the launcher's default location was not found
|
||||
if (path !== null)
|
||||
notifications.create({
|
||||
title: 'Instance Scan Error',
|
||||
message: `${err}`,
|
||||
});
|
||||
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const selectedInstances = selected();
|
||||
|
||||
props.setCanImport?.(() => selectedInstances.length > 0);
|
||||
|
||||
props.setImportInformation({
|
||||
importType: untrack(props.importInformation).importType,
|
||||
path: untrack(customPath)!,
|
||||
instances: selectedInstances,
|
||||
});
|
||||
});
|
||||
|
||||
function onPicked(paths: string[]) {
|
||||
setCustomPath(paths[0] || '');
|
||||
setPathChanged(true);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
refetchInstances();
|
||||
setPathChanged(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="max-w-sm w-full flex flex-col gap-2">
|
||||
<Spinner.Suspense>
|
||||
<h4>Path</h4>
|
||||
<div class="max-w-full w-full flex flex-row items-center justify-center gap-2">
|
||||
<FilePicker
|
||||
defaultPath={customPath() || ''}
|
||||
directory
|
||||
onPicked={onPicked}
|
||||
ref={(ref) => {
|
||||
if (customPath() === null)
|
||||
ref.open();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
buttonStyle="iconSecondary"
|
||||
children={<RefreshCw01Icon />}
|
||||
disabled={!pathChanged()}
|
||||
onClick={refresh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-2">Found Instances</h4>
|
||||
<SelectList
|
||||
class="h-52 max-h-52"
|
||||
multiple={props.multiple}
|
||||
onChange={(indexes) => {
|
||||
const mapped = instances()?.map((instance, index) => indexes.includes(index) ? instance : undefined).filter((instance): instance is string => !!instance) || [];
|
||||
setSelected(mapped);
|
||||
}}
|
||||
>
|
||||
<For each={instances()}>
|
||||
{(instance, index) => {
|
||||
// console.log(props.importInformation(), props.importInformation().instances.includes(instance));
|
||||
return (
|
||||
<SelectList.Row index={index()} selected={selected().includes(instance)}>
|
||||
{instance}
|
||||
</SelectList.Row>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</SelectList>
|
||||
</Spinner.Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import type { ImportType } from '@onelauncher/client/bindings';
|
||||
import type { LauncherImportInformation } from '~ui/components/content/LauncherImportComponent';
|
||||
import { bridge } from '~imports';
|
||||
import SelectList from '~ui/components/base/SelectList';
|
||||
import useCommand from '~ui/hooks/useCommand';
|
||||
import { createEffect, createSignal, For, onMount, untrack } from 'solid-js';
|
||||
import LauncherImportComponent from '~ui/components/content/LauncherImportComponent';
|
||||
import { createSignal, onMount, untrack } from 'solid-js';
|
||||
import { type ClusterStepProps, createClusterStep } from './ClusterCreationModal';
|
||||
|
||||
export default createClusterStep({
|
||||
|
|
@ -12,67 +12,29 @@ export default createClusterStep({
|
|||
});
|
||||
|
||||
function ClusterImportSelection(props: ClusterStepProps) {
|
||||
const [instances] = useCommand(() => {
|
||||
const importType = props.controller.provider();
|
||||
if (importType === undefined || importType === 'New')
|
||||
return [];
|
||||
|
||||
return bridge.commands.getLauncherInstances(importType, null);
|
||||
});
|
||||
|
||||
const [selected, setSelected] = createSignal<number>();
|
||||
|
||||
createEffect(() => {
|
||||
const index = selected();
|
||||
|
||||
props.setCanGoForward(() => {
|
||||
if (index === undefined)
|
||||
return false;
|
||||
|
||||
if (index >= 0)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
const [importInfo, setImportInfo] = createSignal<LauncherImportInformation>({
|
||||
// eslint-disable-next-line solid/reactivity -- It works as intended
|
||||
importType: untrack(props.controller.provider) as ImportType,
|
||||
instances: [],
|
||||
path: null,
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// eslint-disable-next-line solid/reactivity -- It's fine
|
||||
props.controller.setFinishFunction(() => async () => {
|
||||
const index = untrack(selected);
|
||||
if (index === undefined)
|
||||
return false;
|
||||
const details = untrack(importInfo);
|
||||
|
||||
const list = untrack(instances);
|
||||
if (list === undefined)
|
||||
return false;
|
||||
|
||||
const instance = list[1][index];
|
||||
if (instance === undefined)
|
||||
return false;
|
||||
|
||||
const basePath = list[0];
|
||||
|
||||
const importType = untrack(props.controller.provider) as ImportType;
|
||||
|
||||
await bridge.commands.importInstances(importType, basePath, [instance]);
|
||||
bridge.commands.importInstances(details.importType, details.path!, details.instances);
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<SelectList
|
||||
class="h-52 max-h-52"
|
||||
onChange={selected => setSelected(selected[0])}
|
||||
>
|
||||
<For each={instances()?.[1]}>
|
||||
{(instance, index) => (
|
||||
<SelectList.Row index={index()}>
|
||||
{instance}
|
||||
</SelectList.Row>
|
||||
)}
|
||||
</For>
|
||||
</SelectList>
|
||||
<LauncherImportComponent
|
||||
importInformation={importInfo}
|
||||
multiple={false}
|
||||
setCanImport={props.setCanGoForward}
|
||||
setImportInformation={setImportInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useNavigate } from '@solidjs/router';
|
|||
import {
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
RefreshCw01Icon,
|
||||
SearchMdIcon,
|
||||
Settings01Icon,
|
||||
} from '@untitled-theme/icons-solid';
|
||||
|
|
@ -30,7 +29,7 @@ function HomePage() {
|
|||
|
||||
onMount(() => {
|
||||
bridge.events.clusterPayload.listen(({ payload }) => {
|
||||
if (payload.event === 'created' || payload.event === 'deleted')
|
||||
if (payload.event === 'created' || payload.event === 'deleted' || payload.event === 'inserted')
|
||||
refetch();
|
||||
});
|
||||
});
|
||||
|
|
@ -52,14 +51,15 @@ function HomePage() {
|
|||
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<TextField iconLeft={<SearchMdIcon />} placeholder="Search for clusters..." />
|
||||
<TextField disabled iconLeft={<SearchMdIcon />} placeholder="Search for clusters..." />
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-x-2">
|
||||
<Button
|
||||
{/* <Button
|
||||
buttonStyle="icon"
|
||||
children={<RefreshCw01Icon />}
|
||||
onClick={refetch}
|
||||
/>
|
||||
|
||||
/> */}
|
||||
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
|
|
|
|||
|
|
@ -1,29 +1,31 @@
|
|||
import type { ImportType } from '@onelauncher/client/bindings';
|
||||
import type { LauncherImportInformation } from '~ui/components/content/LauncherImportComponent';
|
||||
import Illustration from '~assets/illustrations/onboarding/import_from_others.svg?component-solid';
|
||||
import { bridge } from '~imports';
|
||||
import Button from '~ui/components/base/Button';
|
||||
import Link from '~ui/components/base/Link';
|
||||
import SelectList from '~ui/components/base/SelectList';
|
||||
import LauncherIcon from '~ui/components/content/LauncherIcon';
|
||||
import LauncherImportComponent from '~ui/components/content/LauncherImportComponent';
|
||||
import Modal, { createModal, type ModalProps } from '~ui/components/overlay/Modal';
|
||||
import useCommand from '~ui/hooks/useCommand';
|
||||
import { LAUNCHER_IMPORT_TYPES } from '~utils';
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-solid';
|
||||
import { createEffect, createSignal, For } from 'solid-js';
|
||||
import { createMemo, createSignal, For, untrack } from 'solid-js';
|
||||
import Onboarding, { OnboardingStep } from './Onboarding';
|
||||
|
||||
function OnboardingImport() {
|
||||
const ctx = Onboarding.useContext();
|
||||
const [launcher, setLauncher] = createSignal<ImportType>();
|
||||
|
||||
const foundInfo = createMemo(() => ctx.importInstances(launcher()!));
|
||||
|
||||
const modal = createModal(props => (
|
||||
<InstancesPickerModal
|
||||
{...props}
|
||||
instances={foundInfo()?.instances}
|
||||
launcher={launcher()!}
|
||||
onSelected={(basePath, instance) => {
|
||||
ctx.setImportInstances(launcher()!, basePath, instance);
|
||||
}}
|
||||
selected={ctx.importInstances(launcher()!)?.instances}
|
||||
path={foundInfo()?.basePath}
|
||||
/>
|
||||
));
|
||||
|
||||
|
|
@ -69,25 +71,23 @@ export default OnboardingImport;
|
|||
|
||||
type InstancesPickerModalProps = ModalProps & {
|
||||
launcher: ImportType;
|
||||
selected: string[] | undefined;
|
||||
path: string | undefined;
|
||||
instances: string[] | undefined;
|
||||
onSelected: (basePath: string, instances: string[]) => void;
|
||||
};
|
||||
|
||||
function InstancesPickerModal(props: InstancesPickerModalProps) {
|
||||
const [instances] = useCommand(() => bridge.commands.getLauncherInstances(props.launcher, null));
|
||||
const [selected, setSelected] = createSignal<number[]>([]);
|
||||
|
||||
const getSelectedInstances = () => selected().map(i => instances()?.[1][i]).filter(v => v !== undefined);
|
||||
|
||||
createEffect(() => {
|
||||
const selected = props.selected;
|
||||
const instancesList = instances()?.[1];
|
||||
if (selected !== undefined && instancesList !== undefined) {
|
||||
const indexes = selected.map(instance => instancesList.indexOf(instance)).filter(i => i !== undefined);
|
||||
setSelected(indexes);
|
||||
}
|
||||
const [importInfo, setImportInfo] = createSignal<LauncherImportInformation>({
|
||||
// eslint-disable-next-line solid/reactivity -- its ok
|
||||
importType: props.launcher,
|
||||
// eslint-disable-next-line solid/reactivity -- its ok
|
||||
instances: props.instances || [],
|
||||
// eslint-disable-next-line solid/reactivity -- its ok
|
||||
path: props.path || '',
|
||||
});
|
||||
|
||||
const instanceLength = createMemo(() => importInfo().instances.length);
|
||||
|
||||
return (
|
||||
<Modal.Simple
|
||||
{...props}
|
||||
|
|
@ -95,32 +95,22 @@ function InstancesPickerModal(props: InstancesPickerModalProps) {
|
|||
'Cancel',
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
children={`Import (${selected().length || 'None'})`}
|
||||
disabled={(instances()?.length || 0) <= 0}
|
||||
children={`Import (${instanceLength() || 'None'})`}
|
||||
disabled={instanceLength() <= 0}
|
||||
onClick={() => {
|
||||
props.onSelected(instances()![0], getSelectedInstances());
|
||||
const info = untrack(importInfo);
|
||||
props.onSelected(info.path!, info.instances);
|
||||
props.hide();
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
title="Import Instances"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<SelectList
|
||||
class="h-52 max-h-52"
|
||||
multiple
|
||||
onChange={setSelected}
|
||||
selected={selected()}
|
||||
>
|
||||
<For each={instances()?.[1]}>
|
||||
{(instance, index) => (
|
||||
<SelectList.Row index={index()}>
|
||||
{instance}
|
||||
</SelectList.Row>
|
||||
)}
|
||||
</For>
|
||||
</SelectList>
|
||||
</div>
|
||||
<LauncherImportComponent
|
||||
importInformation={importInfo}
|
||||
multiple={true}
|
||||
setImportInformation={setImportInfo}
|
||||
/>
|
||||
</Modal.Simple>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "1.0.0-alpha.1",
|
||||
"private": true,
|
||||
"engineStrict": true,
|
||||
"packageManager": "pnpm@9.15.0",
|
||||
"packageManager": "pnpm@9.15.1",
|
||||
"description": "Next-generation open source Minecraft launcher and tools for interacting with Polyfrost",
|
||||
"license": "GPL-3.0-only",
|
||||
"homepage": "https://polyfrost.org",
|
||||
|
|
|
|||
|
|
@ -93,10 +93,10 @@ impl ImportType {
|
|||
}
|
||||
|
||||
pub async fn get_launcher_instances(import: ImportType, path: Option<PathBuf>) -> crate::Result<(PathBuf, Vec<String>)> {
|
||||
let base_path = &path.unwrap_or(
|
||||
default_launcher_path(import)
|
||||
.ok_or_else(|| anyhow::anyhow!("could not get launcher base path for {import}"))?
|
||||
);
|
||||
let base_path = match &path {
|
||||
Some(path) => path,
|
||||
None => &default_launcher_path(import).ok_or_else(|| anyhow::anyhow!("could not get launcher base path for {import}"))?
|
||||
};
|
||||
|
||||
let instances_dir = import.get_instances_subpath(base_path).await?;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue