LAUNCHER-10 feat: Custom path input for launcher imports

This commit is contained in:
LynithDev 2024-12-23 15:49:09 +01:00
parent c74d091b8e
commit 3770d7b896
9 changed files with 269 additions and 105 deletions

View 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>
);
}

View file

@ -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

View file

@ -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} />
);
}

View file

@ -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>
);
}

View file

@ -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}
/>
);
}

View file

@ -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"

View file

@ -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>
);
}

View file

@ -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",

View file

@ -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?;