diff --git a/README.md b/README.md index 58a50a92d..7d5ff1f1d 100644 --- a/README.md +++ b/README.md @@ -86,10 +86,12 @@ Note that this is a desktop application that should be run on your local desktop Installers are the easiest way to get started and come with an optional automatic update functionality: - [Windows .msi Installer (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-windows-x86_64.msi) +- [Windows .msi Installer (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-installer-windows-arm64.msi) If you don't like installers, you can also use a portable version that is packaged as an archive: - [Windows .zip Portable (x86-64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-windows-x86_64.zip) +- [Windows .zip Portable (ARM 64)](https://github.com/xpipe-io/xpipe/releases/latest/download/xpipe-portable-windows-arm64.zip) Alternatively, you can also use the following package managers: - [choco](https://community.chocolatey.org/packages/xpipe) to install it with `choco install xpipe`. diff --git a/app/build.gradle b/app/build.gradle index 9d9090cd6..26e7b4296 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -23,8 +23,8 @@ dependencies { api project(':beacon') compileOnly 'org.hamcrest:hamcrest:3.0' - compileOnly 'org.junit.jupiter:junit-jupiter-api:5.11.4' - compileOnly 'org.junit.jupiter:junit-jupiter-params:5.11.4' + compileOnly 'org.junit.jupiter:junit-jupiter-api:5.12.2' + compileOnly 'org.junit.jupiter:junit-jupiter-params:5.12.2' api 'com.vladsch.flexmark:flexmark:0.64.8' api 'com.vladsch.flexmark:flexmark-util:0.64.8' @@ -49,21 +49,21 @@ dependencies { api 'com.vladsch.flexmark:flexmark-ext-toc:0.64.8' api("com.github.weisj:jsvg:1.7.1") - api files("$rootDir/gradle/gradle_scripts/vernacular-1.16.jar") + api 'io.xpipe:vernacular:1.15' api 'org.bouncycastle:bcprov-jdk18on:1.80' api 'info.picocli:picocli:4.7.6' api 'org.apache.commons:commons-lang3:3.17.0' - api 'io.sentry:sentry:8.7.0' - api 'commons-io:commons-io:2.18.0' - api group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: "2.18.3" - api group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version: "2.18.3" + api 'io.sentry:sentry:8.13.3' + api 'commons-io:commons-io:2.19.0' + api group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: "2.19.1" + api group: 'com.fasterxml.jackson.datatype', name: 'jackson-datatype-jsr310', version: "2.19.1" api group: 'org.kordamp.ikonli', name: 'ikonli-material2-pack', version: "12.2.0" api group: 'org.kordamp.ikonli', name: 'ikonli-materialdesign2-pack', version: "12.2.0" api group: 'org.kordamp.ikonli', name: 'ikonli-javafx', version: "12.2.0" api group: 'org.kordamp.ikonli', name: 'ikonli-material-pack', version: "12.2.0" api group: 'org.kordamp.ikonli', name: 'ikonli-feather-pack', version: "12.2.0" - api group: 'org.slf4j', name: 'slf4j-api', version: '2.0.16' - api group: 'org.slf4j', name: 'slf4j-jdk-platform-logging', version: '2.0.16' + api group: 'org.slf4j', name: 'slf4j-api', version: '2.0.17' + api group: 'org.slf4j', name: 'slf4j-jdk-platform-logging', version: '2.0.17' api 'io.xpipe:modulefs:0.1.6' api 'net.synedra:validatorfx:0.4.2' api files("$rootDir/gradle/gradle_scripts/atlantafx-base-2.0.2.jar") diff --git a/app/src/main/java/io/xpipe/app/action/AbstractAction.java b/app/src/main/java/io/xpipe/app/action/AbstractAction.java new file mode 100644 index 000000000..a4db6c2d3 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/action/AbstractAction.java @@ -0,0 +1,191 @@ +package io.xpipe.app.action; + +import io.xpipe.app.comp.base.ModalButton; +import io.xpipe.app.comp.base.ModalOverlay; +import io.xpipe.app.core.AppCache; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.core.AppLayoutModel; +import io.xpipe.app.core.window.AppDialog; +import io.xpipe.app.issue.ErrorEventFactory; +import io.xpipe.app.issue.TrackEvent; +import io.xpipe.app.util.DataStoreFormatter; +import io.xpipe.app.util.LabelGraphic; +import io.xpipe.app.util.ThreadHelper; + +import lombok.experimental.SuperBuilder; + +import java.util.*; +import java.util.function.Consumer; + +@SuperBuilder +public abstract class AbstractAction { + + private static final Set active = new HashSet<>(); + private static boolean closed; + private static Consumer pick; + + private static final AppLayoutModel.QueueEntry queueEntry = new AppLayoutModel.QueueEntry( + AppI18n.observable("cancelActionPicker"), new LabelGraphic.IconGraphic("mdal-cancel_presentation"), () -> { + cancelPick(); + }); + + public static synchronized void expectPick() { + if (pick != null) { + return; + } + + var show = !AppCache.getBoolean("pickIntroductionShown", false); + if (show) { + var modal = ModalOverlay.of("actionPickerTitle", AppDialog.dialogTextKey("actionPickerDescription")); + modal.addButton(ModalButton.ok()); + modal.showAndWait(); + AppCache.update("pickIntroductionShown", true); + } + + AppLayoutModel.get().getQueueEntries().add(queueEntry); + pick = action -> { + cancelPick(); + var modal = ModalOverlay.of("actionShortcuts", new ActionPickComp(action).prefWidth(600)); + modal.show(); + }; + } + + public static synchronized void cancelPick() { + AppLayoutModel.get().getQueueEntries().remove(queueEntry); + pick = null; + } + + public static void reset() { + closed = true; + for (int i = 10; i > 0; i--) { + synchronized (active) { + var count = active.size(); + if (count == 0) { + break; + } + } + + // Wait 10s max + ThreadHelper.sleep(1000); + } + + synchronized (active) { + for (AbstractAction abstractAction : active) { + TrackEvent.trace("Action has not quit after timeout: " + abstractAction.toString()); + } + } + } + + public void executeSync() { + if (closed) { + return; + } + + synchronized (AbstractAction.class) { + if (pick != null) { + TrackEvent.withTrace("Picked action").tags(toDisplayMap()).handle(); + pick.accept(this); + pick = null; + return; + } + } + + executeSyncImpl(); + } + + public void executeAsync() { + if (closed) { + return; + } + + synchronized (AbstractAction.class) { + if (pick != null) { + TrackEvent.withTrace("Picked action").tags(toDisplayMap()).handle(); + pick.accept(this); + pick = null; + return; + } + } + + ThreadHelper.runAsync(() -> { + executeSyncImpl(); + }); + } + + private void executeSyncImpl() { + if (!ActionConfirmation.confirmAction(this)) { + return; + } + + if (closed) { + return; + } + + synchronized (active) { + active.add(this); + } + + TrackEvent.withTrace("Starting action execution").tags(toDisplayMap()).handle(); + + try { + if (!beforeExecute()) { + return; + } + } catch (Throwable t) { + ErrorEventFactory.fromThrowable(t).handle(); + return; + } + + try { + executeImpl(); + } catch (Throwable t) { + ErrorEventFactory.fromThrowable(t).handle(); + } finally { + afterExecute(); + synchronized (active) { + active.remove(this); + } + + TrackEvent.withTrace("Finished action execution").tag("id", getId()).handle(); + } + } + + public String getId() { + return getProvider().getId(); + } + + public String getDisplayName() { + var id = getId(); + return id != null ? DataStoreFormatter.camelCaseToName(id) : "?"; + } + + public ActionProvider getProvider() { + var clazz = getClass(); + var enc = clazz.getEnclosingClass(); + if (enc == null) { + throw new IllegalStateException("No enclosing instance of " + clazz); + } + return ActionProvider.ALL.stream() + .filter(actionProvider -> actionProvider.getClass().equals(enc)) + .findFirst() + .orElseThrow(IllegalStateException::new); + } + + public String getShortcutName() { + return getDisplayName(); + } + + public abstract void executeImpl() throws Exception; + + protected boolean beforeExecute() throws Exception { + return true; + } + + public boolean isMutation() { + return false; + } + + protected void afterExecute() {} + + public abstract Map toDisplayMap(); +} diff --git a/app/src/main/java/io/xpipe/app/action/ActionConfigComp.java b/app/src/main/java/io/xpipe/app/action/ActionConfigComp.java new file mode 100644 index 000000000..cd026ffe4 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/action/ActionConfigComp.java @@ -0,0 +1,105 @@ +package io.xpipe.app.action; + +import io.xpipe.app.comp.Comp; +import io.xpipe.app.comp.SimpleComp; +import io.xpipe.app.comp.base.*; +import io.xpipe.app.hub.action.BatchStoreAction; +import io.xpipe.app.hub.action.MultiStoreAction; +import io.xpipe.app.hub.action.StoreAction; +import io.xpipe.app.hub.comp.StoreChoiceComp; +import io.xpipe.app.hub.comp.StoreListChoiceComp; +import io.xpipe.app.hub.comp.StoreViewState; +import io.xpipe.app.storage.DataStoreEntryRef; +import io.xpipe.app.util.*; +import io.xpipe.core.store.DataStore; + +import javafx.beans.property.*; +import javafx.collections.FXCollections; +import javafx.scene.layout.Region; + +public class ActionConfigComp extends SimpleComp { + + private final Property action; + + public ActionConfigComp(Property action) { + this.action = action; + } + + @Override + protected Region createSimple() { + var options = new OptionsBuilder(); + options.nameAndDescription("actionStore") + .addComp(createChooser()) + .nameAndDescription("actionStores") + .addComp(createMultiChooser()); + options.nameAndDescription("actionConfiguration").addComp(createTextArea()); + return options.build(); + } + + @SuppressWarnings("unchecked") + private Comp createMultiChooser() { + var listProp = new SimpleListProperty>(FXCollections.observableArrayList()); + if (action.getValue() instanceof BatchStoreAction ba) { + listProp.setAll(((BatchStoreAction) ba).getRefs()); + } else if (action.getValue() instanceof MultiStoreAction ma) { + listProp.setAll(((MultiStoreAction) ma).getRefs()); + } else { + listProp.clear(); + } + + listProp.addListener((obs, o, n) -> { + if (action.getValue() instanceof BatchStoreAction ba) { + action.setValue(((BatchStoreAction) ba).withRefs(n)); + } else if (action.getValue() instanceof MultiStoreAction ma) { + action.setValue(((MultiStoreAction) ma).withRefs(n)); + } + }); + + var choice = new StoreListChoiceComp<>( + listProp, DataStore.class, null, StoreViewState.get().getAllConnectionsCategory()); + choice.hide(listProp.emptyProperty()); + return choice; + } + + @SuppressWarnings("unchecked") + private Comp createChooser() { + var singleProp = new SimpleObjectProperty>(); + var s = action.getValue() instanceof StoreAction sa ? sa.getRef() : null; + singleProp.set((DataStoreEntryRef) s); + + singleProp.addListener((obs, o, n) -> { + if (action.getValue() instanceof StoreAction sa) { + action.setValue(sa.withRef(n.asNeeded())); + } + }); + + var choice = new StoreChoiceComp<>( + StoreChoiceComp.Mode.OTHER, + null, + singleProp, + DataStore.class, + ref -> true, + StoreViewState.get().getAllConnectionsCategory()); + choice.hide(singleProp.isNull()); + return choice; + } + + private Comp createTextArea() { + var config = new SimpleStringProperty(); + var s = action.getValue() instanceof SerializableAction sa ? sa.toConfigNode() : null; + config.set(s != null && s.size() > 0 ? s.toPrettyString() : null); + + config.addListener((obs, o, n) -> { + if (action.getValue() instanceof SerializableAction aa && n != null) { + var with = aa.withConfigString(n); + if (with.isPresent()) { + action.setValue(with.get()); + } + } + }); + + var area = new IntegratedTextAreaComp(config, false, "action", new SimpleStringProperty("json")); + area.hide(config.isNull()); + return area; + } +} diff --git a/app/src/main/java/io/xpipe/app/action/ActionConfirmComp.java b/app/src/main/java/io/xpipe/app/action/ActionConfirmComp.java new file mode 100644 index 000000000..9a81dd508 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/action/ActionConfirmComp.java @@ -0,0 +1,79 @@ +package io.xpipe.app.action; + +import io.xpipe.app.comp.Comp; +import io.xpipe.app.comp.SimpleComp; +import io.xpipe.app.hub.action.BatchStoreAction; +import io.xpipe.app.hub.action.MultiStoreAction; +import io.xpipe.app.hub.action.StoreAction; +import io.xpipe.app.hub.comp.StoreListChoiceComp; +import io.xpipe.app.hub.comp.StoreViewState; +import io.xpipe.app.storage.DataStoreEntryRef; +import io.xpipe.app.util.OptionsBuilder; +import io.xpipe.core.store.DataStore; + +import javafx.beans.property.SimpleListProperty; +import javafx.collections.FXCollections; +import javafx.scene.control.Label; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Region; + +import atlantafx.base.theme.Styles; + +import java.util.List; +import java.util.Map; + +public class ActionConfirmComp extends SimpleComp { + + private final AbstractAction action; + + public ActionConfirmComp(AbstractAction action) { + this.action = action; + } + + @Override + protected Region createSimple() { + var options = new OptionsBuilder(); + var plural = action instanceof BatchStoreAction || action instanceof MultiStoreAction; + options.nameAndDescription(plural ? "actionConnections" : "actionConnection") + .addComp(createList()); + options.nameAndDescription("actionConfiguration").addComp(createTable()); + return options.build(); + } + + @SuppressWarnings("unchecked") + private Comp createList() { + var listProp = new SimpleListProperty>(FXCollections.observableArrayList()); + if (action instanceof BatchStoreAction ba) { + listProp.setAll(((BatchStoreAction) ba).getRefs()); + } else if (action instanceof MultiStoreAction ma) { + listProp.setAll(((MultiStoreAction) ma).getRefs()); + } else if (action instanceof StoreAction sa) { + listProp.setAll(List.of(sa.getRef().asNeeded())); + } + + var choice = new StoreListChoiceComp<>( + listProp, DataStore.class, null, StoreViewState.get().getAllConnectionsCategory()); + choice.setEditable(false); + choice.hide(listProp.emptyProperty()); + return choice; + } + + private Comp createTable() { + var map = action.toDisplayMap(); + return Comp.of(() -> { + var grid = new GridPane(); + grid.setHgap(11); + grid.setVgap(2); + var row = 0; + for (Map.Entry e : map.entrySet()) { + var name = new Label(e.getKey()); + var value = new Label(e.getValue()); + value.getStyleClass().add(Styles.TEXT_BOLD); + grid.add(name, 0, row); + grid.add(value, 1, row); + row++; + } + return grid; + }); + } +} diff --git a/app/src/main/java/io/xpipe/app/action/ActionConfirmation.java b/app/src/main/java/io/xpipe/app/action/ActionConfirmation.java new file mode 100644 index 000000000..1ed26079a --- /dev/null +++ b/app/src/main/java/io/xpipe/app/action/ActionConfirmation.java @@ -0,0 +1,42 @@ +package io.xpipe.app.action; + +import io.xpipe.app.comp.base.ModalButton; +import io.xpipe.app.comp.base.ModalOverlay; +import io.xpipe.app.storage.DataStorage; +import io.xpipe.app.storage.DataStoreEntry; + +import javafx.beans.property.SimpleBooleanProperty; + +import java.util.List; + +public class ActionConfirmation { + + public static boolean confirmAction(AbstractAction action) { + if (!action.isMutation() || !confirmAllModifications(action)) { + return true; + } + + var ok = new SimpleBooleanProperty(false); + var modal = ModalOverlay.of("confirmAction", new ActionConfirmComp(action).prefWidth(550)); + modal.addButton(ModalButton.cancel()); + modal.addButton(ModalButton.ok(() -> ok.set(true))); + modal.showAndWait(); + return ok.get(); + } + + private static boolean confirmAllModifications(AbstractAction action) { + var context = getContext(action); + return context.stream().anyMatch(dataStoreEntry -> { + var config = DataStorage.get().getEffectiveCategoryConfig(dataStoreEntry); + return config.getConfirmAllModifications() != null && config.getConfirmAllModifications(); + }); + } + + private static List getContext(AbstractAction action) { + if (action instanceof StoreContextAction ca) { + return ca.getStoreEntryContext(); + } + + return List.of(); + } +} diff --git a/app/src/main/java/io/xpipe/app/action/ActionJacksonMapper.java b/app/src/main/java/io/xpipe/app/action/ActionJacksonMapper.java new file mode 100644 index 000000000..ce9c4da68 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/action/ActionJacksonMapper.java @@ -0,0 +1,105 @@ +package io.xpipe.app.action; + +import io.xpipe.app.hub.action.BatchStoreAction; +import io.xpipe.app.hub.action.MultiStoreAction; +import io.xpipe.app.hub.action.StoreAction; +import io.xpipe.core.store.DataStore; +import io.xpipe.core.util.JacksonMapper; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.ArrayList; + +public class ActionJacksonMapper { + + @SuppressWarnings("unchecked") + public static T parse(JsonNode tree) throws JsonProcessingException { + if (!tree.isObject()) { + return null; + } + + var id = tree.get("id"); + if (id == null || !id.isTextual()) { + return null; + } + + var provider = ActionProvider.ALL.stream() + .filter(actionProvider -> id.textValue().equals(actionProvider.getId())) + .findFirst(); + if (provider.isEmpty()) { + return null; + } + + var clazz = provider.get().getActionClass(); + if (clazz.isEmpty()) { + return null; + } + + var object = (ObjectNode) tree; + var ref = tree.get("ref"); + + if (ref != null && !ref.isArray() && StoreAction.class.isAssignableFrom(clazz.get())) { + var action = JacksonMapper.getDefault().treeToValue(tree, clazz.get()); + return (T) action; + } + + var makeBatch = ref != null && ref.isArray() && !MultiStoreAction.class.isAssignableFrom(clazz.get()); + if (makeBatch) { + if (ref.size() == 0) { + return null; + } + + var batchActions = new ArrayList>(); + object.remove("ref"); + for (JsonNode batchRef : ref) { + object.set("ref", batchRef); + var action = JacksonMapper.getDefault().treeToValue(object, clazz.get()); + batchActions.add((StoreAction) action); + } + return (T) BatchStoreAction.builder().actions(batchActions).build(); + } + + var makeMulti = ref != null && ref.isArray() && MultiStoreAction.class.isAssignableFrom(clazz.get()); + if (makeMulti) { + object.remove("ref"); + object.set("refs", ref); + var action = JacksonMapper.getDefault().treeToValue(object, clazz.get()); + return (T) action; + } + + return null; + } + + public static ObjectNode write(AbstractAction value) { + if (value instanceof BatchStoreAction b) { + var arrayNode = JsonNodeFactory.instance.arrayNode(); + b.getActions().stream() + .map(a -> { + var tree = (ObjectNode) JacksonMapper.getDefault().valueToTree(a); + return tree.get("ref"); + }) + .forEach(n -> arrayNode.add(n)); + var tree = (ObjectNode) + JacksonMapper.getDefault().valueToTree(b.getActions().getFirst()); + tree.set("ref", arrayNode); + tree.put("id", b.getActions().getFirst().getId()); + return tree; + } + + var tree = (ObjectNode) JacksonMapper.getDefault().valueToTree(value); + + if (value instanceof MultiStoreAction m) { + var refs = tree.get("refs"); + tree.remove("refs"); + tree.set("ref", refs); + tree.put("id", m.getId()); + return tree; + } + + tree.put("id", value.getId()); + return tree; + } +} diff --git a/app/src/main/java/io/xpipe/app/action/ActionPickComp.java b/app/src/main/java/io/xpipe/app/action/ActionPickComp.java new file mode 100644 index 000000000..405cb8ad8 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/action/ActionPickComp.java @@ -0,0 +1,27 @@ +package io.xpipe.app.action; + +import io.xpipe.app.comp.base.ModalOverlayContentComp; +import io.xpipe.app.util.OptionsBuilder; + +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.layout.Region; + +public class ActionPickComp extends ModalOverlayContentComp { + + private final AbstractAction action; + + public ActionPickComp(AbstractAction action) { + this.action = action; + } + + @Override + protected Region createSimple() { + var prop = new SimpleObjectProperty<>(action); + var top = new ActionConfigComp(prop); + var bottom = new ActionShortcutComp(prop, () -> { + getModalOverlay().close(); + }); + var options = new OptionsBuilder().addComp(top).addComp(bottom); + return options.build(); + } +} diff --git a/app/src/main/java/io/xpipe/app/action/ActionProvider.java b/app/src/main/java/io/xpipe/app/action/ActionProvider.java new file mode 100644 index 000000000..8cf030d1d --- /dev/null +++ b/app/src/main/java/io/xpipe/app/action/ActionProvider.java @@ -0,0 +1,57 @@ +package io.xpipe.app.action; + +import io.xpipe.app.ext.DataStoreProviders; +import io.xpipe.app.issue.ErrorEventFactory; +import io.xpipe.app.issue.TrackEvent; +import io.xpipe.core.util.ModuleLayerLoader; + +import java.util.*; + +public interface ActionProvider { + + List ALL = new ArrayList<>(); + + static void initProviders() { + TrackEvent.trace("Starting action provider initialization"); + for (ActionProvider actionProvider : ALL) { + try { + actionProvider.init(); + } catch (Throwable t) { + ErrorEventFactory.fromThrowable(t).handle(); + } + } + TrackEvent.trace("Finished action provider initialization"); + } + + default void init() throws Exception {} + + default String getLicensedFeatureId() { + return null; + } + + default String getId() { + return null; + } + + @SuppressWarnings("unchecked") + default Optional> getActionClass() { + var child = Arrays.stream(getClass().getDeclaredClasses()) + .filter(aClass -> aClass.getSimpleName().equals("Action")) + .findFirst() + .map(aClass -> (Class) aClass); + return child.isPresent() ? Optional.of(child.get()) : Optional.empty(); + } + + class Loader implements ModuleLayerLoader { + + @Override + public void init(ModuleLayer layer) { + ALL.addAll(ServiceLoader.load(layer, ActionProvider.class).stream() + .map(p -> p.get()) + .toList()); + for (var p : DataStoreProviders.getAll()) { + ALL.addAll(p.getActionProviders()); + } + } + } +} diff --git a/app/src/main/java/io/xpipe/app/action/ActionShortcutComp.java b/app/src/main/java/io/xpipe/app/action/ActionShortcutComp.java new file mode 100644 index 000000000..19ead9702 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/action/ActionShortcutComp.java @@ -0,0 +1,88 @@ +package io.xpipe.app.action; + +import io.xpipe.app.comp.Comp; +import io.xpipe.app.comp.SimpleComp; +import io.xpipe.app.comp.base.ButtonComp; +import io.xpipe.app.comp.base.InputGroupComp; +import io.xpipe.app.comp.base.TextFieldComp; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.*; + +import javafx.beans.property.Property; +import javafx.beans.property.SimpleStringProperty; +import javafx.scene.layout.Region; + +import org.kordamp.ikonli.javafx.FontIcon; + +import java.util.List; + +public class ActionShortcutComp extends SimpleComp { + + private final Property action; + private final Runnable onCreateMacro; + + public ActionShortcutComp(Property action, Runnable onCreateMacro) { + this.action = action; + this.onCreateMacro = onCreateMacro; + } + + @Override + protected Region createSimple() { + var options = new OptionsBuilder(); + options.nameAndDescription("actionDesktopShortcut").addComp(createDesktopComp()); + options.nameAndDescription("actionUrlShortcut").addComp(createUrlComp()); + // options.nameAndDescription("actionMacro") + // .addComp(createMacroComp()); + return options.build(); + } + + private Comp createUrlComp() { + var url = new SimpleStringProperty(); + action.subscribe((v) -> { + var s = ActionUrls.toUrl(v); + PlatformThread.runLaterIfNeeded(() -> { + url.set(s); + }); + }); + + var copyButton = new ButtonComp(null, new FontIcon("mdi2c-clipboard-multiple-outline"), () -> { + ClipboardHelper.copyUrl(url.getValue()); + }) + .grow(false, true) + .tooltipKey("createShortcut"); + var field = new TextFieldComp(url); + field.grow(true, false); + field.apply(struc -> struc.get().setEditable(false)); + var group = new InputGroupComp(List.of(field, copyButton)); + return group; + } + + private Comp createDesktopComp() { + var url = BindingsHelper.map(action, abstractAction -> ActionUrls.toUrl(abstractAction)); + var name = new SimpleStringProperty(); + action.subscribe((v) -> { + var s = v.getShortcutName(); + PlatformThread.runLaterIfNeeded(() -> { + name.set(s); + }); + }); + var copyButton = new ButtonComp(null, new FontIcon("mdi2f-file-move-outline"), () -> { + ThreadHelper.runFailableAsync(() -> { + var file = DesktopShortcuts.createCliOpen(url.getValue(), name.getValue()); + DesktopHelper.browseFileInDirectory(file); + }); + }) + .grow(false, true) + .tooltipKey("createShortcut"); + var field = new TextFieldComp(name); + field.grow(true, false); + var group = new InputGroupComp(List.of(field, copyButton)); + return group; + } + + private Comp createMacroComp() { + var button = new ButtonComp( + AppI18n.observable("createMacro"), new FontIcon("mdi2c-clipboard-multiple-outline"), onCreateMacro); + return button; + } +} diff --git a/app/src/main/java/io/xpipe/app/action/ActionUrls.java b/app/src/main/java/io/xpipe/app/action/ActionUrls.java new file mode 100644 index 000000000..578546885 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/action/ActionUrls.java @@ -0,0 +1,146 @@ +package io.xpipe.app.action; + +import io.xpipe.app.storage.DataStorage; +import io.xpipe.core.util.InPlaceSecretValue; +import io.xpipe.core.util.JacksonMapper; +import io.xpipe.core.util.UuidHelper; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.SneakyThrows; + +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +public class ActionUrls { + + private static String encodeValue(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static List nodeToString(JsonNode node) { + if (node.isTextual()) { + return List.of(encodeValue(node.asText())); + } + + if (node.isArray()) { + var list = new ArrayList(); + for (JsonNode c : node) { + var r = nodeToString(c); + if (r.size() == 1) { + list.add(r.getFirst()); + } + } + return list; + } + + var enc = InPlaceSecretValue.of(node.toPrettyString()).getEncryptedValue(); + return List.of(enc); + } + + @SneakyThrows + public static String toUrl(AbstractAction action) { + if (!(action instanceof SerializableAction sa)) { + return null; + } + + var json = sa.toNode(); + var parsed = + JacksonMapper.getDefault().treeToValue(json, new TypeReference>() {}); + + Map> requestParams = new LinkedHashMap<>(); + for (Map.Entry e : parsed.entrySet()) { + var value = nodeToString(e.getValue()); + requestParams.put(e.getKey(), value); + } + + String encodedURL = requestParams.keySet().stream() + .map(key -> { + var vals = requestParams.get(key); + return vals.stream().map(s -> key + "=" + s).collect(Collectors.joining("&")); + }) + .collect(Collectors.joining("&", "xpipe://action?", "")); + return encodedURL; + } + + public static Optional parse(String queryString) throws Exception { + var query = splitQuery(queryString); + + var id = query.get("id"); + if (id == null || id.size() != 1) { + return Optional.empty(); + } + + var provider = ActionProvider.ALL.stream() + .filter(actionProvider -> id.getFirst().equals(actionProvider.getId())) + .findFirst(); + if (provider.isEmpty()) { + return Optional.empty(); + } + + var clazz = provider.get().getActionClass(); + if (clazz.isEmpty()) { + return Optional.empty(); + } + + if (!SerializableAction.class.isAssignableFrom(clazz.get())) { + return Optional.empty(); + } + + var stores = query.get("ref"); + if (stores == null || stores.isEmpty()) { + return Optional.empty(); + } + + for (String store : stores) { + var uuid = UuidHelper.parse(store); + if (uuid.isEmpty()) { + throw new IllegalArgumentException("Invalid store id: " + store); + } + + var entry = DataStorage.get().getStoreEntryIfPresent(uuid.get()); + if (entry.isEmpty()) { + throw new IllegalArgumentException("Store not found for id: " + store); + } + + if (!entry.get().getValidity().isUsable()) { + throw new IllegalArgumentException( + "Store " + DataStorage.get().getStorePath(entry.get()) + " is incomplete"); + } + } + + var fixedMap = query.entrySet().stream() + .collect(Collectors.toMap( + entry -> entry.getKey(), + entry -> entry.getValue().size() == 1 ? entry.getValue().getFirst() : entry.getValue())); + var json = (ObjectNode) JacksonMapper.getDefault().valueToTree(fixedMap); + var instance = ActionJacksonMapper.parse(json); + return Optional.ofNullable(instance); + } + + private static Map> splitQuery(String query) { + if (query == null || query.isBlank()) { + return Collections.emptyMap(); + } + + return Arrays.stream(query.split("&")) + .map(ActionUrls::splitQueryParameter) + .collect(Collectors.groupingBy( + AbstractMap.SimpleImmutableEntry::getKey, + LinkedHashMap::new, + Collectors.mapping(Map.Entry::getValue, Collectors.toList()))); + } + + private static AbstractMap.SimpleImmutableEntry splitQueryParameter(String it) { + final int idx = it.indexOf("="); + final String key = idx > 0 ? it.substring(0, idx) : it; + final String value = idx > 0 && it.length() > idx + 1 ? it.substring(idx + 1) : null; + return new AbstractMap.SimpleImmutableEntry<>( + URLDecoder.decode(key, StandardCharsets.UTF_8), + value != null ? URLDecoder.decode(value, StandardCharsets.UTF_8) : null); + } +} diff --git a/app/src/main/java/io/xpipe/app/action/LauncherUrlProvider.java b/app/src/main/java/io/xpipe/app/action/LauncherUrlProvider.java new file mode 100644 index 000000000..669829c57 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/action/LauncherUrlProvider.java @@ -0,0 +1,10 @@ +package io.xpipe.app.action; + +import java.net.URI; + +public interface LauncherUrlProvider extends ActionProvider { + + String getScheme(); + + AbstractAction createAction(URI uri) throws Exception; +} diff --git a/app/src/main/java/io/xpipe/app/action/SerializableAction.java b/app/src/main/java/io/xpipe/app/action/SerializableAction.java new file mode 100644 index 000000000..33a3166cd --- /dev/null +++ b/app/src/main/java/io/xpipe/app/action/SerializableAction.java @@ -0,0 +1,73 @@ +package io.xpipe.app.action; + +import io.xpipe.app.util.DataStoreFormatter; +import io.xpipe.core.util.JacksonMapper; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.experimental.SuperBuilder; + +import java.util.*; + +@SuperBuilder +public abstract class SerializableAction extends AbstractAction { + + public String toString() { + return toNode().toPrettyString(); + } + + public ObjectNode toNode() { + var json = ActionJacksonMapper.write(this); + return json; + } + + public ObjectNode toConfigNode() { + var json = toNode(); + json.remove("ref"); + json.remove("refs"); + return json; + } + + public Optional withConfigString(String configString) { + try { + var tree = (ObjectNode) JacksonMapper.getDefault().readTree(configString); + tree.put("id", getId()); + SerializableAction action = ActionJacksonMapper.parse(tree); + return Optional.ofNullable(action); + } catch (Exception ex) { + return Optional.empty(); + } + } + + @Override + public Map toDisplayMap() { + var node = toConfigNode(); + + var map = new LinkedHashMap(); + map.put("Action", getDisplayName()); + for (Map.Entry property : node.properties()) { + if (property.getKey().equals("id")) { + continue; + } + + var name = DataStoreFormatter.camelCaseToName(property.getKey()); + var value = property.getValue().asText(); + if (!value.isEmpty()) { + map.put(name, value); + } else if (property.getValue().isArray()) { + var list = new ArrayList(); + for (JsonNode jsonNode : property.getValue()) { + var s = jsonNode.asText(); + if (!s.isEmpty()) { + list.add(s); + } + } + + if (!list.isEmpty()) { + map.put(name, String.join("\n", list)); + } + } + } + return map; + } +} diff --git a/app/src/main/java/io/xpipe/app/action/StoreContextAction.java b/app/src/main/java/io/xpipe/app/action/StoreContextAction.java new file mode 100644 index 000000000..d0c1750b2 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/action/StoreContextAction.java @@ -0,0 +1,10 @@ +package io.xpipe.app.action; + +import io.xpipe.app.storage.DataStoreEntry; + +import java.util.List; + +public interface StoreContextAction { + + List getStoreEntryContext(); +} diff --git a/app/src/main/java/io/xpipe/app/action/XPipeUrlProvider.java b/app/src/main/java/io/xpipe/app/action/XPipeUrlProvider.java new file mode 100644 index 000000000..75f3f917f --- /dev/null +++ b/app/src/main/java/io/xpipe/app/action/XPipeUrlProvider.java @@ -0,0 +1,23 @@ +package io.xpipe.app.action; + +import java.net.URI; + +public class XPipeUrlProvider implements LauncherUrlProvider { + + @Override + public String getScheme() { + return "xpipe"; + } + + @Override + public AbstractAction createAction(URI uri) throws Exception { + var a = uri.getHost(); + if (!"action".equals(a)) { + return null; + } + + var query = uri.getQuery(); + var action = ActionUrls.parse(query); + return action.orElse(null); + } +} diff --git a/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java b/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java index c12a70ce4..1ed7dbc45 100644 --- a/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java +++ b/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java @@ -1,6 +1,6 @@ package io.xpipe.app.beacon; -import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.issue.TrackEvent; import io.xpipe.app.util.DocumentationLink; import io.xpipe.beacon.BeaconConfig; @@ -75,7 +75,7 @@ public class AppBeaconServer { } catch (Exception ex) { // Not terminal! // We can still continue without the running server - ErrorEvent.fromThrowable("Unable to start local http server on port " + INSTANCE.getPort(), ex) + ErrorEventFactory.fromThrowable("Unable to start local http server on port " + INSTANCE.getPort(), ex) .build() .handle(); } @@ -135,7 +135,7 @@ public class AppBeaconServer { t.setDaemon(true); t.setName("http handler"); t.setUncaughtExceptionHandler((t1, e) -> { - ErrorEvent.fromThrowable(e).handle(); + ErrorEventFactory.fromThrowable(e).handle(); }); return t; }); diff --git a/app/src/main/java/io/xpipe/app/beacon/BeaconRequestHandler.java b/app/src/main/java/io/xpipe/app/beacon/BeaconRequestHandler.java index 22561c0fd..8fc5a1ddc 100644 --- a/app/src/main/java/io/xpipe/app/beacon/BeaconRequestHandler.java +++ b/app/src/main/java/io/xpipe/app/beacon/BeaconRequestHandler.java @@ -1,7 +1,7 @@ package io.xpipe.app.beacon; import io.xpipe.app.core.mode.OperationMode; -import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.issue.TrackEvent; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.ThreadHelper; @@ -106,12 +106,12 @@ public class BeaconRequestHandler implements HttpHandler { response = beaconInterface.handle(exchange, object); } } catch (BeaconClientException clientException) { - ErrorEvent.fromThrowable(clientException).omit().expected().handle(); + ErrorEventFactory.fromThrowable(clientException).omit().expected().handle(); writeError(exchange, new BeaconClientErrorResponse(clientException.getMessage()), 400); return; } catch (BeaconServerException serverException) { var cause = serverException.getCause() != null ? serverException.getCause() : serverException; - var event = ErrorEvent.fromThrowable(cause).omit().handle(); + var event = ErrorEventFactory.fromThrowable(cause).omit().handle(); var link = event.getLink(); writeError(exchange, new BeaconServerErrorResponse(cause, link), 500); return; @@ -119,9 +119,9 @@ public class BeaconRequestHandler implements HttpHandler { // Handle serialization errors as normal exceptions and other IO exceptions as assuming that the connection // is broken if (!ex.getClass().getName().contains("jackson")) { - ErrorEvent.fromThrowable(ex).omit().expected().handle(); + ErrorEventFactory.fromThrowable(ex).omit().expected().handle(); } else { - ErrorEvent.fromThrowable(ex).omit().expected().handle(); + ErrorEventFactory.fromThrowable(ex).omit().expected().handle(); // Make deserialization error message more readable var message = ex.getMessage() .replace("$RequestBuilder", "") @@ -133,7 +133,7 @@ public class BeaconRequestHandler implements HttpHandler { } return; } catch (Throwable other) { - var event = ErrorEvent.fromThrowable(other).omit().expected().handle(); + var event = ErrorEventFactory.fromThrowable(other).omit().expected().handle(); var link = event.getLink(); writeError(exchange, new BeaconServerErrorResponse(other, link), 500); return; @@ -159,10 +159,10 @@ public class BeaconRequestHandler implements HttpHandler { } catch (IOException ioException) { // The exchange implementation might have already sent a response manually if (!"headers already sent".equals(ioException.getMessage())) { - ErrorEvent.fromThrowable(ioException).omit().expected().handle(); + ErrorEventFactory.fromThrowable(ioException).omit().expected().handle(); } } catch (Throwable other) { - var event = ErrorEvent.fromThrowable(other).handle(); + var event = ErrorEventFactory.fromThrowable(other).handle(); var link = event.getLink(); writeError(exchange, new BeaconServerErrorResponse(other, link), 500); } @@ -177,7 +177,7 @@ public class BeaconRequestHandler implements HttpHandler { os.write(bytes); } } catch (IOException ex) { - ErrorEvent.fromThrowable(ex).omit().expected().handle(); + ErrorEventFactory.fromThrowable(ex).omit().expected().handle(); } } diff --git a/app/src/main/java/io/xpipe/app/beacon/BlobManager.java b/app/src/main/java/io/xpipe/app/beacon/BlobManager.java index fc12a1b42..571ec7e8d 100644 --- a/app/src/main/java/io/xpipe/app/beacon/BlobManager.java +++ b/app/src/main/java/io/xpipe/app/beacon/BlobManager.java @@ -1,6 +1,6 @@ package io.xpipe.app.beacon; -import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.util.ShellTemp; import io.xpipe.beacon.BeaconClientException; @@ -36,7 +36,7 @@ public class BlobManager { } catch (IOException ignored) { } } catch (IOException e) { - ErrorEvent.fromThrowable(e).handle(); + ErrorEventFactory.fromThrowable(e).handle(); } } diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/AskpassExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/AskpassExchangeImpl.java index a99719526..4c35b398a 100644 --- a/app/src/main/java/io/xpipe/app/beacon/impl/AskpassExchangeImpl.java +++ b/app/src/main/java/io/xpipe/app/beacon/impl/AskpassExchangeImpl.java @@ -1,15 +1,15 @@ package io.xpipe.app.beacon.impl; -import io.xpipe.app.core.AppI18n; import io.xpipe.app.core.AppLayoutModel; import io.xpipe.app.terminal.TerminalView; import io.xpipe.app.util.*; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.AskpassExchange; +import io.xpipe.core.util.InPlaceSecretValue; + +import javafx.beans.property.SimpleStringProperty; import com.sun.net.httpserver.HttpExchange; -import io.xpipe.core.util.InPlaceSecretValue; -import javafx.beans.property.SimpleStringProperty; import java.time.Duration; @@ -25,15 +25,19 @@ public class AskpassExchangeImpl extends AskpassExchange { // SSH auth with a smartcard will prompt to confirm user presence // Maybe we can show some dialog for this in the future if (msg.getPrompt() != null && msg.getPrompt().toLowerCase().contains("confirm user presence")) { - var shown = AppLayoutModel.get().getQueueEntries().stream().anyMatch(queueEntry -> - msg.getPrompt().equals(queueEntry.getName().getValue())); + var shown = AppLayoutModel.get().getQueueEntries().stream().anyMatch(queueEntry -> msg.getPrompt() + .equals(queueEntry.getName().getValue())); if (!shown) { - var qe = new AppLayoutModel.QueueEntry(new SimpleStringProperty(msg.getPrompt()), new LabelGraphic.IconGraphic("mdi2f-fingerprint"), + var qe = new AppLayoutModel.QueueEntry( + new SimpleStringProperty(msg.getPrompt()), + new LabelGraphic.IconGraphic("mdi2f-fingerprint"), () -> {}); AppLayoutModel.get().getQueueEntries().add(qe); - GlobalTimer.delay(() -> { - AppLayoutModel.get().getQueueEntries().remove(qe); - }, Duration.ofSeconds(10)); + GlobalTimer.delay( + () -> { + AppLayoutModel.get().getQueueEntries().remove(qe); + }, + Duration.ofSeconds(10)); } return Response.builder().value(InPlaceSecretValue.of("")).build(); } diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionAddExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionAddExchangeImpl.java index b95c0e41d..0ab17b7b6 100644 --- a/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionAddExchangeImpl.java +++ b/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionAddExchangeImpl.java @@ -1,6 +1,6 @@ package io.xpipe.app.beacon.impl; -import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.beacon.BeaconClientException; @@ -42,10 +42,10 @@ public class ConnectionAddExchangeImpl extends ConnectionAddExchange { } } catch (Throwable ex) { if (ex instanceof ValidationException) { - ErrorEvent.expected(ex); + ErrorEventFactory.expected(ex); } else if (ex instanceof StackOverflowError) { // Cycles in connection graphs can fail hard but are expected - ErrorEvent.expected(ex); + ErrorEventFactory.expected(ex); } throw ex; } finally { diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionToggleExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionToggleExchangeImpl.java index 6025ce0eb..f71ef2e57 100644 --- a/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionToggleExchangeImpl.java +++ b/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionToggleExchangeImpl.java @@ -1,9 +1,9 @@ package io.xpipe.app.beacon.impl; +import io.xpipe.app.ext.SingletonSessionStore; import io.xpipe.app.storage.DataStorage; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.api.ConnectionToggleExchange; -import io.xpipe.core.store.SingletonSessionStore; import com.sun.net.httpserver.HttpExchange; diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/FsScriptExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/FsScriptExchangeImpl.java index 2bfb1a61c..92b7b9dd4 100644 --- a/app/src/main/java/io/xpipe/app/beacon/impl/FsScriptExchangeImpl.java +++ b/app/src/main/java/io/xpipe/app/beacon/impl/FsScriptExchangeImpl.java @@ -20,7 +20,7 @@ public class FsScriptExchangeImpl extends FsScriptExchange { try (var in = BlobManager.get().getBlob(msg.getBlob())) { data = new String(in.readAllBytes(), StandardCharsets.UTF_8); } - data = shell.getControl().getShellDialect().prepareScriptContent(data); + data = shell.getControl().getShellDialect().prepareScriptContent(shell.getControl(), data); var file = ScriptHelper.createExecScript(shell.getControl(), data); return Response.builder().path(file).build(); } diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/TerminalWaitExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/TerminalWaitExchangeImpl.java index 2765ed077..0c9f44734 100644 --- a/app/src/main/java/io/xpipe/app/beacon/impl/TerminalWaitExchangeImpl.java +++ b/app/src/main/java/io/xpipe/app/beacon/impl/TerminalWaitExchangeImpl.java @@ -1,7 +1,6 @@ package io.xpipe.app.beacon.impl; import io.xpipe.app.terminal.TerminalLauncherManager; -import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.BeaconServerException; import io.xpipe.beacon.api.TerminalWaitExchange; @@ -10,7 +9,7 @@ import com.sun.net.httpserver.HttpExchange; public class TerminalWaitExchangeImpl extends TerminalWaitExchange { @Override - public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException, BeaconServerException { + public Object handle(HttpExchange exchange, Request msg) throws BeaconServerException { TerminalLauncherManager.waitExchange(msg.getRequest()); return Response.builder().build(); } diff --git a/app/src/main/java/io/xpipe/app/browser/BrowserFileChooserSessionComp.java b/app/src/main/java/io/xpipe/app/browser/BrowserFileChooserSessionComp.java index d3434cb8d..3ca5f8596 100644 --- a/app/src/main/java/io/xpipe/app/browser/BrowserFileChooserSessionComp.java +++ b/app/src/main/java/io/xpipe/app/browser/BrowserFileChooserSessionComp.java @@ -7,11 +7,11 @@ import io.xpipe.app.browser.file.BrowserFileSystemTabComp; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.comp.Comp; import io.xpipe.app.comp.base.*; -import io.xpipe.app.comp.store.StoreEntryWrapper; -import io.xpipe.app.comp.store.StoreViewState; import io.xpipe.app.core.AppFontSizes; import io.xpipe.app.core.AppLayoutModel; import io.xpipe.app.ext.ShellStore; +import io.xpipe.app.hub.comp.StoreEntryWrapper; +import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.app.util.BindingsHelper; import io.xpipe.app.util.FileReference; @@ -43,7 +43,10 @@ public class BrowserFileChooserSessionComp extends ModalOverlayContentComp { } public static void openSingleFile( - Supplier> store, Supplier initialPath, Consumer file, boolean save) { + Supplier> store, + Supplier initialPath, + Consumer file, + boolean save) { var model = new BrowserFileChooserSessionModel(BrowserFileSystemTabModel.SelectionMode.SINGLE_FILE); model.setOnFinish(fileStores -> { file.accept(fileStores.size() > 0 ? fileStores.getFirst() : null); diff --git a/app/src/main/java/io/xpipe/app/browser/BrowserFullSessionComp.java b/app/src/main/java/io/xpipe/app/browser/BrowserFullSessionComp.java index ffc417591..e25145bdd 100644 --- a/app/src/main/java/io/xpipe/app/browser/BrowserFullSessionComp.java +++ b/app/src/main/java/io/xpipe/app/browser/BrowserFullSessionComp.java @@ -6,11 +6,12 @@ import io.xpipe.app.browser.file.BrowserTransferComp; import io.xpipe.app.comp.Comp; import io.xpipe.app.comp.SimpleComp; import io.xpipe.app.comp.base.*; -import io.xpipe.app.comp.store.StoreEntryWrapper; -import io.xpipe.app.comp.store.StoreViewState; +import io.xpipe.app.core.AppFontSizes; import io.xpipe.app.core.AppLayoutModel; import io.xpipe.app.core.window.AppMainWindow; import io.xpipe.app.ext.ShellStore; +import io.xpipe.app.hub.comp.StoreEntryWrapper; +import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.util.BindingsHelper; import io.xpipe.app.util.PlatformThread; import io.xpipe.app.util.ThreadHelper; @@ -61,9 +62,9 @@ public class BrowserFullSessionComp extends SimpleComp { .bind(Bindings.createObjectBinding( () -> new Insets(tabs.getHeaderHeight().get(), 0, 0, 0), tabs.getHeaderHeight())); }); - var loadingIndicator = LoadingOverlayComp.noProgress(Comp.empty(), model.getBusy()) + var loadingIndicator = new LoadingIconComp(model.getBusy(), AppFontSizes::xxxl) .apply(struc -> { - AnchorPane.setTopAnchor(struc.get(), 3.0); + AnchorPane.setTopAnchor(struc.get(), 0.0); AnchorPane.setRightAnchor(struc.get(), 0.0); }) .styleClass("tab-loading-indicator"); @@ -117,21 +118,21 @@ public class BrowserFullSessionComp extends SimpleComp { return true; } - return storeEntryWrapper.getEntry().getProvider().browserAction(model, storeEntryWrapper.getEntry(), null) + return storeEntryWrapper.getEntry().getProvider().launchBrowser(model, storeEntryWrapper.getEntry(), null) != null; }; BiConsumer action = (w, busy) -> { - ThreadHelper.runFailableAsync(() -> { - var entry = w.getEntry(); - if (!entry.getValidity().isUsable()) { - return; - } + var entry = w.getEntry(); + if (!entry.getValidity().isUsable()) { + return; + } - var a = entry.getProvider().browserAction(model, entry, busy); - if (a != null) { - a.execute(); - } - }); + var a = entry.getProvider().launchBrowser(model, entry, busy); + if (a != null) { + ThreadHelper.runFailableAsync(() -> { + a.run(); + }); + } }; var category = new SimpleObjectProperty<>( @@ -160,7 +161,7 @@ public class BrowserFullSessionComp extends SimpleComp { }) .vgrow(); var localDownloadStage = new BrowserTransferComp(model.getLocalTransfersStage()) - .hide(PlatformThread.sync(Bindings.createBooleanBinding( + .hide(Bindings.createBooleanBinding( () -> { if (model.getSessionEntries().size() == 0) { return true; @@ -169,7 +170,7 @@ public class BrowserFullSessionComp extends SimpleComp { return false; }, model.getSessionEntries(), - model.getSelectedEntry()))); + model.getSelectedEntry())); localDownloadStage.prefHeight(200); localDownloadStage.maxHeight(200); var vertical = @@ -212,6 +213,11 @@ public class BrowserFullSessionComp extends SimpleComp { struc.get().setMaxWidth(newValue.doubleValue()); }); + var clip = new Rectangle(); + clip.widthProperty().bind(struc.get().widthProperty()); + clip.heightProperty().bind(struc.get().heightProperty()); + struc.get().setClip(clip); + AnchorPane.setBottomAnchor(struc.get(), 0.0); AnchorPane.setRightAnchor(struc.get(), 0.0); tabs.getHeaderHeight().subscribe(number -> { diff --git a/app/src/main/java/io/xpipe/app/browser/BrowserFullSessionModel.java b/app/src/main/java/io/xpipe/app/browser/BrowserFullSessionModel.java index 2cb8fe6d9..604943b76 100644 --- a/app/src/main/java/io/xpipe/app/browser/BrowserFullSessionModel.java +++ b/app/src/main/java/io/xpipe/app/browser/BrowserFullSessionModel.java @@ -232,7 +232,12 @@ public class BrowserFullSessionModel extends BrowserAbstractSessionModel { - node.setClip(null); node.setPickOnBounds(false); var r = (Region) node; @@ -417,28 +418,24 @@ public class BrowserSessionTabsComp extends SimpleComp { }); if (tabModel.getIcon() != null) { - var ring = new RingProgressIndicator(0, false); - ring.setMinSize(16, 16); - ring.setPrefSize(16, 16); - ring.setMaxSize(16, 16); - ring.progressProperty() - .bind(Bindings.createDoubleBinding( - () -> tabModel.getBusy().get() - && !AppPrefs.get().performanceMode().get() - ? -1d - : 0, - PlatformThread.sync(tabModel.getBusy()), - AppPrefs.get().performanceMode())); + var loading = new LoadingIconComp(tabModel.getBusy(), AppFontSizes::base); + loading.prefWidth(16); + loading.prefHeight(16); var image = tabModel.getIcon(); - var logo = PrettyImageHelper.ofFixedSizeSquare(image, 16).createRegion(); + var logo = PrettyImageHelper.ofFixedSizeSquare(image, 16); + logo.apply(struc -> { + struc.get() + .opacityProperty() + .bind(PlatformThread.sync(Bindings.createDoubleBinding( + () -> { + return !tabModel.getBusy().get() ? 1.0 : 0.15; + }, + tabModel.getBusy()))); + }); - tab.graphicProperty() - .bind(Bindings.createObjectBinding( - () -> { - return tabModel.getBusy().get() ? ring : logo; - }, - PlatformThread.sync(tabModel.getBusy()))); + var stack = new StackComp(List.of(logo, loading)); + tab.setGraphic(stack.createRegion()); } if (tabModel.getBrowserModel() instanceof BrowserFullSessionModel sessionModel) { @@ -460,13 +457,15 @@ public class BrowserSessionTabsComp extends SimpleComp { Comp comp = tabModel.comp(); var compRegion = comp.createRegion(); + var empty = new StackPane(); - empty.setMinWidth(450); + empty.setMinWidth(0); empty.widthProperty().addListener((observable, oldValue, newValue) -> { if (tabModel.isCloseable() && tabs.getSelectionModel().getSelectedItem() == tab) { rightPadding.setValue(newValue.doubleValue()); } }); + var split = new SplitPane(compRegion); if (tabModel.isCloseable()) { split.getItems().add(empty); @@ -496,6 +495,7 @@ public class BrowserSessionTabsComp extends SimpleComp { if (newValue != null) { Platform.runLater(() -> { Label l = (Label) tabs.lookup("#" + id + " .tab-label"); + l.setGraphicTextGap(7); var w = l.maxWidthProperty(); l.minWidthProperty().bind(w); l.prefWidthProperty().bind(w); @@ -512,10 +512,11 @@ public class BrowserSessionTabsComp extends SimpleComp { if (color != null) { c.getStyleClass().add(color.getId()); } - c.addEventHandler( - DragEvent.DRAG_ENTERED, - mouseEvent -> Platform.runLater( - () -> tabs.getSelectionModel().select(tab))); + c.addEventHandler(DragEvent.DRAG_ENTERED, mouseEvent -> { + if (tabModel.isCloseable()) { + Platform.runLater(() -> tabs.getSelectionModel().select(tab)); + } + }); }); } }); diff --git a/app/src/main/java/io/xpipe/app/browser/action/BrowserAction.java b/app/src/main/java/io/xpipe/app/browser/action/BrowserAction.java index 4ab2c2ef0..1643083c1 100644 --- a/app/src/main/java/io/xpipe/app/browser/action/BrowserAction.java +++ b/app/src/main/java/io/xpipe/app/browser/action/BrowserAction.java @@ -1,118 +1,112 @@ package io.xpipe.app.browser.action; +import io.xpipe.app.browser.BrowserFullSessionModel; +import io.xpipe.app.browser.BrowserStoreSessionTab; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; -import io.xpipe.app.issue.ErrorEvent; -import io.xpipe.core.util.ModuleLayerLoader; +import io.xpipe.app.core.AppLayoutModel; +import io.xpipe.app.hub.action.StoreAction; +import io.xpipe.core.store.FilePath; +import io.xpipe.core.store.FileSystemStore; -import javafx.beans.value.ObservableValue; -import javafx.scene.Node; -import javafx.scene.control.MenuItem; -import javafx.scene.input.KeyCombination; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.experimental.SuperBuilder; -import java.util.ArrayList; import java.util.List; -import java.util.ServiceLoader; -public interface BrowserAction { +@SuperBuilder +public abstract class BrowserAction extends StoreAction { - List ALL = new ArrayList<>(); + protected final List files; - static List getFlattened(BrowserFileSystemTabModel model, List entries) { - return ALL.stream() - .map(browserAction -> getFlattened(browserAction, model, entries)) - .flatMap(List::stream) + @JsonIgnore + protected BrowserFileSystemTabModel model; + + @JsonIgnore + private List entries; + + @Override + protected boolean beforeExecute() throws Exception { + AppLayoutModel.get().selectBrowser(); + + if (model == null) { + var found = BrowserFullSessionModel.DEFAULT.getAllTabs().stream() + .filter(t -> t instanceof BrowserStoreSessionTab bs + && bs.getEntry().equals(ref)) + .findFirst(); + if (found.isPresent()) { + model = (BrowserFileSystemTabModel) found.get(); + } else { + model = BrowserFullSessionModel.DEFAULT.openFileSystemSync( + ref.asNeeded(), + model -> { + var isFile = model.getFileSystem().fileExists(files.getFirst()); + if (isFile) { + return files.getFirst().getParent(); + } else { + var dir = files.getFirst().getParent(); + if (!model.getFileSystem().directoryExists(dir)) { + throw new IllegalArgumentException("Directory does not exist: " + dir); + } + return dir; + } + }, + null, + true); + } + } + + model.getBusy().set(true); + + // Start shell in case we exited + model.getFileSystem().getShell().orElseThrow().start(); + + return true; + } + + @Override + protected void afterExecute() { + model.getBusy().set(false); + } + + protected List getEntries() { + if (entries != null) { + return entries; + } + + entries = files.stream() + .map(filePath -> { + var be = model.getFileList().getAll().getValue().stream() + .filter(browserEntry -> + browserEntry.getRawFileEntry().getPath().equals(filePath)) + .findFirst(); + if (be.isPresent()) { + return be.get(); + } + + return null; + }) + .filter(browserEntry -> browserEntry != null) .toList(); + return entries; } - static List getFlattened( - BrowserAction browserAction, BrowserFileSystemTabModel model, List entries) { - return browserAction instanceof BrowserLeafAction - ? List.of((BrowserLeafAction) browserAction) - : ((BrowserBranchAction) browserAction) - .getBranchingActions(model, entries).stream() - .map(action -> getFlattened(action, model, entries)) - .flatMap(List::stream) - .toList(); - } + public abstract static class BrowserActionBuilder> + extends StoreActionBuilder { - static BrowserLeafAction byId(String id, BrowserFileSystemTabModel model, List entries) { - return getFlattened(model, entries).stream() - .filter(browserAction -> id.equals(browserAction.getId())) - .findAny() - .orElseThrow(); - } - - default List resolveFilesIfNeeded(List selected) { - return automaticallyResolveLinks() - ? selected.stream() - .map(browserEntry -> - new BrowserEntry(browserEntry.getRawFileEntry().resolved(), browserEntry.getModel())) - .toList() - : selected; - } - - MenuItem toMenuItem(BrowserFileSystemTabModel model, List selected); - - default void init(BrowserFileSystemTabModel model) throws Exception {} - - default String getProFeatureId() { - return null; - } - - default Node getIcon(BrowserFileSystemTabModel model, List entries) { - return null; - } - - default Category getCategory() { - return null; - } - - default KeyCombination getShortcut() { - return null; - } - - default boolean acceptsEmptySelection() { - return false; - } - - ObservableValue getName(BrowserFileSystemTabModel model, List entries); - - default boolean isApplicable(BrowserFileSystemTabModel model, List entries) { - return true; - } - - default boolean automaticallyResolveLinks() { - return true; - } - - default boolean isActive(BrowserFileSystemTabModel model, List entries) { - return true; - } - - enum Category { - CUSTOM, - OPEN, - NATIVE, - COPY_PASTE, - MUTATION - } - - class Loader implements ModuleLayerLoader { - - @Override - public void init(ModuleLayer layer) { - ALL.addAll(ServiceLoader.load(layer, BrowserAction.class).stream() - .map(actionProviderProvider -> actionProviderProvider.get()) - .filter(provider -> { - try { - return true; - } catch (Throwable e) { - ErrorEvent.fromThrowable(e).handle(); - return false; - } - }) + public void initEntries(BrowserFileSystemTabModel model, List entries) { + ref(model.getEntry().asNeeded()); + model(model); + files(entries.stream() + .map(browserEntry -> browserEntry.getRawFileEntry().getPath()) .toList()); + entries(entries); + } + + public void initFiles(BrowserFileSystemTabModel model, List entries) { + ref(model.getEntry().asNeeded()); + model(model); + files(entries); } } } diff --git a/app/src/main/java/io/xpipe/app/browser/action/BrowserActionFormatter.java b/app/src/main/java/io/xpipe/app/browser/action/BrowserActionFormatter.java deleted file mode 100644 index fd7b02903..000000000 --- a/app/src/main/java/io/xpipe/app/browser/action/BrowserActionFormatter.java +++ /dev/null @@ -1,25 +0,0 @@ -package io.xpipe.app.browser.action; - -import io.xpipe.app.browser.file.BrowserEntry; - -import java.util.List; - -public class BrowserActionFormatter { - - public static String filesArgument(List entries) { - return entries.size() == 1 ? entries.getFirst().getFileName() : "(" + entries.size() + ")"; - } - - public static String centerEllipsis(String input, int length) { - if (input == null) { - return ""; - } - - if (input.length() <= length) { - return input; - } - - var half = (length / 2) - 5; - return input.substring(0, half) + " ... " + input.substring(input.length() - half); - } -} diff --git a/app/src/main/java/io/xpipe/app/browser/action/BrowserActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/BrowserActionProvider.java new file mode 100644 index 000000000..0a3b5e732 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/BrowserActionProvider.java @@ -0,0 +1,18 @@ +package io.xpipe.app.browser.action; + +import io.xpipe.app.action.ActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; + +import java.util.List; + +public interface BrowserActionProvider extends ActionProvider { + + default boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + return true; + } + + default boolean isActive(BrowserFileSystemTabModel model, List entries) { + return true; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/BrowserActionProviders.java b/app/src/main/java/io/xpipe/app/browser/action/BrowserActionProviders.java new file mode 100644 index 000000000..ab055ec22 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/BrowserActionProviders.java @@ -0,0 +1,13 @@ +package io.xpipe.app.browser.action; + +import io.xpipe.app.action.ActionProvider; + +public class BrowserActionProviders { + + public static BrowserActionProvider forClass(Class clazz) { + return (BrowserActionProvider) ActionProvider.ALL.stream() + .filter(actionProvider -> actionProvider.getClass().equals(clazz)) + .findFirst() + .orElseThrow(); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/BrowserLeafAction.java b/app/src/main/java/io/xpipe/app/browser/action/BrowserLeafAction.java deleted file mode 100644 index 0c7a91348..000000000 --- a/app/src/main/java/io/xpipe/app/browser/action/BrowserLeafAction.java +++ /dev/null @@ -1,114 +0,0 @@ -package io.xpipe.app.browser.action; - -import io.xpipe.app.browser.file.BrowserEntry; -import io.xpipe.app.browser.file.BrowserFileSystemTabModel; -import io.xpipe.app.comp.base.TooltipHelper; -import io.xpipe.app.util.BindingsHelper; -import io.xpipe.app.util.BooleanScope; -import io.xpipe.app.util.LicenseProvider; -import io.xpipe.app.util.ThreadHelper; - -import javafx.scene.control.Button; -import javafx.scene.control.MenuItem; -import javafx.scene.control.Tooltip; -import javafx.scene.input.KeyEvent; -import javafx.scene.layout.Region; - -import org.kordamp.ikonli.javafx.FontIcon; - -import java.util.List; - -public interface BrowserLeafAction extends BrowserAction { - - void execute(BrowserFileSystemTabModel model, List entries) throws Exception; - - default Button toButton(Region root, BrowserFileSystemTabModel model, List selected) { - var b = new Button(); - b.setOnAction(event -> { - ThreadHelper.runFailableAsync(() -> { - BooleanScope.executeExclusive(model.getBusy(), () -> { - if (model.getFileSystem() == null) { - return; - } - - // Start shell in case we exited - model.getFileSystem().getShell().orElseThrow().start(); - execute(model, selected); - }); - }); - event.consume(); - }); - var name = getName(model, selected); - Tooltip.install(b, TooltipHelper.create(name, getShortcut())); - var graphic = getIcon(model, selected); - if (graphic != null) { - b.setGraphic(graphic); - } - b.setMnemonicParsing(false); - b.accessibleTextProperty().bind(name); - root.addEventFilter(KeyEvent.KEY_PRESSED, event -> { - if (getShortcut() != null && getShortcut().match(event)) { - b.fire(); - event.consume(); - } - }); - - b.setDisable(!isActive(model, selected)); - model.getCurrentPath().addListener((observable, oldValue, newValue) -> { - b.setDisable(!isActive(model, selected)); - }); - - if (getProFeatureId() != null - && !LicenseProvider.get().getFeature(getProFeatureId()).isSupported()) { - b.setDisable(true); - b.setGraphic(new FontIcon("mdi2p-professional-hexagon")); - } - - return b; - } - - default MenuItem toMenuItem(BrowserFileSystemTabModel model, List selected) { - var name = getName(model, selected); - var mi = new MenuItem(); - mi.textProperty().bind(BindingsHelper.map(name, s -> { - if (getProFeatureId() != null) { - return LicenseProvider.get().getFeature(getProFeatureId()).suffix(s); - } - return s; - })); - mi.setOnAction(event -> { - ThreadHelper.runFailableAsync(() -> { - BooleanScope.executeExclusive(model.getBusy(), () -> { - if (model.getFileSystem() == null) { - return; - } - - // Start shell in case we exited - model.getFileSystem().getShell().orElseThrow().start(); - execute(model, selected); - }); - }); - event.consume(); - }); - if (getShortcut() != null) { - mi.setAccelerator(getShortcut()); - } - var graphic = getIcon(model, selected); - if (graphic != null) { - mi.setGraphic(graphic); - } - mi.setMnemonicParsing(false); - mi.setDisable(!isActive(model, selected)); - - if (getProFeatureId() != null - && !LicenseProvider.get().getFeature(getProFeatureId()).isSupported()) { - mi.setDisable(true); - } - - return mi; - } - - default String getId() { - return null; - } -} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/ApplyFileEditActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/ApplyFileEditActionProvider.java new file mode 100644 index 000000000..aa7d5f07a --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/ApplyFileEditActionProvider.java @@ -0,0 +1,55 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.action.AbstractAction; +import io.xpipe.app.action.ActionProvider; +import io.xpipe.app.browser.file.BrowserFileOutput; + +import lombok.NonNull; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; + +public class ApplyFileEditActionProvider implements ActionProvider { + + @Override + public String getId() { + return "applyFileEdit"; + } + + @Jacksonized + @SuperBuilder + public static class Action extends AbstractAction { + + @NonNull + String target; + + @NonNull + InputStream input; + + @NonNull + BrowserFileOutput output; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + try (var out = output.open()) { + input.transferTo(out); + } + } + + @Override + public Map toDisplayMap() { + var map = new LinkedHashMap(); + map.put("action", getDisplayName()); + map.put("target", target); + return map; + } + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/BrowseInNativeManagerActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/BrowseInNativeManagerActionProvider.java new file mode 100644 index 000000000..cb26969e8 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/BrowseInNativeManagerActionProvider.java @@ -0,0 +1,49 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.util.DesktopHelper; +import io.xpipe.app.util.LocalShell; +import io.xpipe.core.process.ShellControl; + +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +public class BrowseInNativeManagerActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @Override + public void executeImpl() throws Exception { + ShellControl sc = model.getFileSystem().getShell().orElseThrow(); + for (BrowserEntry entry : getEntries()) { + var e = entry.getRawFileEntry().getPath(); + var localFile = sc.getLocalSystemAccess().translateToLocalSystemPath(e); + try (var local = LocalShell.getShell().start()) { + DesktopHelper.browsePathRemote( + local, localFile, entry.getRawFileEntry().getKind()); + } + } + } + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + return model.getFileSystem() + .getShell() + .orElseThrow() + .getLocalSystemAccess() + .supportsFileSystemAccess(); + } + + @Override + public String getId() { + return "browseInNativeFileManager"; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/ChgrpActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/ChgrpActionProvider.java new file mode 100644 index 000000000..fcd0cfeca --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/ChgrpActionProvider.java @@ -0,0 +1,61 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.core.process.CommandBuilder; +import io.xpipe.core.process.OsType; + +import lombok.NonNull; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +public class ChgrpActionProvider implements BrowserActionProvider { + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + var os = model.getFileSystem().getShell().orElseThrow().getOsType(); + return os != OsType.WINDOWS && os != OsType.MACOS; + } + + @Override + public String getId() { + return "chgrp"; + } + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @NonNull + private final String group; + + private final boolean recursive; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + model.getFileSystem() + .getShell() + .orElseThrow() + .executeSimpleCommand(CommandBuilder.of() + .add("chgrp") + .addIf(recursive, "-R") + .addLiteral(group) + .addFiles(getEntries().stream() + .map(browserEntry -> browserEntry + .getRawFileEntry() + .getPath() + .toString()) + .toList())); + model.refreshEntriesSync(getEntries()); + } + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/ChmodActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/ChmodActionProvider.java new file mode 100644 index 000000000..c3a85048d --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/ChmodActionProvider.java @@ -0,0 +1,60 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.core.process.CommandBuilder; +import io.xpipe.core.process.OsType; + +import lombok.NonNull; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +public class ChmodActionProvider implements BrowserActionProvider { + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + return model.getFileSystem().getShell().orElseThrow().getOsType() != OsType.WINDOWS; + } + + @Override + public String getId() { + return "chmod"; + } + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @NonNull + private final String permissions; + + private final boolean recursive; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + model.getFileSystem() + .getShell() + .orElseThrow() + .executeSimpleCommand(CommandBuilder.of() + .add("chmod") + .addIf(recursive, "-R") + .addLiteral(permissions) + .addFiles(getEntries().stream() + .map(browserEntry -> browserEntry + .getRawFileEntry() + .getPath() + .toString()) + .toList())); + model.refreshEntriesSync(getEntries()); + } + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/ChownActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/ChownActionProvider.java new file mode 100644 index 000000000..fec60a56d --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/ChownActionProvider.java @@ -0,0 +1,61 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.core.process.CommandBuilder; +import io.xpipe.core.process.OsType; + +import lombok.NonNull; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +public class ChownActionProvider implements BrowserActionProvider { + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + var os = model.getFileSystem().getShell().orElseThrow().getOsType(); + return os != OsType.WINDOWS && os != OsType.MACOS; + } + + @Override + public String getId() { + return "chown"; + } + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @NonNull + private final String owner; + + private final boolean recursive; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + model.getFileSystem() + .getShell() + .orElseThrow() + .executeSimpleCommand(CommandBuilder.of() + .add("chown") + .addIf(recursive, "-R") + .addLiteral(owner) + .addFiles(getEntries().stream() + .map(browserEntry -> browserEntry + .getRawFileEntry() + .getPath() + .toString()) + .toList())); + model.refreshSync(); + } + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/ComputeDirectorySizesActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/ComputeDirectorySizesActionProvider.java new file mode 100644 index 000000000..985a64eda --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/ComputeDirectorySizesActionProvider.java @@ -0,0 +1,42 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.core.store.FileKind; + +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +public class ComputeDirectorySizesActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @Override + public void executeImpl() throws Exception { + var entries = getEntries(); + if (entries.size() == 1 && entries.getFirst().getRawFileEntry().equals(model.getCurrentDirectory())) { + entries = model.getFileList().getAll().getValue(); + } + + for (BrowserEntry be : entries) { + if (be.getRawFileEntry().resolved().getKind() != FileKind.DIRECTORY) { + continue; + } + + var size = model.getFileSystem() + .getDirectorySize(be.getRawFileEntry().resolved().getPath()); + var fileEntry = be.getRawFileEntry(); + fileEntry.resolved().setSize("" + size); + model.getFileList().updateEntry(be.getRawFileEntry().getPath(), fileEntry); + } + } + } + + @Override + public String getId() { + return "computeDirectorySizes"; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/DeleteActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/DeleteActionProvider.java new file mode 100644 index 000000000..14b69bba7 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/DeleteActionProvider.java @@ -0,0 +1,34 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.*; + +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +public class DeleteActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + var toDelete = + getEntries().stream().map(entry -> entry.getRawFileEntry()).toList(); + BrowserFileSystemHelper.delete(toDelete); + model.refreshSync(); + } + } + + @Override + public String getId() { + return "deleteFile"; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/MoveFileActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/MoveFileActionProvider.java new file mode 100644 index 000000000..80b5be997 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/MoveFileActionProvider.java @@ -0,0 +1,36 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.core.store.FilePath; + +import lombok.NonNull; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +public class MoveFileActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @NonNull + FilePath target; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + model.getFileSystem().move(getEntries().getFirst().getRawFileEntry().getPath(), target); + model.refreshSync(); + } + } + + @Override + public String getId() { + return "moveFile"; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/NewDirectoryActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/NewDirectoryActionProvider.java new file mode 100644 index 000000000..1df184799 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/NewDirectoryActionProvider.java @@ -0,0 +1,44 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.core.store.FileKind; + +import lombok.NonNull; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +public class NewDirectoryActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @NonNull + String name; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + for (BrowserEntry entry : getEntries()) { + if (entry.getRawFileEntry().getKind() != FileKind.DIRECTORY) { + continue; + } + + var file = entry.getRawFileEntry().getPath().join(name); + model.getFileSystem().mkdirs(file); + } + model.refreshSync(); + } + } + + @Override + public String getId() { + return "newDirectory"; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/NewFileActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/NewFileActionProvider.java new file mode 100644 index 000000000..22572b9b9 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/NewFileActionProvider.java @@ -0,0 +1,44 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.core.store.FileKind; + +import lombok.NonNull; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +public class NewFileActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @NonNull + String name; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + for (BrowserEntry entry : getEntries()) { + if (entry.getRawFileEntry().getKind() != FileKind.DIRECTORY) { + continue; + } + + var file = entry.getRawFileEntry().getPath().join(name); + model.getFileSystem().touch(file); + } + model.refreshSync(); + } + } + + @Override + public String getId() { + return "newFile"; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/NewLinkActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/NewLinkActionProvider.java new file mode 100644 index 000000000..17d3b1ae9 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/NewLinkActionProvider.java @@ -0,0 +1,48 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.core.store.FileKind; +import io.xpipe.core.store.FilePath; + +import lombok.NonNull; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +public class NewLinkActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @NonNull + String name; + + @NonNull + FilePath target; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + for (BrowserEntry entry : getEntries()) { + if (entry.getRawFileEntry().getKind() != FileKind.DIRECTORY) { + continue; + } + + var file = entry.getRawFileEntry().getPath().join(name); + model.getFileSystem().symbolicLink(file, target); + } + model.refreshSync(); + } + } + + @Override + public String getId() { + return "newLink"; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/OpenDirectoryActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/OpenDirectoryActionProvider.java new file mode 100644 index 000000000..387d06313 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/OpenDirectoryActionProvider.java @@ -0,0 +1,37 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.core.store.FileKind; + +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +public class OpenDirectoryActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @Override + public void executeImpl() { + var first = getEntries().getFirst(); + model.cdSync(first.getRawFileEntry().getPath().toString()); + } + } + + @Override + public String getId() { + return "openDirectory"; + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + return entries.size() == 1 + && entries.stream().allMatch(entry -> entry.getRawFileEntry().getKind() == FileKind.DIRECTORY); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/OpenFileDefaultActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/OpenFileDefaultActionProvider.java new file mode 100644 index 000000000..8ef809211 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/OpenFileDefaultActionProvider.java @@ -0,0 +1,39 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileOpener; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.core.store.FileKind; + +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +public class OpenFileDefaultActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @Override + public void executeImpl() { + for (var entry : getEntries()) { + BrowserFileOpener.openInDefaultApplication(model, entry.getRawFileEntry()); + } + } + } + + @Override + public String getId() { + return "openFileDefault"; + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + return model.getFileList().getEditing().getValue() == null + && entries.stream().allMatch(entry -> entry.getRawFileEntry().getKind() == FileKind.FILE); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/OpenFileNativeDetailsActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/OpenFileNativeDetailsActionProvider.java new file mode 100644 index 000000000..8836f301f --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/OpenFileNativeDetailsActionProvider.java @@ -0,0 +1,97 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.util.LocalShell; +import io.xpipe.core.process.CommandBuilder; +import io.xpipe.core.process.OsType; +import io.xpipe.core.process.ShellControl; +import io.xpipe.core.store.FileKind; + +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +public class OpenFileNativeDetailsActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @Override + public void executeImpl() throws Exception { + ShellControl sc = model.getFileSystem().getShell().get(); + for (BrowserEntry entry : getEntries()) { + var e = entry.getRawFileEntry().getPath(); + var localFile = sc.getLocalSystemAccess().translateToLocalSystemPath(e); + switch (OsType.getLocal()) { + case OsType.Windows windows -> { + var parent = localFile.getParent(); + // If we execute this on a drive root there will be no parent, so we have to check for that! + var content = parent != null + ? String.format( + "$shell = New-Object -ComObject Shell.Application; $shell.NameSpace('%s').ParseName('%s').InvokeVerb('Properties')", + parent, localFile.getFileName()) + : String.format( + "$shell = New-Object -ComObject Shell.Application; $shell.NameSpace('%s').Self.InvokeVerb('Properties')", + localFile); + + // The Windows shell invoke verb functionality behaves kinda weirdly and only shows the window + // as + // long as the parent process is running. + // So let's keep one process running + LocalShell.getLocalPowershell() + .command(content) + .notComplex() + .execute(); + } + case OsType.Linux linux -> { + var dbus = String.format( + """ + dbus-send --session --print-reply --dest=org.freedesktop.FileManager1 --type=method_call /org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItemProperties array:string:"file://%s" string:"" + """, + localFile); + var success = sc.executeSimpleBooleanCommand(dbus); + if (success) { + return; + } + + sc.command(CommandBuilder.of() + .add("xdg-open") + .addFile( + entry.getRawFileEntry().getKind() == FileKind.DIRECTORY + ? e + : e.getParent())) + .execute(); + } + case OsType.MacOs macOs -> { + sc.osascriptCommand(String.format( + """ + set fileEntry to (POSIX file "%s") as text + tell application "Finder" + activate + open information window of alias fileEntry + end tell + """, + localFile)) + .execute(); + } + } + } + } + } + + @Override + public String getId() { + return "openFileNativeDetails"; + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + var sc = model.getFileSystem().getShell().orElseThrow(); + return sc.getLocalSystemAccess().supportsFileSystemAccess(); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/OpenFileWithActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/OpenFileWithActionProvider.java new file mode 100644 index 000000000..d195951b7 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/OpenFileWithActionProvider.java @@ -0,0 +1,41 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileOpener; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.core.process.OsType; +import io.xpipe.core.store.FileKind; + +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +public class OpenFileWithActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @Override + public void executeImpl() { + for (var entry : getEntries()) { + BrowserFileOpener.openWithAnyApplication(model, entry.getRawFileEntry()); + } + } + } + + @Override + public String getId() { + return "openFileWith"; + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + return OsType.getLocal().equals(OsType.WINDOWS) + && entries.size() == 1 + && entries.stream().allMatch(entry -> entry.getRawFileEntry().getKind() == FileKind.FILE); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/OpenTerminalActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/OpenTerminalActionProvider.java new file mode 100644 index 000000000..918785722 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/OpenTerminalActionProvider.java @@ -0,0 +1,56 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.core.store.FileKind; +import io.xpipe.core.store.FilePath; + +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +import java.util.Collections; +import java.util.List; + +public class OpenTerminalActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @Override + public void executeImpl() throws Exception { + var entries = getEntries(); + var dirs = entries.size() > 0 + ? entries.stream() + .map(browserEntry -> browserEntry.getRawFileEntry().getPath()) + .toList() + : model.getCurrentDirectory() != null + ? List.of(model.getCurrentDirectory().getPath()) + : Collections.singletonList((FilePath) null); + for (var dir : dirs) { + var name = (dir != null ? dir + " - " : "") + model.getName().getValue(); + model.openTerminalSync( + name, dir, model.getFileSystem().getShell().orElseThrow(), dirs.size() == 1); + } + } + } + + @Override + public String getId() { + return "openTerminalInDirectory"; + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + return entries.stream().allMatch(entry -> entry.getRawFileEntry().getKind() == FileKind.DIRECTORY); + } + + @Override + public boolean isActive(BrowserFileSystemTabModel model, List entries) { + var t = AppPrefs.get().terminalType().getValue(); + return t != null; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/RunCommandInBackgroundActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/RunCommandInBackgroundActionProvider.java new file mode 100644 index 000000000..48468d736 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/RunCommandInBackgroundActionProvider.java @@ -0,0 +1,63 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.issue.ErrorEventFactory; +import io.xpipe.core.process.CommandBuilder; +import io.xpipe.core.process.ProcessOutputException; + +import lombok.NonNull; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +import java.util.concurrent.atomic.AtomicReference; + +public class RunCommandInBackgroundActionProvider implements BrowserActionProvider { + + @Override + public String getId() { + return "runFileInBackground"; + } + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @NonNull + String command; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + var cmd = CommandBuilder.of().addFile(command); + for (BrowserEntry entry : getEntries()) { + cmd.addFile(entry.getRawFileEntry().getPath()); + } + + AtomicReference out = new AtomicReference<>(); + AtomicReference err = new AtomicReference<>(); + long exitCode; + try (var command = model.getFileSystem() + .getShell() + .orElseThrow() + .command(cmd) + .withWorkingDirectory(model.getCurrentDirectory().getPath()) + .start()) { + var r = command.readStdoutAndStderr(); + out.set(r[0]); + err.set(r[1]); + exitCode = command.getExitCode(); + } + + // Only throw actual error output + if (exitCode != 0) { + throw ErrorEventFactory.expected(ProcessOutputException.of(exitCode, out.get(), err.get())); + } + } + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/RunCommandInBrowserActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/RunCommandInBrowserActionProvider.java new file mode 100644 index 000000000..0f23c6f23 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/RunCommandInBrowserActionProvider.java @@ -0,0 +1,43 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.util.CommandDialog; +import io.xpipe.core.process.CommandBuilder; + +import lombok.NonNull; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +public class RunCommandInBrowserActionProvider implements BrowserActionProvider { + + @Override + public String getId() { + return "runCommandInBrowser"; + } + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @NonNull + String command; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() { + var builder = CommandBuilder.of().addFile(command); + for (BrowserEntry entry : getEntries()) { + builder.addFile(entry.getRawFileEntry().getPath()); + } + + var cmd = model.getFileSystem().getShell().orElseThrow().command(builder); + CommandDialog.runAsyncAndShow(cmd); + } + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/RunCommandInTerminalActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/RunCommandInTerminalActionProvider.java new file mode 100644 index 000000000..8129495a1 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/RunCommandInTerminalActionProvider.java @@ -0,0 +1,50 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.core.process.CommandBuilder; + +import lombok.NonNull; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +public class RunCommandInTerminalActionProvider implements BrowserActionProvider { + + @Override + public String getId() { + return "runCommandInTerminal"; + } + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @NonNull + String title; + + @NonNull + String command; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + var cmd = CommandBuilder.of().addFile(command); + for (BrowserEntry entry : getEntries()) { + cmd.addFile(entry.getRawFileEntry().getPath()); + } + + model.openTerminalSync( + title, + model.getCurrentDirectory() != null + ? model.getCurrentDirectory().getPath() + : null, + model.getFileSystem().getShell().orElseThrow().command(cmd), + true); + } + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/action/impl/TransferFilesActionProvider.java b/app/src/main/java/io/xpipe/app/browser/action/impl/TransferFilesActionProvider.java new file mode 100644 index 000000000..0b97ca9d3 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/action/impl/TransferFilesActionProvider.java @@ -0,0 +1,69 @@ +package io.xpipe.app.browser.action.impl; + +import io.xpipe.app.action.AbstractAction; +import io.xpipe.app.action.ActionProvider; +import io.xpipe.app.action.StoreContextAction; +import io.xpipe.app.browser.file.BrowserFileTransferOperation; +import io.xpipe.app.storage.DataStorage; +import io.xpipe.app.storage.DataStoreEntry; +import io.xpipe.app.storage.DataStoreEntryRef; +import io.xpipe.core.store.FileSystemStore; + +import lombok.NonNull; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class TransferFilesActionProvider implements ActionProvider { + + @Override + public String getId() { + return "transferFiles"; + } + + @Jacksonized + @SuperBuilder + public static class Action extends AbstractAction implements StoreContextAction { + + @NonNull + DataStoreEntryRef target; + + @NonNull + BrowserFileTransferOperation operation; + + boolean download; + + @Override + public boolean isMutation() { + return !download; + } + + @Override + public void executeImpl() throws Exception { + operation.execute(); + } + + @Override + public Map toDisplayMap() { + var map = new LinkedHashMap(); + map.put("action", getDisplayName()); + map.put( + "sources", + operation.getFiles().stream() + .map(fileEntry -> fileEntry.getName()) + .collect(Collectors.joining("\n"))); + map.put("target", DataStorage.get().getStoreEntryDisplayName(target.get())); + map.put("targetDirectory", operation.getTarget().getPath().toString()); + return map; + } + + @Override + public List getStoreEntryContext() { + return List.of(target.get()); + } + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserAlerts.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserAlerts.java index 6679e180c..e7846f735 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserAlerts.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserAlerts.java @@ -1,61 +1,45 @@ package io.xpipe.app.browser.file; +import io.xpipe.app.comp.base.ModalButton; +import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.core.AppI18n; +import io.xpipe.app.core.window.AppDialog; import io.xpipe.app.core.window.AppWindowHelper; -import io.xpipe.app.storage.DataStorage; import io.xpipe.core.store.FileEntry; import io.xpipe.core.store.FileKind; import io.xpipe.core.store.FilePath; +import javafx.beans.property.SimpleObjectProperty; import javafx.scene.control.Alert; -import javafx.scene.control.ButtonBar; -import javafx.scene.control.ButtonType; -import java.util.LinkedHashMap; import java.util.List; import java.util.stream.Collectors; public class BrowserAlerts { public static FileConflictChoice showFileConflictAlert(FilePath file, boolean multiple) { - var map = new LinkedHashMap(); - map.put(new ButtonType(AppI18n.get("cancel"), ButtonBar.ButtonData.CANCEL_CLOSE), FileConflictChoice.CANCEL); - if (multiple) { - map.put(new ButtonType(AppI18n.get("skip"), ButtonBar.ButtonData.OTHER), FileConflictChoice.SKIP); - map.put(new ButtonType(AppI18n.get("skipAll"), ButtonBar.ButtonData.OTHER), FileConflictChoice.SKIP_ALL); - } - map.put(new ButtonType(AppI18n.get("replace"), ButtonBar.ButtonData.OTHER), FileConflictChoice.REPLACE); - if (multiple) { - map.put( - new ButtonType(AppI18n.get("replaceAll"), ButtonBar.ButtonData.OTHER), - FileConflictChoice.REPLACE_ALL); - } - map.put(new ButtonType(AppI18n.get("rename"), ButtonBar.ButtonData.OTHER), FileConflictChoice.RENAME); - if (multiple) { - map.put( - new ButtonType(AppI18n.get("renameAll"), ButtonBar.ButtonData.OTHER), - FileConflictChoice.RENAME_ALL); - } + var choice = new SimpleObjectProperty(); + var key = multiple ? "fileConflictAlertContentMultiple" : "fileConflictAlertContent"; var w = multiple ? 700 : 400; - return AppWindowHelper.showBlockingAlert(alert -> { - alert.setTitle(AppI18n.get("fileConflictAlertTitle")); - alert.setHeaderText(AppI18n.get("fileConflictAlertHeader")); - alert.setAlertType(Alert.AlertType.CONFIRMATION); - alert.getButtonTypes().clear(); - alert.getDialogPane() - .setContent(AppWindowHelper.alertContentText( - AppI18n.get( - multiple ? "fileConflictAlertContentMultiple" : "fileConflictAlertContent", - file), - w - 50)); - alert.getDialogPane().setMinWidth(w); - alert.getDialogPane().setPrefWidth(w); - alert.getDialogPane().setMaxWidth(w); - map.sequencedKeySet() - .forEach(buttonType -> alert.getButtonTypes().add(buttonType)); - }) - .map(map::get) - .orElse(FileConflictChoice.CANCEL); + var modal = ModalOverlay.of( + "fileConflictAlertTitle", + AppDialog.dialogText(AppI18n.observable(key, file)).prefWidth(w)); + modal.addButton(new ModalButton("cancel", () -> choice.set(FileConflictChoice.CANCEL), true, false)); + if (multiple) { + modal.addButton(new ModalButton("skip", () -> choice.set(FileConflictChoice.SKIP), true, false)); + modal.addButton(new ModalButton("skipAll", () -> choice.set(FileConflictChoice.SKIP_ALL), true, false)); + } + modal.addButton(new ModalButton("replace", () -> choice.set(FileConflictChoice.REPLACE), true, false)); + if (multiple) { + modal.addButton( + new ModalButton("replaceAll", () -> choice.set(FileConflictChoice.REPLACE_ALL), true, false)); + } + modal.addButton(new ModalButton("rename", () -> choice.set(FileConflictChoice.RENAME), true, false)); + if (multiple) { + modal.addButton(new ModalButton("renameAll", () -> choice.set(FileConflictChoice.RENAME_ALL), true, false)); + } + modal.showAndWait(); + return choice.get() != null ? choice.get() : FileConflictChoice.CANCEL; } public static boolean showMoveAlert(List source, FileEntry target) { @@ -74,25 +58,6 @@ public class BrowserAlerts { .orElse(false); } - public static boolean showDeleteAlert(BrowserFileSystemTabModel model, List source) { - var config = - DataStorage.get().getEffectiveCategoryConfig(model.getEntry().get()); - if (!Boolean.TRUE.equals(config.getConfirmAllModifications()) - && source.stream().noneMatch(entry -> entry.getKind() == FileKind.DIRECTORY)) { - return true; - } - - return AppWindowHelper.showBlockingAlert(alert -> { - alert.setTitle(AppI18n.get("deleteAlertTitle")); - alert.setHeaderText(AppI18n.get("deleteAlertHeader", source.size())); - alert.getDialogPane() - .setContent(AppWindowHelper.alertContentText(getSelectedElementsString(source))); - alert.setAlertType(Alert.AlertType.CONFIRMATION); - }) - .map(b -> b.getButtonData().isDefaultButton()) - .orElse(false); - } - private static String getSelectedElementsString(List source) { var namesHeader = AppI18n.get("selectedElements"); var names = namesHeader + "\n" diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserBreadcrumbBar.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserBreadcrumbBar.java index 16ed30682..07f2f4be4 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserBreadcrumbBar.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserBreadcrumbBar.java @@ -2,7 +2,7 @@ package io.xpipe.app.browser.file; import io.xpipe.app.comp.SimpleComp; import io.xpipe.app.util.PlatformThread; -import io.xpipe.core.store.FileNames; +import io.xpipe.core.store.FilePath; import javafx.scene.Node; import javafx.scene.control.Button; @@ -14,6 +14,7 @@ import javafx.util.Callback; import atlantafx.base.controls.Breadcrumbs; import java.util.ArrayList; +import java.util.List; public class BrowserBreadcrumbBar extends SimpleComp { @@ -26,7 +27,7 @@ public class BrowserBreadcrumbBar extends SimpleComp { @Override protected Region createSimple() { Callback, ButtonBase> crumbFactory = crumb -> { - var name = crumb.getValue().equals("/") ? "/" : FileNames.getFileName(crumb.getValue()); + var name = crumb.getValue().equals("/") ? "/" : FilePath.of(crumb.getValue()).getFileName(); var btn = new Button(name, null); btn.setMnemonicParsing(false); btn.setFocusTraversable(false); @@ -41,37 +42,39 @@ public class BrowserBreadcrumbBar extends SimpleComp { var breadcrumbs = new Breadcrumbs(); breadcrumbs.setMinWidth(0); - PlatformThread.sync(model.getCurrentPath()).subscribe(val -> { - if (val == null) { - breadcrumbs.setSelectedCrumb(null); - return; - } + model.getCurrentPath().subscribe(val -> { + PlatformThread.runLaterIfNeeded(() -> { + if (val == null) { + breadcrumbs.setSelectedCrumb(null); + return; + } - var sc = model.getFileSystem().getShell(); - if (sc.isEmpty()) { - breadcrumbs.setDividerFactory(item -> item != null && !item.isLast() ? new Label("/") : null); - } else { - breadcrumbs.setDividerFactory(item -> { - if (item == null) { - return null; - } + var sc = model.getFileSystem().getShell(); + if (sc.isEmpty()) { + breadcrumbs.setDividerFactory(item -> item != null && !item.isLast() ? new Label("/") : null); + } else { + breadcrumbs.setDividerFactory(item -> { + if (item == null) { + return null; + } - if (item.isFirst() && item.getValue().equals("/")) { - return new Label(""); - } + if (item.isFirst() && item.getValue().equals("/")) { + return new Label(""); + } - return new Label(sc.get().getOsType().getFileSystemSeparator()); - }); - } + return new Label(sc.get().getOsType().getFileSystemSeparator()); + }); + } - var elements = val.splitHierarchy(); - var modifiedElements = new ArrayList<>(elements); - if (val.toString().startsWith("/")) { - modifiedElements.addFirst("/"); - } - Breadcrumbs.BreadCrumbItem items = - Breadcrumbs.buildTreeModel(modifiedElements.toArray(String[]::new)); - breadcrumbs.setSelectedCrumb(items); + var elements = createBreadcumbHierarchy(val); + var modifiedElements = new ArrayList<>(elements); + if (val.toString().startsWith("/")) { + modifiedElements.addFirst("/"); + } + Breadcrumbs.BreadCrumbItem items = + Breadcrumbs.buildTreeModel(modifiedElements.toArray(String[]::new)); + breadcrumbs.setSelectedCrumb(items); + }); }); if (crumbFactory != null) { @@ -87,4 +90,20 @@ public class BrowserBreadcrumbBar extends SimpleComp { return breadcrumbs; } + + private List createBreadcumbHierarchy(FilePath filePath) { + var f = filePath.toString() + "/"; + var list = new ArrayList(); + int lastElementStart = 0; + for (int i = 0; i < f.length(); i++) { + if (f.charAt(i) == '\\' || f.charAt(i) == '/') { + if (i - lastElementStart > 0) { + list.add(f.substring(0, i)); + } + + lastElementStart = i + 1; + } + } + return list; + } } diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserClipboard.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserClipboard.java index 8fb5b2f8b..4afee4563 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserClipboard.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserClipboard.java @@ -1,11 +1,9 @@ package io.xpipe.app.browser.file; import io.xpipe.app.ext.ProcessControlProvider; -import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.util.GlobalClipboard; -import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.store.FileEntry; -import io.xpipe.core.util.FailableRunnable; import javafx.beans.property.Property; import javafx.beans.property.SimpleObjectProperty; @@ -16,7 +14,6 @@ import javafx.scene.input.Dragboard; import lombok.SneakyThrows; import lombok.Value; -import java.awt.*; import java.awt.datatransfer.Clipboard; import java.awt.datatransfer.DataFlavor; import java.io.File; @@ -45,7 +42,10 @@ public class BrowserClipboard { List data = (List) clipboard.getData(DataFlavor.javaFileListFlavor); // Sometimes file data can contain invalid chars. Why? - var files = data.stream().filter(file -> file.toString().chars().noneMatch(value -> Character.isISOControl(value))).map(f -> f.toPath()).toList(); + var files = data.stream() + .filter(file -> file.toString().chars().noneMatch(value -> Character.isISOControl(value))) + .map(f -> f.toPath()) + .toList(); if (files.size() == 0) { return; } @@ -55,9 +55,10 @@ public class BrowserClipboard { entries.add(BrowserLocalFileSystem.getLocalBrowserEntry(file)); } - currentCopyClipboard.setValue(new Instance(UUID.randomUUID(), null, entries, BrowserFileTransferMode.COPY)); + currentCopyClipboard.setValue( + new Instance(UUID.randomUUID(), null, entries, BrowserFileTransferMode.COPY)); } catch (Exception e) { - ErrorEvent.fromThrowable(e).expected().omit().handle(); + ErrorEventFactory.fromThrowable(e).expected().omit().handle(); } } }); diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListComp.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListComp.java index d1d541861..d0b09206e 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListComp.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListComp.java @@ -3,7 +3,7 @@ package io.xpipe.app.browser.file; import io.xpipe.app.comp.Comp; import io.xpipe.app.comp.CompStructure; import io.xpipe.app.comp.SimpleComp; -import io.xpipe.app.comp.store.*; +import io.xpipe.app.hub.comp.*; import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.app.util.PlatformThread; @@ -73,7 +73,8 @@ public final class BrowserConnectionListComp extends SimpleComp { filter, category, StoreViewState.get().getEntriesListVisibilityObservable(), - StoreViewState.get().getEntriesListUpdateObservable()), + StoreViewState.get().getEntriesListUpdateObservable(), + new ReadOnlyBooleanWrapper(true)), augment, selectedAction -> { BooleanProperty busy = new SimpleBooleanProperty(false); diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListFilterComp.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListFilterComp.java index cb5222bea..f5b65673d 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListFilterComp.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserConnectionListFilterComp.java @@ -3,9 +3,9 @@ package io.xpipe.app.browser.file; import io.xpipe.app.comp.SimpleComp; import io.xpipe.app.comp.base.FilterComp; import io.xpipe.app.comp.base.HorizontalComp; -import io.xpipe.app.comp.store.StoreCategoryWrapper; -import io.xpipe.app.comp.store.StoreViewState; import io.xpipe.app.core.AppFontSizes; +import io.xpipe.app.hub.comp.StoreCategoryWrapper; +import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.util.DataStoreCategoryChoiceComp; import javafx.beans.property.Property; diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserContextMenu.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserContextMenu.java index b11ea11c3..9241a809e 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserContextMenu.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserContextMenu.java @@ -1,6 +1,8 @@ package io.xpipe.app.browser.file; -import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.action.ActionProvider; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuItemProvider; import io.xpipe.app.core.AppFontSizes; import io.xpipe.app.util.InputHelper; @@ -44,8 +46,10 @@ public final class BrowserContextMenu extends ContextMenu { return; } - for (BrowserAction.Category cat : BrowserAction.Category.values()) { - var all = BrowserAction.ALL.stream() + for (var cat : BrowserMenuCategory.values()) { + var all = ActionProvider.ALL.stream() + .map(actionProvider -> actionProvider instanceof BrowserMenuItemProvider ba ? ba : null) + .filter(browserActionProvider -> browserActionProvider != null) .filter(browserAction -> browserAction.getCategory() == cat) .filter(browserAction -> { if (model.isClosed()) { @@ -72,13 +76,16 @@ public final class BrowserContextMenu extends ContextMenu { getItems().add(new SeparatorMenuItem()); } - for (BrowserAction a : all) { + for (var a : all) { if (model.isClosed()) { return; } var used = a.resolveFilesIfNeeded(selected); - getItems().add(a.toMenuItem(model, used)); + var item = a.toMenuItem(model, used); + if (item != null) { + getItems().add(item); + } } } } diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListComp.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListComp.java index 400aaf766..d17ab0357 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListComp.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListComp.java @@ -1,6 +1,6 @@ package io.xpipe.app.browser.file; -import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.menu.BrowserMenuProviders; import io.xpipe.app.comp.SimpleComp; import io.xpipe.app.core.AppI18n; import io.xpipe.app.util.*; @@ -29,6 +29,7 @@ import java.time.Instant; import java.time.ZoneId; import java.util.*; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; import static io.xpipe.app.util.HumanReadableFormat.byteCount; import static javafx.scene.control.TableColumn.SortType.ASCENDING; @@ -76,7 +77,6 @@ public final class BrowserFileListComp extends SimpleComp { param.getValue().getRawFileEntry().resolved().getSize())); sizeCol.setCellFactory(col -> new FileSizeCell()); sizeCol.setResizable(false); - sizeCol.setPrefWidth(120); sizeCol.setReorderable(false); var mtimeCol = new TableColumn(); @@ -124,13 +124,14 @@ public final class BrowserFileListComp extends SimpleComp { }); table.setFixedCellSize(30.0); - prepareColumnVisibility(table, ownerCol, filenameCol); + prepareColumnVisibility(table, filenameCol, mtimeCol, modeCol, ownerCol, sizeCol); prepareTableScrollFix(table); prepareTableSelectionModel(table); prepareTableShortcuts(table); prepareTableEntries(table); prepareTableChanges(table, filenameCol, mtimeCol, modeCol, ownerCol); prepareTypedSelectionModel(table); + table.setMinWidth(0); return table; } @@ -148,8 +149,11 @@ public final class BrowserFileListComp extends SimpleComp { private void prepareColumnVisibility( TableView table, + TableColumn filenameCol, + TableColumn mtimeCol, + TableColumn modeCol, TableColumn ownerCol, - TableColumn filenameCol) { + TableColumn sizeCol) { var os = fileList.getFileSystemModel() .getFileSystem() .getShell() @@ -159,6 +163,15 @@ public final class BrowserFileListComp extends SimpleComp { if (os != OsType.WINDOWS && os != OsType.MACOS) { ownerCol.setVisible(newValue.doubleValue() > 1000); } + + var shell = fileList.getFileSystemModel().getFileSystem().getShell().orElseThrow(); + if (!OsType.WINDOWS.equals(shell.getOsType()) && !OsType.MACOS.equals(shell.getOsType())) { + modeCol.setVisible(newValue.doubleValue() > 600); + } + + mtimeCol.setPrefWidth(newValue.doubleValue() == 0.0 || newValue.doubleValue() > 600 ? 150 : 100); + sizeCol.setPrefWidth(newValue.doubleValue() == 0.0 || newValue.doubleValue() > 600 ? 120 : 90); + var width = getFilenameWidth(table); filenameCol.setPrefWidth(width); }); @@ -171,7 +184,7 @@ public final class BrowserFileListComp extends SimpleComp { .mapToDouble(value -> value.getPrefWidth()) .sum() + 7; - return tableView.getWidth() - sum; + return Math.max(200, tableView.getWidth() - sum); } private String formatOwner(BrowserEntry param) { @@ -336,7 +349,7 @@ public final class BrowserFileListComp extends SimpleComp { } var selected = fileList.getSelection(); - var action = BrowserAction.getFlattened(fileList.getFileSystemModel(), selected).stream() + var action = BrowserMenuProviders.getFlattened(fileList.getFileSystemModel(), selected).stream() .filter(browserAction -> browserAction.isApplicable(fileList.getFileSystemModel(), selected) && browserAction.isActive(fileList.getFileSystemModel(), selected)) .filter(browserAction -> browserAction.getShortcut() != null) @@ -345,9 +358,11 @@ public final class BrowserFileListComp extends SimpleComp { action.ifPresent(browserAction -> { // Prevent concurrent modification by creating copy on platform thread var selectionCopy = new ArrayList<>(selected); - ThreadHelper.runFailableAsync(() -> { + try { browserAction.execute(fileList.getFileSystemModel(), selectionCopy); - }); + } catch (Exception e) { + throw new RuntimeException(e); + } event.consume(); }); if (action.isPresent()) { @@ -479,8 +494,29 @@ public final class BrowserFileListComp extends SimpleComp { TableColumn modeCol, TableColumn ownerCol) { var lastDir = new SimpleObjectProperty(); - Runnable updateHandler = () -> { + BiConsumer, List> updateHandler = (o, n) -> { PlatformThread.runLaterIfNeeded(() -> { + // Optimization for single entry updates + if (o != null && n != null && o.size() == n.size()) { + var left = new HashSet<>(n); + o.forEach(left::remove); + if (left.size() == 1) { + var updatedEntry = left.iterator().next(); + var found = o.stream() + .filter(browserEntry -> browserEntry + .getRawFileEntry() + .getPath() + .equals(updatedEntry.getRawFileEntry().getPath())) + .findFirst(); + if (found.isPresent()) { + table.refresh(); + table.getItems().set(table.getItems().indexOf(found.get()), updatedEntry); + return; + } + } + } + + table.setDisable(true); var newItems = new ArrayList<>(fileList.getShown().getValue()); table.getItems().clear(); @@ -506,21 +542,17 @@ public final class BrowserFileListComp extends SimpleComp { ownerCol.setPrefWidth(0); } - if (fileList.getFileSystemModel().getFileSystem() != null) { - var shell = fileList.getFileSystemModel() - .getFileSystem() - .getShell() - .orElseThrow(); - if (OsType.WINDOWS.equals(shell.getOsType()) || OsType.MACOS.equals(shell.getOsType())) { - modeCol.setVisible(false); + var shell = + fileList.getFileSystemModel().getFileSystem().getShell().orElseThrow(); + if (OsType.WINDOWS.equals(shell.getOsType()) || OsType.MACOS.equals(shell.getOsType())) { + modeCol.setVisible(false); + ownerCol.setVisible(false); + } else { + modeCol.setVisible(table.getWidth() > 600); + if (table.getWidth() > 1000) { + ownerCol.setVisible(hasOwner); + } else if (!hasOwner) { ownerCol.setVisible(false); - } else { - modeCol.setVisible(true); - if (table.getWidth() > 1000) { - ownerCol.setVisible(hasOwner); - } else if (!hasOwner) { - ownerCol.setVisible(false); - } } } @@ -541,17 +573,20 @@ public final class BrowserFileListComp extends SimpleComp { } } lastDir.setValue(currentDirectory); + table.setDisable(false); }); }; - updateHandler.run(); + updateHandler.accept(null, null); fileList.getShown().addListener((observable, oldValue, newValue) -> { // Delay to prevent internal tableview exceptions when sorting - Platform.runLater(updateHandler); + Platform.runLater(() -> { + updateHandler.accept(oldValue, newValue); + }); }); fileList.getFileSystemModel().getCurrentPath().addListener((observable, oldValue, newValue) -> { if (oldValue == null) { - updateHandler.run(); + updateHandler.accept(null, null); } }); } @@ -594,16 +629,15 @@ public final class BrowserFileListComp extends SimpleComp { if (empty || getTableRow() == null || getTableRow().getItem() == null) { setText(null); } else { - var path = getTableRow().getItem(); - if (path.getRawFileEntry().resolved().getKind() == FileKind.DIRECTORY) { - setText(null); - } else if (fileSize != null) { + if (fileSize != null) { try { var l = Long.parseLong(fileSize); setText(byteCount(l)); } catch (NumberFormatException e) { setText(fileSize); } + } else { + setText(null); } } } diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListFilterComp.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListFilterComp.java index cab84e76f..f888ffbc1 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListFilterComp.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListFilterComp.java @@ -6,9 +6,8 @@ import io.xpipe.app.comp.base.TextFieldComp; import io.xpipe.app.comp.base.TooltipHelper; import io.xpipe.app.core.AppI18n; import io.xpipe.app.util.InputHelper; - import io.xpipe.app.util.PlatformThread; -import javafx.application.Platform; + import javafx.beans.property.Property; import javafx.beans.property.SimpleBooleanProperty; import javafx.geometry.Pos; diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListModel.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListModel.java index 9160c743f..fd532dab1 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListModel.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListModel.java @@ -1,10 +1,12 @@ package io.xpipe.app.browser.file; -import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.browser.action.impl.MoveFileActionProvider; +import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.core.process.OsType; import io.xpipe.core.store.FileEntry; import io.xpipe.core.store.FileKind; +import io.xpipe.core.store.FilePath; import javafx.beans.property.Property; import javafx.beans.property.SimpleBooleanProperty; @@ -56,12 +58,31 @@ public final class BrowserFileListModel { } } + public void updateEntry(FilePath p, FileEntry n) { + var found = all.getValue().stream() + .filter(browserEntry -> browserEntry.getRawFileEntry().getPath().equals(p)) + .findFirst(); + if (found.isEmpty()) { + return; + } + + var index = all.getValue().indexOf(found.get()); + var l = new ArrayList<>(all.getValue()); + if (n != null) { + l.set(index, new BrowserEntry(n, this)); + } else { + l.remove(index); + } + all.setValue(l); + refreshShown(); + } + public void setComparator(Comparator comparator) { comparatorProperty.setValue(comparator); refreshShown(); } - private void refreshShown() { + void refreshShown() { List filtered = fileSystemModel.getFilter().getValue() != null ? all.getValue().stream() .filter(entry -> { @@ -90,7 +111,7 @@ public final class BrowserFileListModel { return us; } - public BrowserEntry rename(BrowserEntry old, String newName) { + public BrowserEntry rename(BrowserEntry old, String newName) throws Exception { if (old == null || newName == null || fileSystemModel == null @@ -99,7 +120,6 @@ public final class BrowserFileListModel { return old; } - var fullPath = fileSystemModel.getCurrentPath().get().join(old.getFileName()); var newFullPath = fileSystemModel.getCurrentPath().get().join(newName); // This check will fail on case-insensitive file systems when changing the case of the file @@ -113,22 +133,25 @@ public final class BrowserFileListModel { exists = fileSystemModel.getFileSystem().fileExists(newFullPath) || fileSystemModel.getFileSystem().directoryExists(newFullPath); } catch (Exception e) { - ErrorEvent.fromThrowable(e).handle(); + ErrorEventFactory.fromThrowable(e).handle(); return old; } if (exists) { - ErrorEvent.fromMessage("Target " + newFullPath + " does already exist") + ErrorEventFactory.fromMessage("Target " + newFullPath + " does already exist") .expected() .handle(); - fileSystemModel.refresh(); + fileSystemModel.refreshSync(); return old; } } try { - fileSystemModel.getFileSystem().move(fullPath, newFullPath); - fileSystemModel.refresh(); + var builder = MoveFileActionProvider.Action.builder(); + builder.initEntries(fileSystemModel, List.of(old)); + builder.target(newFullPath); + builder.build().executeSync(); + var b = all.getValue().stream() .filter(browserEntry -> browserEntry.getRawFileEntry().getPath().equals(newFullPath)) @@ -136,7 +159,7 @@ public final class BrowserFileListModel { .orElse(old); return b; } catch (Exception e) { - ErrorEvent.fromThrowable(e).handle(); + ErrorEventFactory.fromThrowable(e).handle(); return old; } } diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListNameCell.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListNameCell.java index dc18cb107..27309b700 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListNameCell.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileListNameCell.java @@ -8,6 +8,7 @@ import io.xpipe.app.util.InputHelper; import io.xpipe.app.util.PlatformThread; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.store.FileKind; +import io.xpipe.core.store.FilePath; import javafx.application.Platform; import javafx.beans.binding.Bindings; @@ -58,7 +59,7 @@ class BrowserFileListNameCell extends TableCell { var textField = new LazyTextFieldComp(text) .minWidth(USE_PREF_SIZE) .createStructure() - .get(); + .getTextField(); var quickAccess = createQuickAccessButton(); setupShortcuts(tableView, (ButtonBase) quickAccess); setupRename(fileList, textField, editing); @@ -143,7 +144,7 @@ class BrowserFileListNameCell extends TableCell { getTableRow().requestFocus(); var it = getTableRow().getItem(); editing.setValue(null); - ThreadHelper.runAsync(() -> { + ThreadHelper.runFailableAsync(() -> { if (it == null) { return; } @@ -163,6 +164,21 @@ class BrowserFileListNameCell extends TableCell { PlatformThread.runLaterIfNeeded(() -> { textField.setDisable(false); textField.requestFocus(); + + var content = textField.getText(); + if (content != null && !content.isEmpty()) { + var name = FilePath.of(content); + var baseNameEnd = name.getBaseName().toString().length(); + textField.positionCaret(baseNameEnd); + } + }); + } + }); + + textField.disabledProperty().addListener((observable, oldValue, newValue) -> { + if (!oldValue && newValue) { + Platform.runLater(() -> { + editing.setValue(null); }); } }); diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOpener.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOpener.java index fd81572fe..f901da974 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOpener.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOpener.java @@ -4,6 +4,7 @@ import io.xpipe.app.core.AppI18n; import io.xpipe.app.core.window.AppDialog; import io.xpipe.app.ext.ConnectionFileSystem; import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.app.util.BooleanScope; import io.xpipe.app.util.FileBridge; import io.xpipe.app.util.FileOpener; @@ -20,58 +21,98 @@ import java.io.FilterOutputStream; import java.io.IOException; import java.io.OutputStream; import java.util.Objects; +import java.util.Optional; public class BrowserFileOpener { - private static OutputStream openFileOutput(BrowserFileSystemTabModel model, FileEntry file, long totalBytes) + private static BrowserFileOutput openFileOutput(BrowserFileSystemTabModel model, FileEntry file, long totalBytes) throws Exception { var fileSystem = model.getFileSystem(); if (model.isClosed() || fileSystem.getShell().isEmpty()) { - return OutputStream.nullOutputStream(); + return BrowserFileOutput.none(); } if (totalBytes == 0) { var existingSize = model.getFileSystem().getFileSize(file.getPath()); if (existingSize != 0) { - var blank = AppDialog.confirm("fileWriteBlankTitle", AppI18n.observable("fileWriteBlankContent", file.getPath())); + var blank = AppDialog.confirm( + "fileWriteBlankTitle", AppI18n.observable("fileWriteBlankContent", file.getPath())); if (!blank) { - return OutputStream.nullOutputStream(); + return BrowserFileOutput.none(); } } } + var defOutput = new BrowserFileOutput() { + + @Override + public Optional target() { + return Optional.of(model.getEntry().get()); + } + + @Override + public boolean hasOutput() { + return true; + } + + @Override + public OutputStream open() throws Exception { + return fileSystem.openOutput(file.getPath(), totalBytes); + } + }; + var sc = fileSystem.getShell().get(); if (sc.getOsType() == OsType.WINDOWS) { - return fileSystem.openOutput(file.getPath(), totalBytes); + return defOutput; } var info = (FileInfo.Unix) file.getInfo(); var requiresSudo = requiresSudo(model, info, file.getPath()); if (!requiresSudo) { - return fileSystem.openOutput(file.getPath(), totalBytes); + return defOutput; } var elevate = AppDialog.confirm("fileWriteSudo"); if (!elevate) { - return fileSystem.openOutput(file.getPath(), totalBytes); + return defOutput; } var rootSc = sc.identicalDialectSubShell() .elevated(ElevationFunction.elevated(null)) .start(); var rootFs = new ConnectionFileSystem(rootSc); - try { - return new FilterOutputStream(rootFs.openOutput(file.getPath(), totalBytes)) { - @Override - public void close() throws IOException { - super.close(); + var rootOutput = new BrowserFileOutput() { + + @Override + public Optional target() { + return Optional.of(model.getEntry().get()); + } + + @Override + public boolean hasOutput() { + return true; + } + + @Override + public OutputStream open() throws Exception { + try { + return new FilterOutputStream(rootFs.openOutput(file.getPath(), totalBytes)) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + rootFs.close(); + } + } + }; + } catch (Exception ex) { rootFs.close(); + throw ex; } - }; - } catch (Exception ex) { - rootFs.close(); - throw ex; - } + } + }; + return rootOutput; } private static boolean requiresSudo(BrowserFileSystemTabModel model, FileInfo.Unix info, FilePath filePath) @@ -128,10 +169,25 @@ public class BrowserFileOpener { }, (size) -> { if (model.isClosed()) { - return OutputStream.nullOutputStream(); + return BrowserFileOutput.none(); } - return entry.getFileSystem().openOutput(file, size); + return new BrowserFileOutput() { + @Override + public boolean hasOutput() { + return true; + } + + @Override + public Optional target() { + return Optional.of(model.getEntry().get()); + } + + @Override + public OutputStream open() throws Exception { + return entry.getFileSystem().openOutput(file, size); + } + }; }, s -> FileOpener.openWithAnyApplication(s)); } @@ -154,10 +210,25 @@ public class BrowserFileOpener { }, (size) -> { if (model.isClosed()) { - return OutputStream.nullOutputStream(); + return BrowserFileOutput.none(); } - return entry.getFileSystem().openOutput(file, size); + return new BrowserFileOutput() { + @Override + public boolean hasOutput() { + return true; + } + + @Override + public Optional target() { + return Optional.of(model.getEntry().get()); + } + + @Override + public OutputStream open() throws Exception { + return entry.getFileSystem().openOutput(file, size); + } + }; }, s -> FileOpener.openInDefaultApplication(s)); } diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOutput.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOutput.java new file mode 100644 index 000000000..37b22b415 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileOutput.java @@ -0,0 +1,35 @@ +package io.xpipe.app.browser.file; + +import io.xpipe.app.storage.DataStoreEntry; + +import java.io.OutputStream; +import java.util.Optional; + +public interface BrowserFileOutput { + + static BrowserFileOutput none() { + return new BrowserFileOutput() { + + @Override + public Optional target() { + return Optional.empty(); + } + + @Override + public boolean hasOutput() { + return false; + } + + @Override + public OutputStream open() { + return null; + } + }; + } + + Optional target(); + + boolean hasOutput(); + + OutputStream open() throws Exception; +} diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemHelper.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemHelper.java index 25b63bf8b..e3d1156ef 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemHelper.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemHelper.java @@ -1,6 +1,6 @@ package io.xpipe.app.browser.file; -import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.core.process.OsType; import io.xpipe.core.store.*; @@ -26,9 +26,6 @@ public class BrowserFileSystemHelper { } // Handle special case when file system creation has failed - if (model.getFileSystem() == null) { - return path; - } var shell = model.getFileSystem().getShell(); if (shell.isEmpty()) { @@ -47,10 +44,6 @@ public class BrowserFileSystemHelper { return null; } - if (model.getFileSystem() == null) { - return path; - } - var shell = model.getFileSystem().getShell(); if (shell.isEmpty() || !shell.get().isRunning(true)) { return path; @@ -63,7 +56,7 @@ public class BrowserFileSystemHelper { .readStdoutOrThrow(); return !r.isBlank() ? r : null; } catch (Exception ex) { - ErrorEvent.expected(ex); + ErrorEventFactory.expected(ex); throw ex; } } @@ -74,10 +67,6 @@ public class BrowserFileSystemHelper { return null; } - if (model.getFileSystem() == null) { - return path; - } - var shell = model.getFileSystem().getShell(); if (shell.isEmpty()) { return path; @@ -105,24 +94,20 @@ public class BrowserFileSystemHelper { return; } - if (model.getFileSystem() == null) { - return; - } - var shell = model.getFileSystem().getShell(); if (shell.isEmpty()) { return; } if (verifyExists && !model.getFileSystem().directoryExists(path)) { - throw ErrorEvent.expected(new IllegalArgumentException( + throw ErrorEventFactory.expected(new IllegalArgumentException( String.format("Directory %s does not exist or is not accessible", path))); } try { model.getFileSystem().directoryAccessible(path); } catch (Exception ex) { - ErrorEvent.expected(ex); + ErrorEventFactory.expected(ex); throw ex; } } @@ -146,7 +131,7 @@ public class BrowserFileSystemHelper { try { file.getFileSystem().delete(file.getPath()); } catch (Throwable t) { - ErrorEvent.fromThrowable(t).handle(); + ErrorEventFactory.fromThrowable(t).handle(); } } } diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabComp.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabComp.java index ca4c12c78..05a58b9b3 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabComp.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabComp.java @@ -1,7 +1,7 @@ package io.xpipe.app.browser.file; import io.xpipe.app.browser.BrowserFullSessionModel; -import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.menu.BrowserMenuProviders; import io.xpipe.app.comp.Comp; import io.xpipe.app.comp.SimpleComp; import io.xpipe.app.comp.SimpleCompStructure; @@ -65,11 +65,12 @@ public class BrowserFileSystemTabComp extends SimpleComp { keyEvent.consume(); }); - var backBtn = BrowserAction.byId("back", model, List.of()).toButton(root, model, List.of()); - var forthBtn = BrowserAction.byId("forward", model, List.of()).toButton(root, model, List.of()); - var refreshBtn = BrowserAction.byId("refresh", model, List.of()).toButton(root, model, List.of()); + var backBtn = BrowserMenuProviders.byId("back", model, List.of()).toButton(root, model, List.of()); + var forthBtn = BrowserMenuProviders.byId("forward", model, List.of()).toButton(root, model, List.of()); + var refreshBtn = BrowserMenuProviders.byId("refresh", model, List.of()).toButton(root, model, List.of()); // Don't handle key events for this button, we also have that available as a menu item - var terminalBtn = BrowserAction.byId("openTerminal", model, List.of()).toButton(new Region(), model, List.of()); + var terminalBtn = + BrowserMenuProviders.byId("openTerminal", model, List.of()).toButton(new Region(), model, List.of()); var menuButton = new MenuButton(null, new FontIcon("mdral-folder_open")); new ContextMenuAugment<>( @@ -80,7 +81,20 @@ public class BrowserFileSystemTabComp extends SimpleComp { menuButton.disableProperty().bind(model.getInOverview()); menuButton.setAccessibleText("Directory options"); - var filter = new BrowserFileListFilterComp(model, model.getFilter()).createStructure(); + var smallWidth = Bindings.createBooleanBinding( + () -> { + return root.getWidth() < 450; + }, + root.widthProperty()); + + refreshBtn.managedProperty().bind(smallWidth.not()); + refreshBtn.visibleProperty().bind(refreshBtn.managedProperty()); + terminalBtn.managedProperty().bind(smallWidth.not()); + terminalBtn.visibleProperty().bind(terminalBtn.managedProperty()); + + var filter = new BrowserFileListFilterComp(model, model.getFilter()) + .hide(smallWidth) + .createStructure(); var topBar = new HBox(); topBar.setAlignment(Pos.CENTER); @@ -101,6 +115,7 @@ public class BrowserFileSystemTabComp extends SimpleComp { refreshBtn, terminalBtn, menuButton); + topBar.setMinWidth(0); if (model.getBrowserModel() instanceof BrowserFullSessionModel fullSessionModel) { var pinButton = new Button(); diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabModel.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabModel.java index b7091b32b..298c9307e 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabModel.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileSystemTabModel.java @@ -1,14 +1,17 @@ package io.xpipe.app.browser.file; +import io.xpipe.app.action.ActionProvider; import io.xpipe.app.browser.BrowserAbstractSessionModel; import io.xpipe.app.browser.BrowserFullSessionModel; import io.xpipe.app.browser.BrowserStoreSessionTab; -import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.impl.TransferFilesActionProvider; +import io.xpipe.app.browser.menu.BrowserMenuItemProvider; import io.xpipe.app.comp.Comp; import io.xpipe.app.core.window.AppMainWindow; import io.xpipe.app.ext.ProcessControlProvider; import io.xpipe.app.ext.ShellStore; -import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.ext.WrapperFileSystem; +import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.storage.DataStoreEntryRef; import io.xpipe.app.terminal.*; @@ -17,7 +20,6 @@ import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.process.*; import io.xpipe.core.store.*; import io.xpipe.core.util.FailableConsumer; -import io.xpipe.core.util.FailableRunnable; import javafx.beans.binding.Bindings; import javafx.beans.property.*; @@ -26,7 +28,6 @@ import javafx.collections.ObservableList; import lombok.Getter; import lombok.NonNull; -import lombok.SneakyThrows; import java.io.IOException; import java.nio.file.Path; @@ -47,7 +48,9 @@ public final class BrowserFileSystemTabModel extends BrowserStoreSessionTab progress = new SimpleObjectProperty<>(); private final ObservableList terminalRequests = FXCollections.observableArrayList(); private final BooleanProperty transferCancelled = new SimpleBooleanProperty(); + private FileSystem fileSystem; + private BrowserFileSystemSavedState savedState; private BrowserFileSystemCache cache; @@ -79,8 +82,7 @@ public final class BrowserFileSystemTabModel extends BrowserStoreSessionTab originalFs.getShell().get().isRunning(true)); } fs.open(); // Listen to kill after init as the shell might get killed during init for certain reasons @@ -105,8 +110,10 @@ public final class BrowserFileSystemTabModel extends BrowserStoreSessionTab { - if (fileSystem == null) { - return; - } - var current = getCurrentDirectory(); // We might close this after storage shutdown // If this entry does not exist, it's not that bad if we save it anyway @@ -134,17 +137,12 @@ public final class BrowserFileSystemTabModel extends BrowserStoreSessionTab c, boolean refresh) { ThreadHelper.runFailableAsync(() -> { - if (fileSystem == null) { - return; - } - BooleanScope.executeExclusive(busy, () -> { if (entry.getStore() instanceof ShellStore s) { c.accept(fileSystem.getShell().orElseThrow()); @@ -176,21 +166,30 @@ public final class BrowserFileSystemTabModel extends BrowserStoreSessionTab { - cdSyncWithoutCheck(currentPath.get()); - }); - } - - public void refreshSync() throws Exception { + public void refreshSync() { cdSyncWithoutCheck(currentPath.get()); } + public void refreshEntriesSync(List entries) throws Exception { + if (fileList.getAll().getValue().size() < 10) { + refreshSync(); + return; + } + + if (entries.size() > 10 && fileList.getAll().getValue().size() < 100) { + refreshSync(); + return; + } + + for (BrowserEntry browserEntry : entries) { + var refresh = fileSystem.getFileInfo(browserEntry.getRawFileEntry().getPath()); + fileList.updateEntry(browserEntry.getRawFileEntry().getPath(), refresh.orElse(null)); + } + } + public FileEntry getCurrentParentDirectory() { - var current = getCurrentDirectory(); - if (current == null) { - return null; + if (currentPath.get() == null) { + return FileEntry.ofDirectory(fileSystem, FilePath.of("?")); } var parent = currentPath.get().getParent(); @@ -202,12 +201,10 @@ public final class BrowserFileSystemTabModel extends BrowserStoreSessionTab files) { ThreadHelper.runFailableAsync(() -> { BooleanScope.executeExclusive(busy, () -> { - if (fileSystem == null) { - return; - } - startIfNeeded(); var op = BrowserFileTransferOperation.ofLocal( entry, files, BrowserFileTransferMode.COPY, true, progress::setValue, transferCancelled); - op.execute(); + var action = TransferFilesActionProvider.Action.builder() + .operation(op) + .target(this.entry.asNeeded()) + .build(); + action.executeSync(); refreshSync(); }); }); @@ -440,137 +425,21 @@ public final class BrowserFileSystemTabModel extends BrowserStoreSessionTab { BooleanScope.executeExclusive(busy, () -> { - if (fileSystem == null) { - return; - } - startIfNeeded(); var op = new BrowserFileTransferOperation( target, files, mode, true, progress::setValue, transferCancelled); - op.execute(); - refreshSync(); - }); - }); - } - - public void createDirectoryAsync(String name) { - if (name == null || name.isBlank()) { - return; - } - - if (getCurrentDirectory() == null) { - return; - } - - ThreadHelper.runFailableAsync(() -> { - BooleanScope.executeExclusive(busy, () -> { - if (fileSystem == null) { - return; - } - - startIfNeeded(); - var abs = getCurrentDirectory().getPath().join(name); - if (fileSystem.directoryExists(abs)) { - throw ErrorEvent.expected( - new IllegalStateException(String.format("Directory %s already exists", abs))); - } - - fileSystem.mkdirs(abs); - refreshSync(); - }); - }); - } - - public void createLinkAsync(String linkName, FilePath targetFile) { - if (linkName == null || linkName.isBlank() || targetFile == null) { - return; - } - - ThreadHelper.runFailableAsync(() -> { - BooleanScope.executeExclusive(busy, () -> { - if (fileSystem == null) { - return; - } - - if (getCurrentDirectory() == null) { - return; - } - - startIfNeeded(); - var abs = getCurrentDirectory().getPath().join(linkName); - fileSystem.symbolicLink(abs, targetFile); - refreshSync(); - }); - }); - } - - public void runCommandAsync(CommandBuilder command, boolean refresh) { - ThreadHelper.runFailableAsync(() -> { - BooleanScope.executeExclusive(busy, () -> { - if (fileSystem == null) { - return; - } - - if (getCurrentDirectory() == null) { - return; - } - - fileSystem - .getShell() - .orElseThrow() - .command(command) - .withWorkingDirectory(getCurrentDirectory().getPath()) - .execute(); - if (refresh) { - refreshSync(); - } - }); - }); - } - - public void runAsync(FailableRunnable r, boolean refresh) { - ThreadHelper.runFailableAsync(() -> { - BooleanScope.executeExclusive(busy, () -> { - if (fileSystem == null) { - return; - } - - if (getCurrentDirectory() == null) { - return; - } - - r.run(); - if (refresh) { - refreshSync(); - } - }); - }); - } - - public void createFileAsync(String name) { - if (name == null || name.isBlank()) { - return; - } - - ThreadHelper.runFailableAsync(() -> { - BooleanScope.executeExclusive(busy, () -> { - if (fileSystem == null) { - return; - } - - if (getCurrentDirectory() == null) { - return; - } - - var abs = getCurrentDirectory().getPath().join(name); - fileSystem.touch(abs); + var action = TransferFilesActionProvider.Action.builder() + .operation(op) + .target(entry.asNeeded()) + .build(); + action.executeSync(); refreshSync(); }); }); } public boolean isClosed() { - return fileSystem == null; + return false; } public void initWithGivenDirectory(FilePath dir) { @@ -585,30 +454,28 @@ public final class BrowserFileSystemTabModel extends BrowserStoreSessionTab { - if (fileSystem == null) { - return; - } - BooleanScope.executeExclusive(busy, () -> { - if (fileSystem.getShell().isPresent()) { - var dock = shouldLaunchSplitTerminal() && dockIfPossible; - var uuid = UUID.randomUUID(); - terminalRequests.add(uuid); - if (dock - && browserModel instanceof BrowserFullSessionModel fullSessionModel - && !(fullSessionModel.getSplits().get(this) instanceof BrowserTerminalDockTabModel)) { - fullSessionModel.splitTab( - this, new BrowserTerminalDockTabModel(browserModel, this, terminalRequests)); - } - TerminalLauncher.open(entry.get(), name, directory, processControl, uuid, !dock); - - // Restart connection as we will have to start it anyway, so we speed it up by doing it preemptively - startIfNeeded(); - } + openTerminalSync(name, directory, processControl, dockIfPossible); }); }); } + public void openTerminalSync(String name, FilePath directory, ProcessControl processControl, boolean dockIfPossible) + throws Exception { + var dock = shouldLaunchSplitTerminal() && dockIfPossible; + var uuid = UUID.randomUUID(); + terminalRequests.add(uuid); + if (dock + && browserModel instanceof BrowserFullSessionModel fullSessionModel + && !(fullSessionModel.getSplits().get(this) instanceof BrowserTerminalDockTabModel)) { + fullSessionModel.splitTab(this, new BrowserTerminalDockTabModel(browserModel, this, terminalRequests)); + } + TerminalLauncher.open(entry.get(), name, directory, processControl, uuid, !dock); + + // Restart connection as we will have to start it anyway, so we speed it up by doing it preemptively + startIfNeeded(); + } + public void backSync(int i) { var b = history.back(i); if (b != null) { diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileTransferOperation.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileTransferOperation.java index 0608dc2f5..8bf483986 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserFileTransferOperation.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserFileTransferOperation.java @@ -1,14 +1,18 @@ package io.xpipe.app.browser.file; -import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.store.*; import javafx.beans.property.BooleanProperty; +import javafx.beans.value.ChangeListener; + +import lombok.Getter; import java.io.*; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; import java.time.Instant; import java.util.LinkedHashMap; import java.util.List; @@ -20,8 +24,12 @@ import java.util.regex.Pattern; public class BrowserFileTransferOperation { + @Getter private final FileEntry target; + + @Getter private final List files; + private final BrowserFileTransferMode transferMode; private final boolean checkConflicts; private final Consumer progress; @@ -184,7 +192,7 @@ public class BrowserFileTransferOperation { } if (source.getKind() == FileKind.DIRECTORY && target.getFileSystem().directoryExists(targetFile)) { - throw ErrorEvent.expected( + throw ErrorEventFactory.expected( new IllegalArgumentException("Target directory " + targetFile + " does already exist")); } @@ -232,8 +240,8 @@ public class BrowserFileTransferOperation { } } - var noExt = target.getFileName().equals(target.getExtension()); - return FilePath.of(target.getBaseName() + " (" + 1 + ")" + (noExt ? "" : "." + target.getExtension())); + var ext = target.getExtension(); + return FilePath.of(target.getBaseName() + " (" + 1 + ")" + (ext.isPresent() ? "." + ext.get() : "")); } private void handleSingleAcrossFileSystems(FileEntry source) throws Exception { @@ -287,59 +295,90 @@ public class BrowserFileTransferOperation { totalSize.addAndGet(source.getFileSizeLong().orElse(0)); } - var start = Instant.now(); - AtomicLong transferred = new AtomicLong(); - for (var e : flatFiles.entrySet()) { - if (cancelled()) { - return; - } + var originalSourceFs = flatFiles.keySet().iterator().next().getFileSystem(); + if (!flatFiles.keySet().stream() + .allMatch(fileEntry -> fileEntry.getFileSystem().equals(originalSourceFs))) { + throw new IllegalArgumentException("Mixed source file systems"); + } - var sourceFile = e.getKey(); - var fixedRelPath = FilePath.of(e.getValue()) - .fileSystemCompatible( - target.getFileSystem().getShell().orElseThrow().getOsType()); - var targetFile = target.getPath().join(fixedRelPath.toString()); - if (sourceFile.getFileSystem().equals(target.getFileSystem())) { - throw new IllegalStateException(); - } + var optimizedSourceFs = originalSourceFs.createTransferOptimizedFileSystem(); + var targetFs = target.getFileSystem().createTransferOptimizedFileSystem(); - if (sourceFile.getKind() == FileKind.DIRECTORY) { - target.getFileSystem().mkdirs(targetFile); - } else if (sourceFile.getKind() == FileKind.FILE) { - if (checkConflicts) { - var fileConflictChoice = - handleChoice(target.getFileSystem(), targetFile, files.size() > 1 || flatFiles.size() > 1); - if (fileConflictChoice == BrowserAlerts.FileConflictChoice.SKIP - || fileConflictChoice == BrowserAlerts.FileConflictChoice.CANCEL) { - continue; - } - - if (fileConflictChoice == BrowserAlerts.FileConflictChoice.RENAME) { - targetFile = renameFileLoop(target.getFileSystem(), targetFile, false); - } + try { + var start = Instant.now(); + AtomicLong transferred = new AtomicLong(); + for (var e : flatFiles.entrySet()) { + if (cancelled()) { + return; } - transfer(sourceFile, targetFile, transferred, totalSize, start); + var sourceFile = e.getKey(); + var fixedRelPath = FilePath.of(e.getValue()) + .fileSystemCompatible(targetFs.getShell().orElseThrow().getOsType()); + var targetFile = target.getPath().join(fixedRelPath.toString()); + if (sourceFile.getFileSystem().equals(targetFs)) { + throw new IllegalStateException(); + } + + if (sourceFile.getKind() == FileKind.DIRECTORY) { + targetFs.mkdirs(targetFile); + } else if (sourceFile.getKind() == FileKind.FILE) { + if (checkConflicts) { + var fileConflictChoice = + handleChoice(targetFs, targetFile, files.size() > 1 || flatFiles.size() > 1); + if (fileConflictChoice == BrowserAlerts.FileConflictChoice.SKIP + || fileConflictChoice == BrowserAlerts.FileConflictChoice.CANCEL) { + continue; + } + + if (fileConflictChoice == BrowserAlerts.FileConflictChoice.RENAME) { + targetFile = renameFileLoop(targetFs, targetFile, false); + } + } + + transfer( + sourceFile.getPath(), + optimizedSourceFs, + targetFile, + targetFs, + transferred, + totalSize, + start); + } + } + updateProgress(BrowserTransferProgress.finished(source.getName(), totalSize.get())); + } finally { + if (optimizedSourceFs != originalSourceFs) { + optimizedSourceFs.close(); + } + if (target.getFileSystem() != targetFs) { + targetFs.close(); } } - updateProgress(BrowserTransferProgress.finished(source.getName(), totalSize.get())); } private void transfer( - FileEntry sourceFile, FilePath targetFile, AtomicLong transferred, AtomicLong totalSize, Instant start) + FilePath sourceFile, + FileSystem sourceFs, + FilePath targetFile, + FileSystem targetFs, + AtomicLong transferred, + AtomicLong totalSize, + Instant start) throws Exception { if (cancelled()) { return; } + var fileSize = sourceFs.getFileSize(sourceFile); + InputStream inputStream = null; OutputStream outputStream = null; try { - var fileSize = sourceFile.getFileSystem().getFileSize(sourceFile.getPath()); // Read the first few bytes to figure out possible command failure early // before creating the output stream - inputStream = new BufferedInputStream(sourceFile.getFileSystem().openInput(sourceFile.getPath()), 1024); + inputStream = new BufferedInputStream(sourceFs.openInput(sourceFile), 1024); inputStream.mark(1024); var streamStart = new byte[1024]; var streamStartLength = inputStream.read(streamStart, 0, 1024); @@ -350,13 +389,11 @@ public class BrowserFileTransferOperation { inputStream.reset(); } - outputStream = target.getFileSystem().openOutput(targetFile, fileSize); + outputStream = targetFs.openOutput(targetFile, fileSize); transferFile(sourceFile, inputStream, outputStream, transferred, totalSize, start, fileSize); - outputStream.flush(); - inputStream.transferTo(OutputStream.nullOutputStream()); } catch (Exception ex) { // Mark progress as finished to reset any progress display - updateProgress(BrowserTransferProgress.finished(sourceFile.getName(), transferred.get())); + updateProgress(BrowserTransferProgress.finished(sourceFile.getFileName(), transferred.get())); if (inputStream != null) { try { @@ -364,7 +401,7 @@ public class BrowserFileTransferOperation { } catch (Exception om) { // This is expected as the process control has to be killed // When calling close, it will throw an exception when it has to kill - // ErrorEvent.fromThrowable(om).handle(); + ErrorEventFactory.fromThrowable(om).expected().omit().handle(); } } if (outputStream != null) { @@ -373,12 +410,24 @@ public class BrowserFileTransferOperation { } catch (Exception om) { // This is expected as the process control has to be killed // When calling close, it will throw an exception when it has to kill - // ErrorEvent.fromThrowable(om).handle(); + ErrorEventFactory.fromThrowable(om).expected().omit().handle(); } } throw ex; } + // If we receive a cancel while we are closing, there's a good chance that the close is stuck + // Then, we just straight up kill the shells + ChangeListener closeCancelListener = (observableValue, oldValue, newValue) -> { + if (!newValue) { + return; + } + + sourceFs.getShell().orElseThrow().killExternal(); + targetFs.getShell().orElseThrow().killExternal(); + }; + cancelled.addListener(closeCancelListener); + Exception exception = null; try { inputStream.close(); @@ -389,12 +438,18 @@ public class BrowserFileTransferOperation { outputStream.close(); } catch (Exception om) { if (exception != null) { - ErrorEvent.fromThrowable(om).handle(); + exception.addSuppressed(om); } else { exception = om; } } + + cancelled.removeListener(closeCancelListener); + if (exception != null) { + ErrorEventFactory.preconfigure(ErrorEventFactory.fromThrowable(exception) + .reportable(!cancelled()) + .omitted(cancelled())); throw exception; } } @@ -406,7 +461,7 @@ public class BrowserFileTransferOperation { private static final int DEFAULT_BUFFER_SIZE = 1024; private void transferFile( - FileEntry sourceFile, + FilePath sourceFile, InputStream inputStream, OutputStream outputStream, AtomicLong transferred, @@ -415,13 +470,13 @@ public class BrowserFileTransferOperation { long expectedFileSize) throws Exception { // Initialize progress immediately prior to reading anything - updateProgress(new BrowserTransferProgress(sourceFile.getName(), transferred.get(), total.get(), start)); + updateProgress(new BrowserTransferProgress(sourceFile.getFileName(), transferred.get(), total.get(), start)); var killStreams = new AtomicBoolean(false); var exception = new AtomicReference(); + var readCount = new AtomicLong(); var thread = ThreadHelper.createPlatformThread("transfer", true, () -> { try { - long readCount = 0; var bs = (int) Math.min(DEFAULT_BUFFER_SIZE, expectedFileSize); byte[] buffer = new byte[bs]; int read; @@ -438,14 +493,17 @@ public class BrowserFileTransferOperation { outputStream.write(buffer, 0, read); transferred.addAndGet(read); - readCount += read; - updateProgress( - new BrowserTransferProgress(sourceFile.getName(), transferred.get(), total.get(), start)); + readCount.addAndGet(read); + updateProgress(new BrowserTransferProgress( + sourceFile.getFileName(), transferred.get(), total.get(), start)); } - var incomplete = readCount < expectedFileSize; + outputStream.flush(); + inputStream.transferTo(OutputStream.nullOutputStream()); + + var incomplete = readCount.get() < expectedFileSize; if (incomplete) { - throw new IOException("Source file " + sourceFile.getPath() + " input did end prematurely"); + throw new IOException("Source file " + sourceFile + " input did end prematurely"); } } catch (Exception ex) { exception.set(ex); @@ -459,9 +517,7 @@ public class BrowserFileTransferOperation { var cancelled = cancelled(); if (cancelled) { - // Assume that the transfer has stalled if it doesn't finish until then - thread.join(1000); - killStreams(); + killStreams(thread, readCount, false); break; } @@ -471,7 +527,7 @@ public class BrowserFileTransferOperation { } if (killStreams.get()) { - killStreams(); + killStreams(thread, readCount, true); } var ex = exception.get(); @@ -491,17 +547,33 @@ public class BrowserFileTransferOperation { var sourceShell = sourceFs.getShell().orElseThrow(); var targetShell = targetFs.getShell().orElseThrow(); // Check for null on shell reset - return sourceShell.getStdout() != null && !sourceShell.getStdout().isClosed() - && targetShell.getStdin() != null && !targetShell.getStdin().isClosed(); + return sourceShell.getStdout() != null + && !sourceShell.getStdout().isClosed() + && targetShell.getStdin() != null + && !targetShell.getStdin().isClosed(); } else { return true; } } - private void killStreams() throws Exception { + private void killStreams(Thread thread, AtomicLong transferred, boolean instant) throws Exception { var sourceFs = files.getFirst().getFileSystem(); var targetFs = target.getFileSystem(); var same = files.getFirst().getFileSystem().equals(target.getFileSystem()); + + if (!instant && !same && checkTransferValidity()) { + var initialTransferred = transferred.get(); + if (!thread.join(Duration.ofMillis(1000))) { + var nowTransferred = transferred.get(); + var stuck = initialTransferred == nowTransferred; + if (stuck) { + sourceFs.getShell().orElseThrow().killExternal(); + targetFs.getShell().orElseThrow().killExternal(); + return; + } + } + } + if (!same) { var sourceShell = sourceFs.getShell().orElseThrow(); var targetShell = targetFs.getShell().orElseThrow(); diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserHistorySavedStateImpl.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserHistorySavedStateImpl.java index f21f54578..84a92ebba 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserHistorySavedStateImpl.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserHistorySavedStateImpl.java @@ -82,7 +82,9 @@ public class BrowserHistorySavedStateImpl implements BrowserHistorySavedState { if (ls == null) { ls = List.of(); } - var valid = ls.stream().filter(entry -> entry.getUuid() != null && entry.getPath() != null).toList(); + var valid = ls.stream() + .filter(entry -> entry.getUuid() != null && entry.getPath() != null) + .toList(); return new BrowserHistorySavedStateImpl(valid); } } diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserOverviewComp.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserOverviewComp.java index 93b69839e..43747a824 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserOverviewComp.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserOverviewComp.java @@ -4,7 +4,7 @@ import io.xpipe.app.comp.SimpleComp; import io.xpipe.app.comp.base.SimpleTitledPaneComp; import io.xpipe.app.comp.base.VerticalComp; import io.xpipe.app.core.AppI18n; -import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.util.DerivedObservableList; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.process.ShellControl; @@ -32,9 +32,6 @@ public class BrowserOverviewComp extends SimpleComp { @SneakyThrows protected Region createSimple() { // The open file system might have already been closed - if (model.getFileSystem() == null) { - return new Region(); - } ShellControl sc = model.getFileSystem().getShell().orElseThrow(); @@ -44,14 +41,11 @@ public class BrowserOverviewComp extends SimpleComp { .map(s -> FileEntry.ofDirectory(model.getFileSystem(), s)) .filter(entry -> { var fs = model.getFileSystem(); - if (fs == null) { - return false; - } try { return fs.directoryExists(entry.getPath()); } catch (Exception e) { - ErrorEvent.fromThrowable(e).handle(); + ErrorEventFactory.fromThrowable(e).handle(); return false; } }) diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserStatusBarComp.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserStatusBarComp.java index 8db966180..925f21387 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserStatusBarComp.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserStatusBarComp.java @@ -64,8 +64,7 @@ public class BrowserStatusBarComp extends SimpleComp { var cancel = PlatformThread.sync(model.getTransferCancelled()); var hide = Bindings.createBooleanBinding( () -> { - if (model.getProgress().getValue() == null - || model.getProgress().getValue().done()) { + if (model.getProgress().getValue() == null) { return true; } diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserTransferComp.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserTransferComp.java index adbdd224e..9bf89e2e6 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserTransferComp.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserTransferComp.java @@ -71,7 +71,7 @@ public class BrowserTransferComp extends SimpleComp { var hideProgress = sourceItem.get().downloadFinished().get(); - var share = p != null ? (p.getTransferred() * 100 / p.getTotal()) : 0; + var share = p.getTransferred() * 100 / p.getTotal(); var progressSuffix = hideProgress ? "" : " " + share + "%"; return entry.getFileName() + progressSuffix; }, diff --git a/app/src/main/java/io/xpipe/app/browser/file/BrowserTransferModel.java b/app/src/main/java/io/xpipe/app/browser/file/BrowserTransferModel.java index 138f76b16..cecebffc0 100644 --- a/app/src/main/java/io/xpipe/app/browser/file/BrowserTransferModel.java +++ b/app/src/main/java/io/xpipe/app/browser/file/BrowserTransferModel.java @@ -1,13 +1,15 @@ package io.xpipe.app.browser.file; import io.xpipe.app.browser.BrowserFullSessionModel; -import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.browser.action.impl.TransferFilesActionProvider; +import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.app.storage.DataStorage; import io.xpipe.app.util.DesktopHelper; import io.xpipe.app.util.ShellTemp; import io.xpipe.app.util.ThreadHelper; - import io.xpipe.core.process.OsType; + import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.Property; @@ -77,7 +79,7 @@ public class BrowserTransferModel { try { FileUtils.forceDelete(item.getLocalFile().toFile()); } catch (IOException e) { - ErrorEvent.fromThrowable(e).handle(); + ErrorEventFactory.fromThrowable(e).handle(); } } @@ -118,7 +120,7 @@ public class BrowserTransferModel { try { FileUtils.forceMkdir(TEMP.toFile()); } catch (IOException e) { - ErrorEvent.fromThrowable(e).handle(); + ErrorEventFactory.fromThrowable(e).handle(); return; } @@ -151,9 +153,14 @@ public class BrowserTransferModel { itemModel.getProgress().setValue(progress); }, itemModel.getTransferCancelled()); - op.execute(); + var action = TransferFilesActionProvider.Action.builder() + .operation(op) + .target(DataStorage.get().local().ref()) + .download(true) + .build(); + action.executeSync(); } catch (Throwable t) { - ErrorEvent.fromThrowable(t).handle(); + ErrorEventFactory.fromThrowable(t).handle(); synchronized (items) { items.remove(item); } diff --git a/app/src/main/java/io/xpipe/app/browser/icon/BrowserIconDirectoryType.java b/app/src/main/java/io/xpipe/app/browser/icon/BrowserIconDirectoryType.java index 7041fa414..faaa8552b 100644 --- a/app/src/main/java/io/xpipe/app/browser/icon/BrowserIconDirectoryType.java +++ b/app/src/main/java/io/xpipe/app/browser/icon/BrowserIconDirectoryType.java @@ -1,6 +1,6 @@ package io.xpipe.app.browser.icon; -import io.xpipe.app.resources.AppResources; +import io.xpipe.app.core.AppResources; import io.xpipe.core.store.FileEntry; import io.xpipe.core.store.FileKind; diff --git a/app/src/main/java/io/xpipe/app/browser/icon/BrowserIconFileType.java b/app/src/main/java/io/xpipe/app/browser/icon/BrowserIconFileType.java index 9bf645f4b..83b6cf778 100644 --- a/app/src/main/java/io/xpipe/app/browser/icon/BrowserIconFileType.java +++ b/app/src/main/java/io/xpipe/app/browser/icon/BrowserIconFileType.java @@ -1,6 +1,6 @@ package io.xpipe.app.browser.icon; -import io.xpipe.app.resources.AppResources; +import io.xpipe.app.core.AppResources; import io.xpipe.core.store.FileEntry; import io.xpipe.core.store.FileKind; @@ -85,7 +85,8 @@ public abstract class BrowserIconFileType { var name = entry.getPath().getFileName(); var ext = entry.getPath().getExtension(); - return (ext != null && endings.contains("." + ext.toLowerCase(Locale.ROOT))) || endings.contains(name); + return (ext.isPresent() && endings.contains("." + ext.get().toLowerCase(Locale.ROOT))) + || endings.contains(name); } @Override diff --git a/app/src/main/java/io/xpipe/app/browser/icon/BrowserIcons.java b/app/src/main/java/io/xpipe/app/browser/icon/BrowserIcons.java index 1d771981d..db265bd81 100644 --- a/app/src/main/java/io/xpipe/app/browser/icon/BrowserIcons.java +++ b/app/src/main/java/io/xpipe/app/browser/icon/BrowserIcons.java @@ -14,8 +14,8 @@ public class BrowserIcons { return PrettyImageHelper.ofFixedSizeSquare("browser/default_folder.svg", 24); } - public static Comp createIcon(BrowserIconFileType type) { - return PrettyImageHelper.ofFixedSizeSquare(type.getIcon(), 24); + public static Comp createContextMenuIcon(BrowserIconFileType type) { + return PrettyImageHelper.ofFixedSizeSquare(type.getIcon(), 16); } public static Comp createIcon(FileEntry entry) { diff --git a/app/src/main/java/io/xpipe/app/browser/action/BrowserApplicationPathAction.java b/app/src/main/java/io/xpipe/app/browser/menu/BrowserApplicationPathMenuProvider.java similarity index 81% rename from app/src/main/java/io/xpipe/app/browser/action/BrowserApplicationPathAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/BrowserApplicationPathMenuProvider.java index 5a5c9b572..7d53c6076 100644 --- a/app/src/main/java/io/xpipe/app/browser/action/BrowserApplicationPathAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/BrowserApplicationPathMenuProvider.java @@ -1,11 +1,11 @@ -package io.xpipe.app.browser.action; +package io.xpipe.app.browser.menu; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import java.util.List; -public interface BrowserApplicationPathAction extends BrowserAction { +public interface BrowserApplicationPathMenuProvider extends BrowserMenuItemProvider { String getExecutable(); diff --git a/app/src/main/java/io/xpipe/app/browser/action/BrowserBranchAction.java b/app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuBranchProvider.java similarity index 59% rename from app/src/main/java/io/xpipe/app/browser/action/BrowserBranchAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuBranchProvider.java index cf2900cd6..e556c7ed4 100644 --- a/app/src/main/java/io/xpipe/app/browser/action/BrowserBranchAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuBranchProvider.java @@ -1,4 +1,4 @@ -package io.xpipe.app.browser.action; +package io.xpipe.app.browser.menu; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; @@ -11,7 +11,7 @@ import org.kordamp.ikonli.javafx.FontIcon; import java.util.List; -public interface BrowserBranchAction extends BrowserAction { +public interface BrowserMenuBranchProvider extends BrowserMenuItemProvider { default MenuItem toMenuItem(BrowserFileSystemTabModel model, List selected) { var m = new Menu(getName(model, selected).getValue() + " ..."); @@ -20,16 +20,24 @@ public interface BrowserBranchAction extends BrowserAction { if (!sub.isApplicable(model, subselected)) { continue; } - m.getItems().add(sub.toMenuItem(model, subselected)); + var item = sub.toMenuItem(model, subselected); + if (item != null) { + m.getItems().add(item); + } } + + if (m.getItems().isEmpty()) { + return null; + } + var graphic = getIcon(model, selected); if (graphic != null) { - m.setGraphic(graphic); + m.setGraphic(graphic.createGraphicNode()); } m.setDisable(!isActive(model, selected)); - if (getProFeatureId() != null - && !LicenseProvider.get().getFeature(getProFeatureId()).isSupported()) { + if (getLicensedFeatureId() != null + && !LicenseProvider.get().getFeature(getLicensedFeatureId()).isSupported()) { m.setDisable(true); m.setGraphic(new FontIcon("mdi2p-professional-hexagon")); } @@ -37,5 +45,6 @@ public interface BrowserBranchAction extends BrowserAction { return m; } - List getBranchingActions(BrowserFileSystemTabModel model, List entries); + List getBranchingActions( + BrowserFileSystemTabModel model, List entries); } diff --git a/app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuCategory.java b/app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuCategory.java new file mode 100644 index 000000000..f9ec0601c --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuCategory.java @@ -0,0 +1,9 @@ +package io.xpipe.app.browser.menu; + +public enum BrowserMenuCategory { + CUSTOM, + OPEN, + COPY_PASTE, + ACTION, + MUTATION +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuItemProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuItemProvider.java new file mode 100644 index 000000000..50c2e7a8f --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuItemProvider.java @@ -0,0 +1,58 @@ +package io.xpipe.app.browser.menu; + +import io.xpipe.app.action.ActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.util.LabelGraphic; + +import javafx.beans.value.ObservableValue; +import javafx.scene.control.MenuItem; +import javafx.scene.input.KeyCombination; + +import java.util.List; + +public interface BrowserMenuItemProvider extends ActionProvider { + + MenuItem toMenuItem(BrowserFileSystemTabModel model, List selected); + + default void init(BrowserFileSystemTabModel model) throws Exception {} + + default boolean automaticallyResolveLinks() { + return true; + } + + default List resolveFilesIfNeeded(List selected) { + return automaticallyResolveLinks() + ? selected.stream() + .map(browserEntry -> + new BrowserEntry(browserEntry.getRawFileEntry().resolved(), browserEntry.getModel())) + .toList() + : selected; + } + + default LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return null; + } + + default BrowserMenuCategory getCategory() { + return null; + } + + default KeyCombination getShortcut() { + return null; + } + + ObservableValue getName(BrowserFileSystemTabModel model, List entries); + + default boolean acceptsEmptySelection() { + return false; + } + + default boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + return true; + } + + default boolean isActive(BrowserFileSystemTabModel model, List entries) { + return true; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuLeafProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuLeafProvider.java new file mode 100644 index 000000000..1c68e6471 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuLeafProvider.java @@ -0,0 +1,156 @@ +package io.xpipe.app.browser.menu; + +import io.xpipe.app.action.AbstractAction; +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.action.BrowserActionProviders; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.comp.base.TooltipHelper; +import io.xpipe.app.hub.action.StoreAction; +import io.xpipe.app.storage.DataStoreEntryRef; +import io.xpipe.app.util.BindingsHelper; +import io.xpipe.app.util.LicenseProvider; + +import javafx.scene.control.Button; +import javafx.scene.control.MenuItem; +import javafx.scene.control.Tooltip; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.Region; + +import lombok.SneakyThrows; +import org.kordamp.ikonli.javafx.FontIcon; + +import java.util.List; + +public interface BrowserMenuLeafProvider extends BrowserMenuItemProvider { + + default void execute(BrowserFileSystemTabModel model, List entries) throws Exception { + createAction(model, entries).executeAsync(); + } + + default Class getDelegateActionProvider() { + return null; + } + + @Override + default boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + if (getDelegateActionProvider() != null) { + var provider = BrowserActionProviders.forClass(getDelegateActionProvider()); + return provider.isApplicable(model, entries); + } else { + return true; + } + } + + @SneakyThrows + default AbstractAction createAction(BrowserFileSystemTabModel model, List entries) { + var c = getDelegateActionProvider() != null + ? BrowserActionProviders.forClass(getDelegateActionProvider()) + .getActionClass() + .orElseThrow() + : getActionClass().orElseThrow(); + var bm = c.getDeclaredMethod("builder"); + bm.setAccessible(true); + var b = bm.invoke(null); + + if (StoreAction.class.isAssignableFrom(c)) { + var refMethod = b.getClass().getMethod("ref", DataStoreEntryRef.class); + refMethod.setAccessible(true); + refMethod.invoke(b, model.getEntry()); + } + + if (BrowserAction.class.isAssignableFrom(c)) { + var modelMethod = b.getClass().getMethod("model", BrowserFileSystemTabModel.class); + modelMethod.setAccessible(true); + modelMethod.invoke(b, model); + + var entriesMethod = b.getClass().getMethod("files", List.class); + entriesMethod.setAccessible(true); + entriesMethod.invoke( + b, + entries.stream() + .map(browserEntry -> browserEntry.getRawFileEntry().getPath()) + .toList()); + } + + var m = b.getClass().getDeclaredMethod("build"); + m.setAccessible(true); + var defValue = c.cast(m.invoke(b)); + return (AbstractAction) defValue; + } + + default Button toButton(Region root, BrowserFileSystemTabModel model, List selected) { + var b = new Button(); + b.setOnAction(event -> { + try { + execute(model, selected); + } catch (Exception e) { + throw new RuntimeException(e); + } + event.consume(); + }); + var name = getName(model, selected); + Tooltip.install(b, TooltipHelper.create(name, getShortcut())); + var graphic = getIcon(model, selected); + if (graphic != null) { + b.setGraphic(graphic.createGraphicNode()); + } + b.setMnemonicParsing(false); + b.accessibleTextProperty().bind(name); + root.addEventFilter(KeyEvent.KEY_PRESSED, event -> { + if (getShortcut() != null && getShortcut().match(event)) { + b.fire(); + event.consume(); + } + }); + + b.setDisable(!isActive(model, selected)); + model.getCurrentPath().addListener((observable, oldValue, newValue) -> { + b.setDisable(!isActive(model, selected)); + }); + + if (getLicensedFeatureId() != null + && !LicenseProvider.get().getFeature(getLicensedFeatureId()).isSupported()) { + b.setDisable(true); + b.setGraphic(new FontIcon("mdi2p-professional-hexagon")); + } + + return b; + } + + default MenuItem toMenuItem(BrowserFileSystemTabModel model, List selected) { + var name = getName(model, selected); + var mi = new MenuItem(); + mi.textProperty().bind(BindingsHelper.map(name, s -> { + if (getLicensedFeatureId() != null) { + return LicenseProvider.get().getFeature(getLicensedFeatureId()).suffix(s); + } + return s; + })); + mi.setOnAction(event -> { + try { + execute(model, selected); + } catch (Exception e) { + throw new RuntimeException(e); + } + event.consume(); + }); + if (getShortcut() != null) { + mi.setAccelerator(getShortcut()); + } + var graphic = getIcon(model, selected); + if (graphic != null) { + mi.setGraphic(graphic.createGraphicNode()); + } + mi.setMnemonicParsing(false); + mi.setDisable(!isActive(model, selected)); + + if (getLicensedFeatureId() != null + && !LicenseProvider.get().getFeature(getLicensedFeatureId()).isSupported()) { + mi.setDisable(true); + } + + return mi; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuProviders.java b/app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuProviders.java new file mode 100644 index 000000000..c97fa544c --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/BrowserMenuProviders.java @@ -0,0 +1,38 @@ +package io.xpipe.app.browser.menu; + +import io.xpipe.app.action.ActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; + +import java.util.List; + +public class BrowserMenuProviders { + + public static List getFlattened( + BrowserFileSystemTabModel model, List entries) { + return ActionProvider.ALL.stream() + .map(browserAction -> browserAction instanceof BrowserMenuItemProvider ba + ? getFlattened(ba, model, entries) + : List.of()) + .flatMap(List::stream) + .toList(); + } + + public static List getFlattened( + BrowserMenuItemProvider browserAction, BrowserFileSystemTabModel model, List entries) { + return browserAction instanceof BrowserMenuLeafProvider + ? List.of((BrowserMenuLeafProvider) browserAction) + : ((BrowserMenuBranchProvider) browserAction) + .getBranchingActions(model, entries).stream() + .map(action -> getFlattened(action, model, entries)) + .flatMap(List::stream) + .toList(); + } + + public static BrowserMenuLeafProvider byId(String id, BrowserFileSystemTabModel model, List entries) { + return getFlattened(model, entries).stream() + .filter(browserAction -> id.equals(browserAction.getId())) + .findAny() + .orElseThrow(); + } +} diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/FileTypeAction.java b/app/src/main/java/io/xpipe/app/browser/menu/FileTypeMenuProvider.java similarity index 61% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/FileTypeAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/FileTypeMenuProvider.java index 8771bfb1e..489df88b1 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/FileTypeAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/FileTypeMenuProvider.java @@ -1,20 +1,18 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu; -import io.xpipe.app.browser.action.BrowserAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.icon.BrowserIconFileType; import io.xpipe.app.browser.icon.BrowserIcons; - -import javafx.scene.Node; +import io.xpipe.app.util.LabelGraphic; import java.util.List; -public interface FileTypeAction extends BrowserAction { +public interface FileTypeMenuProvider extends BrowserMenuItemProvider { @Override - default Node getIcon(BrowserFileSystemTabModel model, List entries) { - return BrowserIcons.createIcon(getType()).createRegion(); + default LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.CompGraphic(BrowserIcons.createContextMenuIcon(getType())); } @Override diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/MultiExecuteAction.java b/app/src/main/java/io/xpipe/app/browser/menu/MultiExecuteMenuProvider.java similarity index 91% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/MultiExecuteAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/MultiExecuteMenuProvider.java index 8f11edf85..557164b44 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/MultiExecuteAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/MultiExecuteMenuProvider.java @@ -1,7 +1,5 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu; -import io.xpipe.app.browser.action.BrowserBranchAction; -import io.xpipe.app.browser.action.BrowserLeafAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.core.AppI18n; @@ -14,15 +12,16 @@ import javafx.beans.value.ObservableValue; import java.util.List; -public abstract class MultiExecuteAction implements BrowserBranchAction { +public abstract class MultiExecuteMenuProvider implements BrowserMenuBranchProvider { protected abstract CommandBuilder createCommand( ShellControl sc, BrowserFileSystemTabModel model, BrowserEntry entry); @Override - public List getBranchingActions(BrowserFileSystemTabModel model, List entries) { + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { return List.of( - new BrowserLeafAction() { + new BrowserMenuLeafProvider() { @Override public void execute(BrowserFileSystemTabModel model, List entries) { @@ -62,7 +61,7 @@ public abstract class MultiExecuteAction implements BrowserBranchAction { return AppPrefs.get().terminalType().getValue() != null; } }, - new BrowserLeafAction() { + new BrowserMenuLeafProvider() { @Override public void execute(BrowserFileSystemTabModel model, List entries) { @@ -87,7 +86,7 @@ public abstract class MultiExecuteAction implements BrowserBranchAction { return AppI18n.observable("runInFileBrowser"); } }, - new BrowserLeafAction() { + new BrowserMenuLeafProvider() { @Override public void execute(BrowserFileSystemTabModel model, List entries) { diff --git a/app/src/main/java/io/xpipe/app/browser/menu/MultiExecuteSelectionMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/MultiExecuteSelectionMenuProvider.java new file mode 100644 index 000000000..ad2008e01 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/MultiExecuteSelectionMenuProvider.java @@ -0,0 +1,83 @@ +package io.xpipe.app.browser.menu; + +import io.xpipe.app.browser.action.impl.RunCommandInBackgroundActionProvider; +import io.xpipe.app.browser.action.impl.RunCommandInBrowserActionProvider; +import io.xpipe.app.browser.action.impl.RunCommandInTerminalActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.prefs.AppPrefs; + +import javafx.beans.value.ObservableValue; + +import java.util.List; + +public abstract class MultiExecuteSelectionMenuProvider implements BrowserMenuBranchProvider { + + protected abstract String createCommand(BrowserFileSystemTabModel model); + + protected abstract String getTerminalTitle(); + + @Override + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { + return List.of( + new BrowserMenuLeafProvider() { + + @Override + public void execute(BrowserFileSystemTabModel model, List entries) { + var builder = RunCommandInTerminalActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.title(getTerminalTitle()); + builder.command(createCommand(model)); + builder.build().executeAsync(); + } + + @Override + public ObservableValue getName( + BrowserFileSystemTabModel model, List entries) { + var t = AppPrefs.get().terminalType().getValue(); + return AppI18n.observable( + "executeInTerminal", + t != null ? t.toTranslatedString().getValue() : "?"); + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + return AppPrefs.get().terminalType().getValue() != null; + } + }, + new BrowserMenuLeafProvider() { + + @Override + public void execute(BrowserFileSystemTabModel model, List entries) { + var builder = RunCommandInBrowserActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.command(createCommand(model)); + builder.build().executeAsync(); + } + + @Override + public ObservableValue getName( + BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("runInFileBrowser"); + } + }, + new BrowserMenuLeafProvider() { + + @Override + public void execute(BrowserFileSystemTabModel model, List entries) { + var builder = RunCommandInBackgroundActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.command(createCommand(model)); + builder.build().executeAsync(); + } + + @Override + public ObservableValue getName( + BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("runSilent"); + } + }); + } +} diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/BackAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/BackMenuProvider.java similarity index 76% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/BackAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/BackMenuProvider.java index d8bbd398b..1ddf5f3a0 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/BackAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/BackMenuProvider.java @@ -1,21 +1,19 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; -import io.xpipe.app.browser.action.BrowserLeafAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; -import org.kordamp.ikonli.javafx.FontIcon; - import java.util.List; -public class BackAction implements BrowserLeafAction { +public class BackMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { @@ -27,8 +25,8 @@ public class BackAction implements BrowserLeafAction { } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return new FontIcon("fth-arrow-left"); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("fth-arrow-left"); } @Override diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/BrowseInNativeManagerMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/BrowseInNativeManagerMenuProvider.java new file mode 100644 index 000000000..3287cd36d --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/BrowseInNativeManagerMenuProvider.java @@ -0,0 +1,41 @@ +package io.xpipe.app.browser.menu.impl; + +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.action.impl.BrowseInNativeManagerActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; +import io.xpipe.app.core.AppI18n; +import io.xpipe.core.process.OsType; + +import javafx.beans.value.ObservableValue; + +import java.util.List; + +public class BrowseInNativeManagerMenuProvider implements BrowserMenuLeafProvider { + + @Override + public Class getDelegateActionProvider() { + return BrowseInNativeManagerActionProvider.class; + } + + @Override + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.OPEN; + } + + @Override + public boolean acceptsEmptySelection() { + return true; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return switch (OsType.getLocal()) { + case OsType.Windows windows -> AppI18n.observable("browseInWindowsExplorer"); + case OsType.Linux linux -> AppI18n.observable("browseInDefaultFileManager"); + case OsType.MacOs macOs -> AppI18n.observable("browseInFinder"); + }; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/ChgrpMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/ChgrpMenuProvider.java new file mode 100644 index 000000000..e4e52b4a4 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/ChgrpMenuProvider.java @@ -0,0 +1,175 @@ +package io.xpipe.app.browser.menu.impl; + +import io.xpipe.app.browser.action.impl.ChgrpActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuBranchProvider; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuItemProvider; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; +import io.xpipe.app.comp.Comp; +import io.xpipe.app.comp.base.ModalOverlay; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; +import io.xpipe.core.process.OsType; +import io.xpipe.core.store.FileKind; + +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.value.ObservableValue; +import javafx.scene.control.TextField; + +import java.util.List; +import java.util.stream.Stream; + +public class ChgrpMenuProvider implements BrowserMenuBranchProvider { + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2a-account-group-outline"); + } + + @Override + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.MUTATION; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("chgrp"); + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + var os = model.getFileSystem().getShell().orElseThrow().getOsType(); + return os != OsType.WINDOWS && os != OsType.MACOS; + } + + @Override + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { + if (entries.stream() + .anyMatch(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.DIRECTORY)) { + return List.of(new FlatProvider(), new RecursiveProvider()); + } else { + return getLeafActions(model, false); + } + } + + private static class FlatProvider implements BrowserMenuBranchProvider { + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2f-file-outline"); + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("flat"); + } + + @Override + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { + return getLeafActions(model, false); + } + } + + private static class RecursiveProvider implements BrowserMenuBranchProvider { + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2f-file-tree"); + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("recursive"); + } + + @Override + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { + return getLeafActions(model, true); + } + } + + private static List getLeafActions(BrowserFileSystemTabModel model, boolean recursive) { + List actions = Stream.concat( + model.getCache().getGroups().entrySet().stream() + .filter(e -> !e.getValue().equals("nohome") + && !e.getValue().equals("nogroup") + && !e.getValue().equals("nobody") + && (e.getKey().equals(0) || e.getKey() >= 900)) + .map(e -> e.getValue()) + .map(s -> (BrowserMenuLeafProvider) new FixedProvider(s, recursive)), + Stream.of(new CustomProvider(recursive))) + .toList(); + return actions; + } + + private static class FixedProvider implements BrowserMenuLeafProvider { + + private final String group; + private final boolean recursive; + + private FixedProvider(String group, boolean recursive) { + this.group = group; + this.recursive = recursive; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return new SimpleStringProperty(group); + } + + @Override + public void execute(BrowserFileSystemTabModel model, List entries) { + var builder = ChgrpActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.group(group); + builder.recursive(recursive); + var action = builder.build(); + action.executeAsync(); + } + } + + private static class CustomProvider implements BrowserMenuLeafProvider { + + private final boolean recursive; + + private CustomProvider(boolean recursive) { + this.recursive = recursive; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("custom"); + } + + @Override + public void execute(BrowserFileSystemTabModel model, List entries) { + var group = new SimpleStringProperty(); + var modal = ModalOverlay.of( + "groupName", + Comp.of(() -> { + var creationName = new TextField(); + creationName.textProperty().bindBidirectional(group); + return creationName; + }) + .prefWidth(350)); + modal.withDefaultButtons(() -> { + if (group.getValue() == null) { + return; + } + + var builder = ChgrpActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.group(group.getValue()); + builder.recursive(recursive); + var action = builder.build(); + action.executeAsync(); + }); + modal.show(); + } + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/ChmodMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/ChmodMenuProvider.java new file mode 100644 index 000000000..5964295f9 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/ChmodMenuProvider.java @@ -0,0 +1,173 @@ +package io.xpipe.app.browser.menu.impl; + +import io.xpipe.app.browser.action.impl.ChmodActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuBranchProvider; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuItemProvider; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; +import io.xpipe.app.comp.Comp; +import io.xpipe.app.comp.base.ModalOverlay; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; +import io.xpipe.core.process.OsType; +import io.xpipe.core.store.FileKind; + +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.value.ObservableValue; +import javafx.scene.control.TextField; + +import java.util.List; + +public class ChmodMenuProvider implements BrowserMenuBranchProvider { + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2w-wrench-outline"); + } + + @Override + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.MUTATION; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("chmod"); + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + return model.getFileSystem().getShell().orElseThrow().getOsType() != OsType.WINDOWS; + } + + @Override + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { + if (entries.stream() + .anyMatch(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.DIRECTORY)) { + return List.of(new FlatProvider(), new RecursiveProvider()); + } else { + return getLeafActions(model, false); + } + } + + private static class FlatProvider implements BrowserMenuBranchProvider { + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2f-file-outline"); + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("flat"); + } + + @Override + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { + return getLeafActions(model, false); + } + } + + private static class RecursiveProvider implements BrowserMenuBranchProvider { + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2f-file-tree"); + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("recursive"); + } + + @Override + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { + return getLeafActions(model, true); + } + } + + private static List getLeafActions(BrowserFileSystemTabModel model, boolean recursive) { + var custom = new CustomProvider(recursive); + return List.of( + new FixedProvider("400", recursive), + new FixedProvider("600", recursive), + new FixedProvider("644", recursive), + new FixedProvider("700", recursive), + new FixedProvider("755", recursive), + new FixedProvider("777", recursive), + new FixedProvider("u+x", recursive), + new FixedProvider("a+x", recursive), + custom); + } + + private static class FixedProvider implements BrowserMenuLeafProvider { + + private final String permissions; + private final boolean recursive; + + private FixedProvider(String permissions, boolean recursive) { + this.permissions = permissions; + this.recursive = recursive; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return new SimpleStringProperty(permissions); + } + + @Override + public void execute(BrowserFileSystemTabModel model, List entries) { + var builder = ChmodActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.permissions(permissions); + builder.recursive(recursive); + var action = builder.build(); + action.executeAsync(); + } + } + + private static class CustomProvider implements BrowserMenuLeafProvider { + + private final boolean recursive; + + private CustomProvider(boolean recursive) { + this.recursive = recursive; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("custom"); + } + + @Override + public void execute(BrowserFileSystemTabModel model, List entries) { + var permissions = new SimpleStringProperty(); + var modal = ModalOverlay.of( + "chmodPermissions", + Comp.of(() -> { + var creationName = new TextField(); + creationName.textProperty().bindBidirectional(permissions); + return creationName; + }) + .prefWidth(350)); + modal.withDefaultButtons(() -> { + if (permissions.getValue() == null) { + return; + } + + var builder = ChmodActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.permissions(permissions.getValue()); + builder.recursive(recursive); + var action = builder.build(); + action.executeAsync(); + }); + modal.show(); + } + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/ChownMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/ChownMenuProvider.java new file mode 100644 index 000000000..a796e1288 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/ChownMenuProvider.java @@ -0,0 +1,174 @@ +package io.xpipe.app.browser.menu.impl; + +import io.xpipe.app.browser.action.impl.ChownActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuBranchProvider; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuItemProvider; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; +import io.xpipe.app.comp.Comp; +import io.xpipe.app.comp.base.ModalOverlay; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; +import io.xpipe.core.process.OsType; +import io.xpipe.core.store.FileKind; + +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.value.ObservableValue; +import javafx.scene.control.TextField; + +import java.util.List; +import java.util.stream.Stream; + +public class ChownMenuProvider implements BrowserMenuBranchProvider { + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2a-account-edit"); + } + + @Override + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.MUTATION; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("chown"); + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + var os = model.getFileSystem().getShell().orElseThrow().getOsType(); + return os != OsType.WINDOWS && os != OsType.MACOS; + } + + @Override + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { + if (entries.stream() + .anyMatch(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.DIRECTORY)) { + return List.of(new FlatProvider(), new RecursiveProvider()); + } else { + return getLeafActions(model, false); + } + } + + private static class FlatProvider implements BrowserMenuBranchProvider { + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2f-file-outline"); + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("flat"); + } + + @Override + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { + return getLeafActions(model, false); + } + } + + private static class RecursiveProvider implements BrowserMenuBranchProvider { + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2f-file-tree"); + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("recursive"); + } + + @Override + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { + return getLeafActions(model, true); + } + } + + private static List getLeafActions(BrowserFileSystemTabModel model, boolean recursive) { + var actions = Stream.concat( + model.getCache().getUsers().entrySet().stream() + .filter(e -> !e.getValue().equals("nohome") + && !e.getValue().equals("nobody") + && (e.getKey().equals(0) || e.getKey() >= 900)) + .map(e -> e.getValue()) + .map(s -> (BrowserMenuLeafProvider) new FixedProvider(s, recursive)), + Stream.of(new CustomProvider(recursive))) + .toList(); + return actions; + } + + private static class FixedProvider implements BrowserMenuLeafProvider { + + private final String owner; + private final boolean recursive; + + private FixedProvider(String owner, boolean recursive) { + this.owner = owner; + this.recursive = recursive; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return new SimpleStringProperty(owner); + } + + @Override + public void execute(BrowserFileSystemTabModel model, List entries) { + var builder = ChownActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.owner(owner); + builder.recursive(recursive); + var action = builder.build(); + action.executeAsync(); + } + } + + private static class CustomProvider implements BrowserMenuLeafProvider { + + private final boolean recursive; + + private CustomProvider(boolean recursive) { + this.recursive = recursive; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("custom"); + } + + @Override + public void execute(BrowserFileSystemTabModel model, List entries) { + var user = new SimpleStringProperty(); + var modal = ModalOverlay.of( + "userName", + Comp.of(() -> { + var creationName = new TextField(); + creationName.textProperty().bindBidirectional(user); + return creationName; + }) + .prefWidth(350)); + modal.withDefaultButtons(() -> { + if (user.getValue() == null) { + return; + } + + var builder = ChownActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.owner(user.getValue()); + builder.recursive(recursive); + var action = builder.build(); + action.executeAsync(); + }); + modal.show(); + } + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/ComputeDirectorySizesMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/ComputeDirectorySizesMenuProvider.java new file mode 100644 index 000000000..0ba0e4595 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/ComputeDirectorySizesMenuProvider.java @@ -0,0 +1,57 @@ +package io.xpipe.app.browser.menu.impl; + +import io.xpipe.app.action.AbstractAction; +import io.xpipe.app.browser.action.impl.ComputeDirectorySizesActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; +import io.xpipe.core.store.FileKind; + +import javafx.beans.value.ObservableValue; + +import java.util.List; + +public class ComputeDirectorySizesMenuProvider implements BrowserMenuLeafProvider { + + @Override + public AbstractAction createAction(BrowserFileSystemTabModel model, List entries) { + var builder = ComputeDirectorySizesActionProvider.Action.builder(); + builder.initEntries(model, entries); + return builder.build(); + } + + public String getId() { + return "computeDirectorySizes"; + } + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2f-format-list-text"); + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + var topLevel = + entries.size() == 1 && entries.getFirst().getRawFileEntry().equals(model.getCurrentDirectory()); + return AppI18n.observable(topLevel ? "computeDirectorySizes" : "computeSize"); + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + return entries.stream() + .allMatch(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.DIRECTORY); + } + + @Override + public boolean acceptsEmptySelection() { + return true; + } + + @Override + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.ACTION; + } +} diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/CopyAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/CopyMenuProvider.java similarity index 66% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/CopyAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/CopyMenuProvider.java index 7c714bd89..4adce0fa0 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/CopyAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/CopyMenuProvider.java @@ -1,22 +1,21 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; -import io.xpipe.app.browser.action.BrowserLeafAction; import io.xpipe.app.browser.file.BrowserClipboard; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; -import org.kordamp.ikonli.javafx.FontIcon; - import java.util.List; -public class CopyAction implements BrowserLeafAction { +public class CopyMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { @@ -24,13 +23,13 @@ public class CopyAction implements BrowserLeafAction { } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return new FontIcon("mdi2c-content-copy"); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdoal-file_copy"); } @Override - public Category getCategory() { - return Category.COPY_PASTE; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.COPY_PASTE; } @Override diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/CopyPathAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/CopyPathMenuProvider.java similarity index 85% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/CopyPathAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/CopyPathMenuProvider.java index d87d4058f..ba84d422c 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/CopyPathAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/CopyPathMenuProvider.java @@ -1,13 +1,13 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; -import io.xpipe.app.browser.action.BrowserAction; -import io.xpipe.app.browser.action.BrowserActionFormatter; -import io.xpipe.app.browser.action.BrowserBranchAction; -import io.xpipe.app.browser.action.BrowserLeafAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuBranchProvider; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.util.ClipboardHelper; +import io.xpipe.app.util.LabelGraphic; import io.xpipe.core.store.FileKind; import javafx.beans.property.SimpleObjectProperty; @@ -19,11 +19,11 @@ import javafx.scene.input.KeyCombination; import java.util.List; import java.util.stream.Collectors; -public class CopyPathAction implements BrowserAction, BrowserBranchAction { +public class CopyPathMenuProvider implements BrowserMenuBranchProvider { @Override - public Category getCategory() { - return Category.COPY_PASTE; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.COPY_PASTE; } @Override @@ -37,9 +37,15 @@ public class CopyPathAction implements BrowserAction, BrowserBranchAction { } @Override - public List getBranchingActions(BrowserFileSystemTabModel model, List entries) { + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2c-content-copy"); + } + + @Override + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { return List.of( - new BrowserLeafAction() { + new BrowserMenuLeafProvider() { @Override public KeyCombination getShortcut() { return new KeyCodeCombination(KeyCode.C, KeyCombination.ALT_DOWN, KeyCombination.SHORTCUT_DOWN); @@ -49,7 +55,7 @@ public class CopyPathAction implements BrowserAction, BrowserBranchAction { public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { if (entries.size() == 1) { - return new SimpleObjectProperty<>(BrowserActionFormatter.centerEllipsis( + return new SimpleObjectProperty<>(centerEllipsis( entries.getFirst() .getRawFileEntry() .getPath() @@ -68,12 +74,12 @@ public class CopyPathAction implements BrowserAction, BrowserBranchAction { ClipboardHelper.copyText(s); } }, - new BrowserLeafAction() { + new BrowserMenuLeafProvider() { @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { if (entries.size() == 1) { - return new SimpleObjectProperty<>(BrowserActionFormatter.centerEllipsis( + return new SimpleObjectProperty<>(centerEllipsis( entries.getFirst() .getRawFileEntry() .getPath() @@ -104,13 +110,13 @@ public class CopyPathAction implements BrowserAction, BrowserBranchAction { ClipboardHelper.copyText(s); } }, - new BrowserLeafAction() { + new BrowserMenuLeafProvider() { @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { if (entries.size() == 1) { return new SimpleObjectProperty<>("\"" - + BrowserActionFormatter.centerEllipsis( + + centerEllipsis( entries.getFirst() .getRawFileEntry() .getPath() @@ -138,7 +144,7 @@ public class CopyPathAction implements BrowserAction, BrowserBranchAction { ClipboardHelper.copyText(s); } }, - new BrowserLeafAction() { + new BrowserMenuLeafProvider() { @Override public KeyCombination getShortcut() { return new KeyCodeCombination( @@ -149,7 +155,7 @@ public class CopyPathAction implements BrowserAction, BrowserBranchAction { public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { if (entries.size() == 1) { - return new SimpleObjectProperty<>(BrowserActionFormatter.centerEllipsis( + return new SimpleObjectProperty<>(centerEllipsis( entries.getFirst() .getRawFileEntry() .getPath() @@ -168,12 +174,12 @@ public class CopyPathAction implements BrowserAction, BrowserBranchAction { ClipboardHelper.copyText(s); } }, - new BrowserLeafAction() { + new BrowserMenuLeafProvider() { @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { if (entries.size() == 1) { - return new SimpleObjectProperty<>(BrowserActionFormatter.centerEllipsis( + return new SimpleObjectProperty<>(centerEllipsis( entries.getFirst() .getRawFileEntry() .getPath() @@ -211,13 +217,13 @@ public class CopyPathAction implements BrowserAction, BrowserBranchAction { ClipboardHelper.copyText(s); } }, - new BrowserLeafAction() { + new BrowserMenuLeafProvider() { @Override public ObservableValue getName( BrowserFileSystemTabModel model, List entries) { if (entries.size() == 1) { return new SimpleObjectProperty<>("\"" - + BrowserActionFormatter.centerEllipsis( + + centerEllipsis( entries.getFirst() .getRawFileEntry() .getPath() @@ -247,4 +253,17 @@ public class CopyPathAction implements BrowserAction, BrowserBranchAction { } }); } + + private static String centerEllipsis(String input, int length) { + if (input == null) { + return ""; + } + + if (input.length() <= length) { + return input; + } + + var half = (length / 2) - 5; + return input.substring(0, half) + " ... " + input.substring(input.length() - half); + } } diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/DeleteMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/DeleteMenuProvider.java new file mode 100644 index 000000000..c4dd10ee2 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/DeleteMenuProvider.java @@ -0,0 +1,66 @@ +package io.xpipe.app.browser.menu.impl; + +import io.xpipe.app.action.AbstractAction; +import io.xpipe.app.browser.action.impl.DeleteActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; +import io.xpipe.core.store.FileKind; + +import javafx.beans.value.ObservableValue; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyCodeCombination; +import javafx.scene.input.KeyCombination; + +import java.util.List; + +public class DeleteMenuProvider implements BrowserMenuLeafProvider { + + @Override + public AbstractAction createAction(BrowserFileSystemTabModel model, List entries) { + var link = entries.stream() + .anyMatch(browserEntry -> browserEntry.getRawFileEntry().getKind() == FileKind.LINK); + var files = entries.stream() + .map(browserEntry -> !link + ? browserEntry.getRawFileEntry().resolved().getPath() + : browserEntry.getRawFileEntry().getPath()) + .toList(); + var builder = DeleteActionProvider.Action.builder(); + builder.initFiles(model, files); + return builder.build(); + } + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2d-delete"); + } + + @Override + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.MUTATION; + } + + @Override + public KeyCombination getShortcut() { + return new KeyCodeCombination(KeyCode.DELETE); + } + + @Override + public boolean automaticallyResolveLinks() { + return false; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable( + "deleteFile", + entries.stream() + .anyMatch(browserEntry -> + browserEntry.getRawFileEntry().getKind() == FileKind.LINK) + ? "link" + : ""); + } +} diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/DownloadAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/DownloadMenuProvider.java similarity index 73% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/DownloadAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/DownloadMenuProvider.java index fdf26341a..0520bf92b 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/DownloadAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/DownloadMenuProvider.java @@ -1,22 +1,21 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.BrowserFullSessionModel; -import io.xpipe.app.browser.action.BrowserLeafAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; -import org.kordamp.ikonli.javafx.FontIcon; - import java.util.List; -public class DownloadAction implements BrowserLeafAction { +public class DownloadMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { @@ -33,13 +32,13 @@ public class DownloadAction implements BrowserLeafAction { } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return new FontIcon("mdi2d-download"); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2d-download"); } @Override - public Category getCategory() { - return Category.MUTATION; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.ACTION; } @Override diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/EditFileAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/EditFileMenuProvider.java similarity index 76% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/EditFileAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/EditFileMenuProvider.java index 57d31d6b9..7e10f398f 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/EditFileAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/EditFileMenuProvider.java @@ -1,24 +1,23 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; -import io.xpipe.app.browser.action.BrowserLeafAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileOpener; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.app.util.LabelGraphic; import io.xpipe.core.store.FileKind; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; -import org.kordamp.ikonli.javafx.FontIcon; - import java.util.List; -public class EditFileAction implements BrowserLeafAction { +public class EditFileMenuProvider implements BrowserMenuLeafProvider { @Override public KeyCombination getShortcut() { @@ -33,13 +32,13 @@ public class EditFileAction implements BrowserLeafAction { } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return new FontIcon("mdi2p-pencil"); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2p-pencil"); } @Override - public Category getCategory() { - return Category.OPEN; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.OPEN; } @Override diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/FollowLinkAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/FollowLinkMenuProvider.java similarity index 69% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/FollowLinkAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/FollowLinkMenuProvider.java index 4dbfdd3d2..bff4367c1 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/FollowLinkAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/FollowLinkMenuProvider.java @@ -1,19 +1,18 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; -import io.xpipe.app.browser.action.BrowserLeafAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; import io.xpipe.core.store.FileKind; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; - -import org.kordamp.ikonli.javafx.FontIcon; import java.util.List; -public class FollowLinkAction implements BrowserLeafAction { +public class FollowLinkMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { @@ -22,13 +21,13 @@ public class FollowLinkAction implements BrowserLeafAction { } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return new FontIcon("mdi2a-arrow-top-right-thick"); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2a-arrow-top-right-thick"); } @Override - public Category getCategory() { - return Category.OPEN; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.OPEN; } @Override diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/ForwardAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/ForwardMenuProvider.java similarity index 76% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/ForwardAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/ForwardMenuProvider.java index bd157528c..415994cee 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/ForwardAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/ForwardMenuProvider.java @@ -1,21 +1,19 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; -import io.xpipe.app.browser.action.BrowserLeafAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; -import org.kordamp.ikonli.javafx.FontIcon; - import java.util.List; -public class ForwardAction implements BrowserLeafAction { +public class ForwardMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { @@ -27,8 +25,8 @@ public class ForwardAction implements BrowserLeafAction { } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return new FontIcon("fth-arrow-right"); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("fth-arrow-right"); } @Override diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/JarAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/JarMenuProvider.java similarity index 54% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/JarAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/JarMenuProvider.java index b7ee10f6b..ca8e902df 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/JarAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/JarMenuProvider.java @@ -1,9 +1,12 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; -import io.xpipe.app.browser.action.BrowserActionFormatter; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.icon.BrowserIconFileType; +import io.xpipe.app.browser.menu.BrowserApplicationPathMenuProvider; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.FileTypeMenuProvider; +import io.xpipe.app.browser.menu.MultiExecuteMenuProvider; import io.xpipe.core.process.CommandBuilder; import io.xpipe.core.process.ShellControl; @@ -12,21 +15,23 @@ import javafx.beans.value.ObservableValue; import java.util.List; -public class JarAction extends MultiExecuteAction implements JavaAction, FileTypeAction { +public class JarMenuProvider extends MultiExecuteMenuProvider + implements BrowserApplicationPathMenuProvider, FileTypeMenuProvider { @Override - public Category getCategory() { - return Category.CUSTOM; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.CUSTOM; } @Override public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { - return new SimpleStringProperty("java -jar " + BrowserActionFormatter.filesArgument(entries)); + var arg = entries.size() == 1 ? entries.getFirst().getFileName() : "(" + entries.size() + ")"; + return new SimpleStringProperty("java -jar " + arg); } @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { - return super.isApplicable(model, entries) && FileTypeAction.super.isApplicable(model, entries); + return super.isApplicable(model, entries) && FileTypeMenuProvider.super.isApplicable(model, entries); } @Override @@ -40,4 +45,9 @@ public class JarAction extends MultiExecuteAction implements JavaAction, FileTyp public BrowserIconFileType getType() { return BrowserIconFileType.byId("jar"); } + + @Override + public String getExecutable() { + return "java"; + } } diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/JavapMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/JavapMenuProvider.java new file mode 100644 index 000000000..0f95058cc --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/JavapMenuProvider.java @@ -0,0 +1,61 @@ +package io.xpipe.app.browser.menu.impl; + +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.icon.BrowserIconFileType; +import io.xpipe.app.browser.menu.BrowserApplicationPathMenuProvider; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; +import io.xpipe.app.browser.menu.FileTypeMenuProvider; +import io.xpipe.app.util.FileOpener; +import io.xpipe.core.process.CommandBuilder; +import io.xpipe.core.process.ShellControl; + +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.value.ObservableValue; + +import java.util.List; + +public class JavapMenuProvider + implements FileTypeMenuProvider, BrowserApplicationPathMenuProvider, BrowserMenuLeafProvider { + + @Override + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.CUSTOM; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + var arg = entries.size() == 1 ? entries.getFirst().getFileName() : "(" + entries.size() + ")"; + return new SimpleStringProperty("javap -c -p " + arg); + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + return FileTypeMenuProvider.super.isApplicable(model, entries); + } + + @Override + public BrowserIconFileType getType() { + return BrowserIconFileType.byId("class"); + } + + @Override + public void execute(BrowserFileSystemTabModel model, List entries) throws Exception { + ShellControl sc = model.getFileSystem().getShell().orElseThrow(); + for (BrowserEntry entry : entries) { + var command = CommandBuilder.of() + .add("javap", "-c", "-p") + .addFile(entry.getRawFileEntry().getPath()); + var out = sc.command(command) + .withWorkingDirectory(model.getCurrentDirectory().getPath()) + .readStdoutOrThrow(); + FileOpener.openReadOnlyString(out); + } + } + + @Override + public String getExecutable() { + return "java"; + } +} diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/NewItemAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/NewItemMenuProvider.java similarity index 59% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/NewItemAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/NewItemMenuProvider.java index c2cb05190..920c15c19 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/NewItemAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/NewItemMenuProvider.java @@ -1,37 +1,38 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; -import io.xpipe.app.browser.action.BrowserAction; -import io.xpipe.app.browser.action.BrowserBranchAction; -import io.xpipe.app.browser.action.BrowserLeafAction; +import io.xpipe.app.browser.action.impl.NewDirectoryActionProvider; +import io.xpipe.app.browser.action.impl.NewFileActionProvider; +import io.xpipe.app.browser.action.impl.NewLinkActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.icon.BrowserIcons; +import io.xpipe.app.browser.menu.BrowserMenuBranchProvider; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.comp.Comp; import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; import io.xpipe.app.util.OptionsBuilder; import io.xpipe.core.process.OsType; import io.xpipe.core.store.FilePath; import javafx.beans.property.SimpleStringProperty; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; import javafx.scene.control.TextField; -import org.kordamp.ikonli.javafx.FontIcon; - import java.util.List; -public class NewItemAction implements BrowserAction, BrowserBranchAction { +public class NewItemMenuProvider implements BrowserMenuBranchProvider { @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return new FontIcon("mdi2p-plus-box-outline"); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2p-plus-box-outline"); } @Override - public Category getCategory() { - return Category.MUTATION; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.ACTION; } @Override @@ -45,9 +46,10 @@ public class NewItemAction implements BrowserAction, BrowserBranchAction { } @Override - public List getBranchingActions(BrowserFileSystemTabModel model, List entries) { + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { return List.of( - new BrowserLeafAction() { + new BrowserMenuLeafProvider() { @Override public void execute(BrowserFileSystemTabModel model, List entries) { var name = new SimpleStringProperty(); @@ -60,14 +62,21 @@ public class NewItemAction implements BrowserAction, BrowserBranchAction { }) .prefWidth(350)); modal.withDefaultButtons(() -> { - model.createFileAsync(name.getValue()); + if (name.getValue() == null || name.getValue().isEmpty()) { + return; + } + + var builder = NewFileActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.name(name.getValue()); + builder.build().executeAsync(); }); modal.show(); } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return BrowserIcons.createDefaultFileIcon().createRegion(); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.CompGraphic(BrowserIcons.createDefaultFileIcon()); } @Override @@ -76,7 +85,7 @@ public class NewItemAction implements BrowserAction, BrowserBranchAction { return AppI18n.observable("file"); } }, - new BrowserLeafAction() { + new BrowserMenuLeafProvider() { @Override public void execute(BrowserFileSystemTabModel model, List entries) { var name = new SimpleStringProperty(); @@ -89,14 +98,21 @@ public class NewItemAction implements BrowserAction, BrowserBranchAction { }) .prefWidth(350)); modal.withDefaultButtons(() -> { - model.createDirectoryAsync(name.getValue()); + if (name.getValue() == null || name.getValue().isEmpty()) { + return; + } + + var builder = NewDirectoryActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.name(name.getValue()); + builder.build().executeAsync(); }); modal.show(); } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return BrowserIcons.createDefaultDirectoryIcon().createRegion(); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.CompGraphic(BrowserIcons.createDefaultDirectoryIcon()); } @Override @@ -105,7 +121,7 @@ public class NewItemAction implements BrowserAction, BrowserBranchAction { return AppI18n.observable("directory"); } }, - new BrowserLeafAction() { + new BrowserMenuLeafProvider() { @Override public void execute(BrowserFileSystemTabModel model, List entries) { var linkName = new SimpleStringProperty(); @@ -120,14 +136,25 @@ public class NewItemAction implements BrowserAction, BrowserBranchAction { .buildComp() .prefWidth(350)); modal.withDefaultButtons(() -> { - model.createLinkAsync(linkName.getValue(), FilePath.of(target.getValue())); + if (linkName.getValue() == null + || linkName.getValue().isEmpty() + || target.getValue() == null + || target.getValue().isEmpty()) { + return; + } + + var builder = NewLinkActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.name(linkName.getValue()); + builder.target(FilePath.of(target.getValue())); + builder.build().executeAsync(); }); modal.show(); } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return BrowserIcons.createDefaultFileIcon().createRegion(); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.CompGraphic(BrowserIcons.createDefaultFileIcon()); } @Override diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/OpenDirectoryInNewTabAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenDirectoryInNewTabMenuProvider.java similarity index 74% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/OpenDirectoryInNewTabAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/OpenDirectoryInNewTabMenuProvider.java index df82f08f0..058e64643 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/OpenDirectoryInNewTabAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenDirectoryInNewTabMenuProvider.java @@ -1,23 +1,22 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.BrowserFullSessionModel; -import io.xpipe.app.browser.action.BrowserLeafAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; import io.xpipe.core.store.FileKind; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; -import org.kordamp.ikonli.javafx.FontIcon; - import java.util.List; -public class OpenDirectoryInNewTabAction implements BrowserLeafAction { +public class OpenDirectoryInNewTabMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { @@ -28,13 +27,13 @@ public class OpenDirectoryInNewTabAction implements BrowserLeafAction { } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return new FontIcon("mdi2f-folder-open-outline"); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2f-folder-open-outline"); } @Override - public Category getCategory() { - return Category.OPEN; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.OPEN; } @Override diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenDirectoryMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenDirectoryMenuProvider.java new file mode 100644 index 000000000..39504cf13 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenDirectoryMenuProvider.java @@ -0,0 +1,45 @@ +package io.xpipe.app.browser.menu.impl; + +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.action.impl.OpenDirectoryActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; + +import javafx.beans.value.ObservableValue; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyCodeCombination; +import javafx.scene.input.KeyCombination; + +import java.util.List; + +public class OpenDirectoryMenuProvider implements BrowserMenuLeafProvider { + + @Override + public Class getDelegateActionProvider() { + return OpenDirectoryActionProvider.class; + } + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2f-folder-open"); + } + + @Override + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.OPEN; + } + + @Override + public KeyCombination getShortcut() { + return new KeyCodeCombination(KeyCode.ENTER); + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("open"); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenFileDefaultMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenFileDefaultMenuProvider.java new file mode 100644 index 000000000..bd3c64087 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenFileDefaultMenuProvider.java @@ -0,0 +1,45 @@ +package io.xpipe.app.browser.menu.impl; + +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.action.impl.OpenFileDefaultActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; + +import javafx.beans.value.ObservableValue; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyCodeCombination; +import javafx.scene.input.KeyCombination; + +import java.util.List; + +public class OpenFileDefaultMenuProvider implements BrowserMenuLeafProvider { + + @Override + public Class getDelegateActionProvider() { + return OpenFileDefaultActionProvider.class; + } + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2b-book-open-variant"); + } + + @Override + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.OPEN; + } + + @Override + public KeyCombination getShortcut() { + return new KeyCodeCombination(KeyCode.ENTER); + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("openWithDefaultApplication"); + } +} diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/OpenFileWithAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenFileWithMenuProvider.java similarity index 58% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/OpenFileWithAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/OpenFileWithMenuProvider.java index d6e68d406..070971483 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/OpenFileWithAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenFileWithMenuProvider.java @@ -1,39 +1,38 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; -import io.xpipe.app.browser.action.BrowserLeafAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.action.impl.OpenFileWithActionProvider; import io.xpipe.app.browser.file.BrowserEntry; -import io.xpipe.app.browser.file.BrowserFileOpener; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; import io.xpipe.core.process.OsType; import io.xpipe.core.store.FileKind; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; -import org.kordamp.ikonli.javafx.FontIcon; - import java.util.List; -public class OpenFileWithAction implements BrowserLeafAction { +public class OpenFileWithMenuProvider implements BrowserMenuLeafProvider { @Override - public void execute(BrowserFileSystemTabModel model, List entries) { - var e = entries.getFirst(); - BrowserFileOpener.openWithAnyApplication(model, e.getRawFileEntry()); + public Class getDelegateActionProvider() { + return OpenFileWithActionProvider.class; } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return new FontIcon("mdi2b-book-open-page-variant-outline"); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2b-book-open-page-variant-outline"); } @Override - public Category getCategory() { - return Category.OPEN; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.OPEN; } @Override diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenNativeFileDetailsMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenNativeFileDetailsMenuProvider.java new file mode 100644 index 000000000..a02575c63 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenNativeFileDetailsMenuProvider.java @@ -0,0 +1,44 @@ +package io.xpipe.app.browser.menu.impl; + +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.action.impl.OpenFileNativeDetailsActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; +import io.xpipe.app.core.AppI18n; + +import javafx.beans.value.ObservableValue; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyCodeCombination; +import javafx.scene.input.KeyCombination; + +import java.util.List; + +public class OpenNativeFileDetailsMenuProvider implements BrowserMenuLeafProvider { + + @Override + public Class getDelegateActionProvider() { + return OpenFileNativeDetailsActionProvider.class; + } + + @Override + public KeyCombination getShortcut() { + return new KeyCodeCombination(KeyCode.ENTER, KeyCombination.ALT_DOWN); + } + + @Override + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.OPEN; + } + + @Override + public boolean acceptsEmptySelection() { + return true; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("showDetails"); + } +} diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/OpenTerminalAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenTerminalMenuProvider.java similarity index 72% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/OpenTerminalAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/OpenTerminalMenuProvider.java index c17cef8d0..b85e9efbc 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/OpenTerminalAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/OpenTerminalMenuProvider.java @@ -1,25 +1,31 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; -import io.xpipe.app.browser.action.BrowserLeafAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.action.impl.OpenTerminalActionProvider; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.app.util.LabelGraphic; import io.xpipe.core.store.FileKind; import io.xpipe.core.store.FilePath; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; -import org.kordamp.ikonli.javafx.FontIcon; - import java.util.Collections; import java.util.List; -public class OpenTerminalAction implements BrowserLeafAction { +public class OpenTerminalMenuProvider implements BrowserMenuLeafProvider { + + @Override + public Class getDelegateActionProvider() { + return OpenTerminalActionProvider.class; + } @Override public void execute(BrowserFileSystemTabModel model, List entries) { @@ -41,13 +47,13 @@ public class OpenTerminalAction implements BrowserLeafAction { } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return new FontIcon("mdi2c-console"); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2c-console"); } @Override - public Category getCategory() { - return Category.OPEN; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.OPEN; } @Override diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/PasteAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/PasteMenuProvider.java similarity index 81% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/PasteAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/PasteMenuProvider.java index 1dd377a0a..9f6ae84a9 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/PasteAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/PasteMenuProvider.java @@ -1,24 +1,23 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; -import io.xpipe.app.browser.action.BrowserLeafAction; import io.xpipe.app.browser.file.BrowserClipboard; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.file.BrowserFileTransferMode; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; import io.xpipe.core.store.FileKind; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; -import org.kordamp.ikonli.javafx.FontIcon; - import java.util.List; -public class PasteAction implements BrowserLeafAction { +public class PasteMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { @@ -44,13 +43,13 @@ public class PasteAction implements BrowserLeafAction { } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return new FontIcon("mdi2c-content-paste"); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2c-content-paste"); } @Override - public Category getCategory() { - return Category.COPY_PASTE; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.COPY_PASTE; } @Override diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/RefreshDirectoryAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/RefreshDirectoryMenuProvider.java similarity index 75% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/RefreshDirectoryAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/RefreshDirectoryMenuProvider.java index 65190ee60..67963235d 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/RefreshDirectoryAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/RefreshDirectoryMenuProvider.java @@ -1,21 +1,19 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; -import io.xpipe.app.browser.action.BrowserLeafAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; -import org.kordamp.ikonli.javafx.FontIcon; - import java.util.List; -public class RefreshDirectoryAction implements BrowserLeafAction { +public class RefreshDirectoryMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) throws Exception { @@ -27,8 +25,8 @@ public class RefreshDirectoryAction implements BrowserLeafAction { } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return new FontIcon("mdmz-refresh"); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdmz-refresh"); } @Override diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/RenameAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/RenameMenuProvider.java similarity index 67% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/RenameAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/RenameMenuProvider.java index 4ed6250b5..bec71cf78 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/RenameAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/RenameMenuProvider.java @@ -1,22 +1,21 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; -import io.xpipe.app.browser.action.BrowserLeafAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; import io.xpipe.core.store.FileKind; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyCodeCombination; import javafx.scene.input.KeyCombination; -import org.kordamp.ikonli.javafx.FontIcon; - import java.util.List; -public class RenameAction implements BrowserLeafAction { +public class RenameMenuProvider implements BrowserMenuLeafProvider { @Override public void execute(BrowserFileSystemTabModel model, List entries) { @@ -24,13 +23,13 @@ public class RenameAction implements BrowserLeafAction { } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return new FontIcon("mdi2r-rename-box"); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2r-rename-box"); } @Override - public Category getCategory() { - return Category.MUTATION; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.MUTATION; } @Override @@ -52,4 +51,9 @@ public class RenameAction implements BrowserLeafAction { public boolean automaticallyResolveLinks() { return false; } + + @Override + public String getId() { + return "renameFile"; + } } diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/RunAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/RunFileMenuProvider.java similarity index 80% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/RunAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/RunFileMenuProvider.java index 47f41190c..a4972080a 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/RunAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/RunFileMenuProvider.java @@ -1,8 +1,11 @@ -package io.xpipe.ext.base.browser; +package io.xpipe.app.browser.menu.impl; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.MultiExecuteMenuProvider; import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; import io.xpipe.core.process.CommandBuilder; import io.xpipe.core.process.OsType; import io.xpipe.core.process.ShellControl; @@ -11,14 +14,11 @@ import io.xpipe.core.store.FileEntry; import io.xpipe.core.store.FileKind; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; - -import org.kordamp.ikonli.javafx.FontIcon; import java.util.List; import java.util.stream.Stream; -public class RunAction extends MultiExecuteAction { +public class RunFileMenuProvider extends MultiExecuteMenuProvider { private boolean isExecutable(FileEntry e) { if (e.getKind() != FileKind.FILE) { @@ -54,13 +54,13 @@ public class RunAction extends MultiExecuteAction { } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return new FontIcon("mdi2p-play"); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2p-play"); } @Override - public Category getCategory() { - return Category.CUSTOM; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.CUSTOM; } @Override diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/compress/BaseUntarAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/BaseUntarMenuProvider.java similarity index 52% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/compress/BaseUntarAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/compress/BaseUntarMenuProvider.java index 5120f29e6..14327bdc8 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/compress/BaseUntarAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/BaseUntarMenuProvider.java @@ -1,34 +1,34 @@ -package io.xpipe.ext.base.browser.compress; +package io.xpipe.app.browser.menu.impl.compress; -import io.xpipe.app.browser.action.BrowserApplicationPathAction; -import io.xpipe.app.browser.action.BrowserLeafAction; +import io.xpipe.app.action.AbstractAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.icon.BrowserIconFileType; import io.xpipe.app.browser.icon.BrowserIcons; +import io.xpipe.app.browser.menu.BrowserApplicationPathMenuProvider; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; -import io.xpipe.core.process.CommandBuilder; -import io.xpipe.core.process.ShellControl; +import io.xpipe.app.util.LabelGraphic; import io.xpipe.core.store.FilePath; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; import java.util.List; -public class BaseUntarAction implements BrowserApplicationPathAction, BrowserLeafAction { +public class BaseUntarMenuProvider implements BrowserApplicationPathMenuProvider, BrowserMenuLeafProvider { private final boolean gz; private final boolean toDirectory; - public BaseUntarAction(boolean gz, boolean toDirectory) { + public BaseUntarMenuProvider(boolean gz, boolean toDirectory) { this.gz = gz; this.toDirectory = toDirectory; } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return BrowserIcons.createIcon(BrowserIconFileType.byId("zip")).createRegion(); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.CompGraphic(BrowserIcons.createContextMenuIcon(BrowserIconFileType.byId("zip"))); } @Override @@ -37,33 +37,17 @@ public class BaseUntarAction implements BrowserApplicationPathAction, BrowserLea } @Override - public void execute(BrowserFileSystemTabModel model, List entries) { - model.runAsync( - () -> { - ShellControl sc = model.getFileSystem().getShell().orElseThrow(); - for (BrowserEntry entry : entries) { - var target = getTarget(entry.getRawFileEntry().getPath()); - var c = CommandBuilder.of().add("tar"); - if (toDirectory) { - c.add("-C").addFile(target); - } - c.add("-x").addIf(gz, "-z").add("-f"); - c.addFile(entry.getRawFileEntry().getPath()); - if (toDirectory) { - model.getFileSystem().mkdirs(target); - } - sc.command(c) - .withWorkingDirectory( - model.getCurrentDirectory().getPath()) - .execute(); - } - }, - true); + public AbstractAction createAction(BrowserFileSystemTabModel model, List entries) { + var builder = UntarActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.gz(gz); + builder.toDirectory(toDirectory); + return builder.build(); } @Override - public Category getCategory() { - return Category.CUSTOM; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.CUSTOM; } @Override diff --git a/ext/base/src/main/java/io/xpipe/ext/base/browser/compress/BaseUnzipUnixAction.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/BaseUnzipUnixMenuProvider.java similarity index 51% rename from ext/base/src/main/java/io/xpipe/ext/base/browser/compress/BaseUnzipUnixAction.java rename to app/src/main/java/io/xpipe/app/browser/menu/impl/compress/BaseUnzipUnixMenuProvider.java index b309f83b4..60e4ef1f3 100644 --- a/ext/base/src/main/java/io/xpipe/ext/base/browser/compress/BaseUnzipUnixAction.java +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/BaseUnzipUnixMenuProvider.java @@ -1,31 +1,32 @@ -package io.xpipe.ext.base.browser.compress; +package io.xpipe.app.browser.menu.impl.compress; +import io.xpipe.app.action.AbstractAction; import io.xpipe.app.browser.file.BrowserEntry; import io.xpipe.app.browser.file.BrowserFileSystemTabModel; import io.xpipe.app.browser.icon.BrowserIconFileType; import io.xpipe.app.browser.icon.BrowserIcons; +import io.xpipe.app.browser.menu.BrowserApplicationPathMenuProvider; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; import io.xpipe.app.core.AppI18n; -import io.xpipe.core.process.CommandBuilder; +import io.xpipe.app.util.LabelGraphic; import io.xpipe.core.process.OsType; -import io.xpipe.core.store.FilePath; -import io.xpipe.ext.base.browser.ExecuteApplicationAction; import javafx.beans.value.ObservableValue; -import javafx.scene.Node; import java.util.List; -public abstract class BaseUnzipUnixAction extends ExecuteApplicationAction { +public abstract class BaseUnzipUnixMenuProvider implements BrowserMenuLeafProvider, BrowserApplicationPathMenuProvider { private final boolean toDirectory; - public BaseUnzipUnixAction(boolean toDirectory) { + public BaseUnzipUnixMenuProvider(boolean toDirectory) { this.toDirectory = toDirectory; } @Override - public Node getIcon(BrowserFileSystemTabModel model, List entries) { - return BrowserIcons.createIcon(BrowserIconFileType.byId("zip")).createRegion(); + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.CompGraphic(BrowserIcons.createContextMenuIcon(BrowserIconFileType.byId("zip"))); } @Override @@ -34,24 +35,16 @@ public abstract class BaseUnzipUnixAction extends ExecuteApplicationAction { } @Override - protected boolean refresh() { - return true; + public AbstractAction createAction(BrowserFileSystemTabModel model, List entries) { + var builder = UnzipActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.toDirectory(toDirectory); + return builder.build(); } @Override - protected CommandBuilder createCommand(BrowserFileSystemTabModel model, BrowserEntry entry) { - var command = CommandBuilder.of() - .add("unzip", "-o") - .addFile(entry.getRawFileEntry().getPath()); - if (toDirectory) { - command.add("-d").addFile(getTarget(entry.getRawFileEntry().getPath())); - } - return command; - } - - @Override - public Category getCategory() { - return Category.CUSTOM; + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.CUSTOM; } @Override @@ -59,14 +52,13 @@ public abstract class BaseUnzipUnixAction extends ExecuteApplicationAction { var sep = model.getFileSystem().getShell().orElseThrow().getOsType().getFileSystemSeparator(); var dir = entries.size() > 1 ? "[...]" - : getTarget(entries.getFirst().getRawFileEntry().getPath()).getFileName() + sep; + : UnzipActionProvider.getTarget( + entries.getFirst().getRawFileEntry().getPath()) + .getFileName() + + sep; return toDirectory ? AppI18n.observable("unzipDirectory", dir) : AppI18n.observable("unzipHere"); } - private FilePath getTarget(FilePath name) { - return FilePath.of(name.toString().replaceAll("\\.zip$", "")); - } - @Override public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { return entries.stream() diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/BaseUnzipWindowsActionProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/BaseUnzipWindowsActionProvider.java new file mode 100644 index 000000000..7fae06e57 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/BaseUnzipWindowsActionProvider.java @@ -0,0 +1,63 @@ +package io.xpipe.app.browser.menu.impl.compress; + +import io.xpipe.app.action.AbstractAction; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.icon.BrowserIconFileType; +import io.xpipe.app.browser.icon.BrowserIcons; +import io.xpipe.app.browser.menu.BrowserMenuCategory; +import io.xpipe.app.browser.menu.BrowserMenuLeafProvider; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.LabelGraphic; +import io.xpipe.core.process.OsType; + +import javafx.beans.value.ObservableValue; + +import java.util.List; + +public abstract class BaseUnzipWindowsActionProvider implements BrowserMenuLeafProvider { + + private final boolean toDirectory; + + public BaseUnzipWindowsActionProvider(boolean toDirectory) { + this.toDirectory = toDirectory; + } + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.CompGraphic(BrowserIcons.createContextMenuIcon(BrowserIconFileType.byId("zip"))); + } + + @Override + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.CUSTOM; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + var sep = model.getFileSystem().getShell().orElseThrow().getOsType().getFileSystemSeparator(); + var dir = entries.size() > 1 + ? "[...]" + : UnzipActionProvider.getTarget( + entries.getFirst().getRawFileEntry().getPath()) + .getFileName() + + sep; + return toDirectory ? AppI18n.observable("unzipDirectory", dir) : AppI18n.observable("unzipHere"); + } + + @Override + public AbstractAction createAction(BrowserFileSystemTabModel model, List entries) { + var builder = UnzipActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.toDirectory(toDirectory); + return builder.build(); + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + return entries.stream() + .allMatch(entry -> + entry.getRawFileEntry().getPath().toString().endsWith(".zip")) + && model.getFileSystem().getShell().orElseThrow().getOsType().equals(OsType.WINDOWS); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/CompressMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/CompressMenuProvider.java new file mode 100644 index 000000000..fa1c5a1b2 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/CompressMenuProvider.java @@ -0,0 +1,226 @@ +package io.xpipe.app.browser.menu.impl.compress; + +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.app.browser.menu.*; +import io.xpipe.app.comp.Comp; +import io.xpipe.app.comp.base.ModalOverlay; +import io.xpipe.app.core.AppI18n; +import io.xpipe.app.util.CommandSupport; +import io.xpipe.app.util.LabelGraphic; +import io.xpipe.core.process.OsType; +import io.xpipe.core.store.FileKind; + +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.value.ObservableValue; +import javafx.scene.control.TextField; + +import java.util.List; + +public class CompressMenuProvider implements BrowserMenuBranchProvider { + + @Override + public void init(BrowserFileSystemTabModel model) throws Exception { + var sc = model.getFileSystem().getShell().orElseThrow(); + + var foundTar = CommandSupport.findProgram(sc, "tar"); + model.getCache().getInstalledApplications().put("tar", foundTar.isPresent()); + + if (sc.getOsType() != OsType.WINDOWS) { + var found = CommandSupport.findProgram(sc, "zip"); + model.getCache().getInstalledApplications().put("zip", found.isPresent()); + } + } + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return new LabelGraphic.IconGraphic("mdi2a-archive"); + } + + @Override + public BrowserMenuCategory getCategory() { + return BrowserMenuCategory.ACTION; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable("compress"); + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + var ext = List.of("zip", "tar", "tar.gz", "tgz", "rar", "xar"); + if (entries.stream().anyMatch(browserEntry -> ext.stream().anyMatch(s -> browserEntry + .getRawFileEntry() + .getPath() + .toString() + .toLowerCase() + .endsWith("." + s)))) { + return false; + } + + return true; + } + + @Override + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { + var contentsOptions = + entries.size() == 1 && entries.getFirst().getRawFileEntry().getKind() == FileKind.DIRECTORY; + if (contentsOptions) { + return List.of(new BranchProvider(false), new BranchProvider(true)); + } + + return List.of( + new ZipActionProvider(false), + new TarBasedActionProvider(false, false) { + @Override + protected String getExtension() { + return "tar"; + } + }, + new TarBasedActionProvider(false, true) { + + @Override + protected String getExtension() { + return "tar.gz"; + } + }); + } + + private class BranchProvider implements BrowserMenuBranchProvider { + + private final boolean directory; + + private BranchProvider(boolean directory) { + this.directory = directory; + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return AppI18n.observable(directory ? "excludeRoot" : "includeRoot"); + } + + @Override + public LabelGraphic getIcon(BrowserFileSystemTabModel model, List entries) { + return directory + ? new LabelGraphic.IconGraphic("mdi2f-file-tree") + : new LabelGraphic.IconGraphic("mdi2f-file-outline"); + } + + @Override + public List getBranchingActions( + BrowserFileSystemTabModel model, List entries) { + return List.of( + new ZipActionProvider(directory), + new TarBasedActionProvider(directory, false) { + @Override + protected String getExtension() { + return "tar"; + } + }, + new TarBasedActionProvider(directory, true) { + + @Override + protected String getExtension() { + return "tar.gz"; + } + }); + } + } + + private abstract static class LeafProvider implements BrowserMenuLeafProvider { + + protected final boolean directory; + + private LeafProvider(boolean directory) { + this.directory = directory; + } + + @Override + public void execute(BrowserFileSystemTabModel model, List entries) { + var name = new SimpleStringProperty(directory ? entries.getFirst().getFileName() : null); + var modal = ModalOverlay.of( + "base.archiveName", + Comp.of(() -> { + var creationName = new TextField(); + creationName.textProperty().bindBidirectional(name); + return creationName; + }) + .prefWidth(350)); + modal.withDefaultButtons(() -> { + var fixedName = name.getValue(); + if (fixedName == null) { + return; + } + + if (!fixedName.endsWith(getExtension())) { + fixedName = fixedName + "." + getExtension(); + } + + create(fixedName, model, entries); + }); + modal.show(); + } + + @Override + public ObservableValue getName(BrowserFileSystemTabModel model, List entries) { + return new SimpleStringProperty("." + getExtension()); + } + + protected abstract void create(String fileName, BrowserFileSystemTabModel model, List entries); + + protected abstract String getExtension(); + } + + private class ZipActionProvider extends LeafProvider { + + private ZipActionProvider(boolean directory) { + super(directory); + } + + @Override + protected void create(String fileName, BrowserFileSystemTabModel model, List entries) { + var builder = io.xpipe.app.browser.menu.impl.compress.ZipActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.target(model.getCurrentDirectory().getPath().join(fileName)); + builder.directoryContentOnly(directory); + builder.build().executeAsync(); + } + + @Override + protected String getExtension() { + return "zip"; + } + } + + private abstract class TarBasedActionProvider extends LeafProvider { + + private final boolean gz; + + private TarBasedActionProvider(boolean directory, boolean gz) { + super(directory); + this.gz = gz; + } + + @Override + protected void create(String fileName, BrowserFileSystemTabModel model, List entries) { + var builder = TarActionProvider.Action.builder(); + builder.initEntries(model, entries); + builder.target(model.getCurrentDirectory().getPath().join(fileName)); + builder.directoryContentOnly(directory); + builder.gz(gz); + builder.build().executeAsync(); + } + + @Override + public boolean isActive(BrowserFileSystemTabModel model, List entries) { + return model.getCache().getInstalledApplications().get("tar"); + } + + @Override + public boolean isApplicable(BrowserFileSystemTabModel model, List entries) { + return model.getFileSystem().getShell().orElseThrow().getOsType() != OsType.WINDOWS || !directory; + } + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/TarActionProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/TarActionProvider.java new file mode 100644 index 000000000..d2a0d292b --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/TarActionProvider.java @@ -0,0 +1,68 @@ +package io.xpipe.app.browser.menu.impl.compress; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.core.process.CommandBuilder; +import io.xpipe.core.store.FilePath; + +import lombok.NonNull; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +public class TarActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @NonNull + private final FilePath target; + + private final boolean directoryContentOnly; + + private final boolean gz; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + var sc = model.getFileSystem().getShell().orElseThrow(); + var tar = CommandBuilder.of() + .add("tar", "-c") + .addIf(gz, "-z") + .add("-f") + .addFile(target); + var base = model.getCurrentDirectory().getPath(); + + if (directoryContentOnly) { + var dir = getEntries().getFirst().getRawFileEntry().getPath(); + // Fix for bsd find, remove / + var command = CommandBuilder.of() + .add("find") + .addFile(dir.removeTrailingSlash().toUnix()) + .add("|", "sed") + .addLiteral("s,^" + dir.toDirectory().toUnix() + "*,,") + .add("|"); + command.add(tar).add("-C").addFile(dir.toDirectory().toUnix()).add("-T", "-"); + sc.command(command).execute(); + } else { + var command = CommandBuilder.of().add(tar); + for (BrowserEntry entry : getEntries()) { + var rel = entry.getRawFileEntry().getPath().relativize(base); + command.addFile(rel); + } + sc.command(command).execute(); + } + model.refreshSync(); + } + } + + @Override + public String getId() { + return "tar"; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarActionProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarActionProvider.java new file mode 100644 index 000000000..2f77e3421 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarActionProvider.java @@ -0,0 +1,59 @@ +package io.xpipe.app.browser.menu.impl.compress; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.core.process.CommandBuilder; +import io.xpipe.core.process.ShellControl; +import io.xpipe.core.store.FilePath; + +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +public class UntarActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + private final boolean gz; + private final boolean toDirectory; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + ShellControl sc = model.getFileSystem().getShell().orElseThrow(); + for (BrowserEntry entry : getEntries()) { + var target = getTarget(entry.getRawFileEntry().getPath()); + var c = CommandBuilder.of().add("tar"); + if (toDirectory) { + c.add("-C").addFile(target); + } + c.add("-x").addIf(gz, "-z").add("-f"); + c.addFile(entry.getRawFileEntry().getPath()); + if (toDirectory) { + model.getFileSystem().mkdirs(target); + } + sc.command(c) + .withWorkingDirectory(model.getCurrentDirectory().getPath()) + .execute(); + } + } + + private FilePath getTarget(FilePath name) { + return FilePath.of(name.toString() + .replaceAll("\\.tar$", "") + .replaceAll("\\.tar.gz$", "") + .replaceAll("\\.tgz$", "")); + } + } + + @Override + public String getId() { + return "untar"; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarDirectoryMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarDirectoryMenuProvider.java new file mode 100644 index 000000000..d10e90348 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarDirectoryMenuProvider.java @@ -0,0 +1,8 @@ +package io.xpipe.app.browser.menu.impl.compress; + +public class UntarDirectoryMenuProvider extends BaseUntarMenuProvider { + + public UntarDirectoryMenuProvider() { + super(false, true); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarGzDirectoryMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarGzDirectoryMenuProvider.java new file mode 100644 index 000000000..2ad443fd3 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarGzDirectoryMenuProvider.java @@ -0,0 +1,8 @@ +package io.xpipe.app.browser.menu.impl.compress; + +public class UntarGzDirectoryMenuProvider extends BaseUntarMenuProvider { + + public UntarGzDirectoryMenuProvider() { + super(true, true); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarGzHereMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarGzHereMenuProvider.java new file mode 100644 index 000000000..ffb565f88 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarGzHereMenuProvider.java @@ -0,0 +1,8 @@ +package io.xpipe.app.browser.menu.impl.compress; + +public class UntarGzHereMenuProvider extends BaseUntarMenuProvider { + + public UntarGzHereMenuProvider() { + super(true, false); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarHereMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarHereMenuProvider.java new file mode 100644 index 000000000..625c5c0b2 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UntarHereMenuProvider.java @@ -0,0 +1,8 @@ +package io.xpipe.app.browser.menu.impl.compress; + +public class UntarHereMenuProvider extends BaseUntarMenuProvider { + + public UntarHereMenuProvider() { + super(false, false); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipActionProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipActionProvider.java new file mode 100644 index 000000000..cc680d1dd --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipActionProvider.java @@ -0,0 +1,85 @@ +package io.xpipe.app.browser.menu.impl.compress; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.app.browser.file.BrowserFileSystemTabModel; +import io.xpipe.core.process.CommandBuilder; +import io.xpipe.core.process.OsType; +import io.xpipe.core.process.ShellControl; +import io.xpipe.core.process.ShellDialects; +import io.xpipe.core.store.FilePath; + +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +public class UnzipActionProvider implements BrowserActionProvider { + + public static FilePath getTarget(FilePath name) { + return FilePath.of(name.toString().replaceAll("\\.zip$", "")); + } + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + private final boolean toDirectory; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + var sc = model.getFileSystem().getShell().orElseThrow(); + if (sc.getOsType() == OsType.WINDOWS) { + if (ShellDialects.isPowershell(sc)) { + for (BrowserEntry entry : getEntries()) { + runPowershellCommand(sc, model, entry); + } + } else { + try (var sub = sc.subShell(ShellDialects.POWERSHELL)) { + for (BrowserEntry entry : getEntries()) { + runPowershellCommand(sub, model, entry); + } + } + } + } else { + for (BrowserEntry entry : getEntries()) { + var command = CommandBuilder.of() + .add("unzip", "-o") + .addFile(entry.getRawFileEntry().getPath()); + if (toDirectory) { + command.add("-d") + .addFile(getTarget(entry.getRawFileEntry().getPath())); + } + try (var cc = sc.command(command) + .withWorkingDirectory(model.getCurrentDirectory().getPath()) + .start()) { + cc.discardOrThrow(); + } + } + } + model.refreshSync(); + } + + private void runPowershellCommand(ShellControl sc, BrowserFileSystemTabModel model, BrowserEntry entry) + throws Exception { + var command = CommandBuilder.of().add("Expand-Archive", "-Force"); + if (toDirectory) { + var target = getTarget(entry.getRawFileEntry().getPath()); + command.add("-DestinationPath").addFile(target); + } + command.add("-Path").addFile(entry.getRawFileEntry().getPath()); + sc.command(command) + .withWorkingDirectory(model.getCurrentDirectory().getPath()) + .execute(); + } + } + + @Override + public String getId() { + return "unzip"; + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipDirectoryUnixMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipDirectoryUnixMenuProvider.java new file mode 100644 index 000000000..4c5854889 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipDirectoryUnixMenuProvider.java @@ -0,0 +1,8 @@ +package io.xpipe.app.browser.menu.impl.compress; + +public class UnzipDirectoryUnixMenuProvider extends BaseUnzipUnixMenuProvider { + + public UnzipDirectoryUnixMenuProvider() { + super(true); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipDirectoryWindowsActionProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipDirectoryWindowsActionProvider.java new file mode 100644 index 000000000..b34ee0e9e --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipDirectoryWindowsActionProvider.java @@ -0,0 +1,8 @@ +package io.xpipe.app.browser.menu.impl.compress; + +public class UnzipDirectoryWindowsActionProvider extends BaseUnzipWindowsActionProvider { + + public UnzipDirectoryWindowsActionProvider() { + super(true); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipHereUnixMenuProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipHereUnixMenuProvider.java new file mode 100644 index 000000000..68a47617b --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipHereUnixMenuProvider.java @@ -0,0 +1,8 @@ +package io.xpipe.app.browser.menu.impl.compress; + +public class UnzipHereUnixMenuProvider extends BaseUnzipUnixMenuProvider { + + public UnzipHereUnixMenuProvider() { + super(false); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipHereWindowsActionProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipHereWindowsActionProvider.java new file mode 100644 index 000000000..124de3a47 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/UnzipHereWindowsActionProvider.java @@ -0,0 +1,8 @@ +package io.xpipe.app.browser.menu.impl.compress; + +public class UnzipHereWindowsActionProvider extends BaseUnzipWindowsActionProvider { + + public UnzipHereWindowsActionProvider() { + super(false); + } +} diff --git a/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/ZipActionProvider.java b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/ZipActionProvider.java new file mode 100644 index 000000000..7e3b2ef2e --- /dev/null +++ b/app/src/main/java/io/xpipe/app/browser/menu/impl/compress/ZipActionProvider.java @@ -0,0 +1,90 @@ +package io.xpipe.app.browser.menu.impl.compress; + +import io.xpipe.app.browser.action.BrowserAction; +import io.xpipe.app.browser.action.BrowserActionProvider; +import io.xpipe.app.browser.file.BrowserEntry; +import io.xpipe.core.process.CommandBuilder; +import io.xpipe.core.process.OsType; +import io.xpipe.core.process.ShellDialects; +import io.xpipe.core.store.FileKind; +import io.xpipe.core.store.FilePath; + +import lombok.NonNull; +import lombok.experimental.SuperBuilder; +import lombok.extern.jackson.Jacksonized; + +public class ZipActionProvider implements BrowserActionProvider { + + @Jacksonized + @SuperBuilder + public static class Action extends BrowserAction { + + @NonNull + private final FilePath target; + + private final boolean directoryContentOnly; + + @Override + public boolean isMutation() { + return true; + } + + @Override + public void executeImpl() throws Exception { + var sc = model.getFileSystem().getShell().orElseThrow(); + if (sc.getOsType() == OsType.WINDOWS) { + var base = model.getCurrentDirectory().getPath(); + var command = CommandBuilder.of() + .add("Compress-Archive", "-Force", "-DestinationPath") + .addFile(target) + .add("-Path"); + for (int i = 0; i < getEntries().size(); i++) { + var rel = getEntries().get(i).getRawFileEntry().getPath().relativize(base); + if (getEntries().get(i).getRawFileEntry().getKind() == FileKind.DIRECTORY && directoryContentOnly) { + command.addQuoted(rel.toDirectory().toWindows() + "*"); + } else { + command.addFile(rel.toWindows()); + } + if (i != getEntries().size() - 1) { + command.add(","); + } + } + + if (ShellDialects.isPowershell(sc)) { + sc.command(command).withWorkingDirectory(base).execute(); + } else { + try (var sub = sc.subShell(ShellDialects.POWERSHELL)) { + sub.command(command).withWorkingDirectory(base).execute(); + } + } + } else { + var command = CommandBuilder.of().add("zip", "-r", "-"); + for (BrowserEntry entry : getEntries()) { + var base = target.getParent(); + var rel = entry.getRawFileEntry().getPath().relativize(base).toUnix(); + if (entry.getRawFileEntry().getKind() == FileKind.DIRECTORY && directoryContentOnly) { + command.add("."); + } else { + command.addFile(rel); + } + } + command.add(">").addFile(target); + + if (directoryContentOnly) { + sc.command(command) + .withWorkingDirectory( + getEntries().getFirst().getRawFileEntry().getPath()) + .execute(); + } else { + sc.command(command).execute(); + } + } + model.refreshSync(); + } + } + + @Override + public String getId() { + return "zip"; + } +} diff --git a/app/src/main/java/io/xpipe/app/comp/base/AppLayoutComp.java b/app/src/main/java/io/xpipe/app/comp/base/AppLayoutComp.java index 0a27668a5..e4340270d 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/AppLayoutComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/AppLayoutComp.java @@ -2,8 +2,8 @@ package io.xpipe.app.comp.base; import io.xpipe.app.comp.Comp; import io.xpipe.app.comp.CompStructure; -import io.xpipe.app.comp.store.StoreViewState; import io.xpipe.app.core.AppLayoutModel; +import io.xpipe.app.hub.comp.StoreViewState; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.util.PlatformThread; diff --git a/app/src/main/java/io/xpipe/app/comp/base/AppMainWindowContentComp.java b/app/src/main/java/io/xpipe/app/comp/base/AppMainWindowContentComp.java index f0b529e16..76a1e7a73 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/AppMainWindowContentComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/AppMainWindowContentComp.java @@ -2,28 +2,27 @@ package io.xpipe.app.comp.base; import io.xpipe.app.comp.Comp; import io.xpipe.app.comp.SimpleComp; -import io.xpipe.app.core.AppFontSizes; -import io.xpipe.app.core.AppProperties; +import io.xpipe.app.core.*; import io.xpipe.app.core.window.AppDialog; import io.xpipe.app.core.window.AppMainWindow; import io.xpipe.app.issue.TrackEvent; -import io.xpipe.app.resources.AppImages; -import io.xpipe.app.resources.AppResources; +import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.util.PlatformThread; -import io.xpipe.core.process.OsType; -import javafx.animation.Animation; +import javafx.animation.*; +import javafx.beans.property.SimpleBooleanProperty; import javafx.collections.ListChangeListener; +import javafx.css.PseudoClass; import javafx.geometry.Pos; +import javafx.scene.effect.DropShadow; import javafx.scene.image.ImageView; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; import javafx.stage.Stage; import javafx.stage.Window; -import atlantafx.base.util.Animations; - public class AppMainWindowContentComp extends SimpleComp { private final Stage stage; @@ -35,30 +34,58 @@ public class AppMainWindowContentComp extends SimpleComp { @Override protected Region createSimple() { var overlay = AppDialog.getModalOverlays(); - var loaded = AppMainWindow.getLoadedContent(); + var loaded = AppMainWindow.getInstance().getLoadedContent(); + var sidebarPresent = new SimpleBooleanProperty(); var bg = Comp.of(() -> { var loadingIcon = new ImageView(); - loadingIcon.setFitWidth(64); - loadingIcon.setFitHeight(64); + loadingIcon.setFitWidth(80); + loadingIcon.setFitHeight(80); - var anim = Animations.pulse(loadingIcon, 1.1); - if (OsType.getLocal() != OsType.LINUX) { - anim.setRate(0.85); - anim.setCycleCount(Animation.INDEFINITE); - anim.play(); - } + var color = + AppPrefs.get() != null && AppPrefs.get().theme().getValue().isDark() + ? Color.web("#0b898aff").darker() + : Color.web("#0b898aff"); + DropShadow shadow = new DropShadow(); + shadow.setRadius(10); + shadow.setColor(color); + + var loadingAnimation = new AnimationTimer() { + + long offset; + + @Override + public void handle(long now) { + // Increment offset as we are always having 60fps + // Prevents animation jumps when the animation timer isn't called for a long time + offset += 1000 / 60; + + // Move shadow in a circle + var rad = -(offset % 1000.0) / 1000.0 * 2 * Math.PI; + var x = Math.sin(rad); + var y = Math.cos(rad); + shadow.setOffsetX(x * 3); + shadow.setOffsetY(y * 3); + } + }; + + loadingIcon.setEffect(shadow); + loadingAnimation.start(); // This allows for assigning logos even if AppImages has not been initialized yet var dir = "img/logo/"; AppResources.with(AppResources.XPIPE_MODULE, dir, path -> { - loadingIcon.setImage(AppImages.loadImage(path.resolve("loading.png"))); + var image = AppPrefs.get() != null + && AppPrefs.get().theme().getValue().isDark() + ? path.resolve("loading-dark.png") + : path.resolve("loading.png"); + loadingIcon.setImage(AppImages.loadImage(image)); }); var version = new LabelComp((AppProperties.get().isStaging() ? "XPipe PTB" : "XPipe") + " " + AppProperties.get().getVersion()); version.apply(struc -> { - AppFontSizes.xxl(struc.get()); - struc.get().setOpacity(0.6); + AppFontSizes.apply(struc.get(), appFontSizes -> "15"); + struc.get().setOpacity(0.65); }); var text = new LabelComp(AppMainWindow.getLoadingText()); @@ -84,12 +111,13 @@ public class AppMainWindowContentComp extends SimpleComp { if (struc != null) { TrackEvent.info("Window content node set"); PlatformThread.runNestedLoopIteration(); - anim.stop(); struc.prepareAddition(); pane.getChildren().add(struc.get()); + sidebarPresent.set(true); PlatformThread.runNestedLoopIteration(); pane.getStyleClass().remove("background"); pane.getChildren().remove(vbox); + loadingAnimation.stop(); struc.show(); TrackEvent.info("Window content node shown"); } @@ -111,7 +139,18 @@ public class AppMainWindowContentComp extends SimpleComp { return pane; }); + var modal = new ModalOverlayStackComp(bg, overlay); - return modal.createRegion(); + var r = modal.createRegion(); + var p = r.lookupAll(".modal-overlay-stack-element"); + sidebarPresent.subscribe(v -> { + if (v) { + p.forEach(node -> { + node.pseudoClassStateChanged(PseudoClass.getPseudoClass("loaded"), true); + }); + } + }); + + return r; } } diff --git a/app/src/main/java/io/xpipe/app/comp/base/ChoiceComp.java b/app/src/main/java/io/xpipe/app/comp/base/ChoiceComp.java index 5c44efe63..4983c22f7 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/ChoiceComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/ChoiceComp.java @@ -35,12 +35,6 @@ public class ChoiceComp extends Comp>> { this.includeNone = includeNone; } - public ChoiceComp(Property value, ObservableValue>> range, boolean includeNone) { - this.value = value; - this.range = PlatformThread.sync(range); - this.includeNone = includeNone; - } - public static ChoiceComp ofTranslatable( Property value, List range, boolean includeNone) { var map = range.stream() diff --git a/app/src/main/java/io/xpipe/app/comp/base/ChoicePaneComp.java b/app/src/main/java/io/xpipe/app/comp/base/ChoicePaneComp.java index 0838fec7a..1b191945e 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/ChoicePaneComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/ChoicePaneComp.java @@ -61,6 +61,12 @@ public class ChoicePaneComp extends Comp> { var vbox = new VBox(transformer.apply(cb)); vbox.setFillWidth(true); + vbox.focusedProperty().addListener((observable, oldValue, newValue) -> { + if (newValue) { + vbox.getChildren().getFirst().requestFocus(); + } + }); + cb.prefWidthProperty().bind(vbox.widthProperty()); cb.valueProperty().subscribe(n -> { if (n == null) { diff --git a/app/src/main/java/io/xpipe/app/comp/base/ContextualFileReferenceChoiceComp.java b/app/src/main/java/io/xpipe/app/comp/base/ContextualFileReferenceChoiceComp.java index 629e1fc99..0f8569619 100644 --- a/app/src/main/java/io/xpipe/app/comp/base/ContextualFileReferenceChoiceComp.java +++ b/app/src/main/java/io/xpipe/app/comp/base/ContextualFileReferenceChoiceComp.java @@ -5,10 +5,9 @@ import io.xpipe.app.comp.Comp; import io.xpipe.app.comp.CompStructure; import io.xpipe.app.comp.SimpleCompStructure; import io.xpipe.app.core.AppLayoutModel; -import io.xpipe.app.core.window.AppWindowHelper; +import io.xpipe.app.core.window.AppDialog; import io.xpipe.app.ext.ProcessControlProvider; -import io.xpipe.app.ext.ShellStore; -import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.app.issue.ErrorEventFactory; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.storage.ContextualFileReference; import io.xpipe.app.storage.DataStorageSyncHandler; @@ -73,7 +72,9 @@ public class ContextualFileReferenceChoiceComp extends Comp> var replacement = ProcessControlProvider.get().replace(fileSystem.getValue()); BrowserFileChooserSessionComp.openSingleFile( () -> replacement, - () -> filePath.getValue() != null ? filePath.getValue().getParent() : null, + () -> filePath.getValue() != null + ? filePath.getValue().getParent() + : null, fileStore -> { if (fileStore != null) { filePath.setValue(fileStore.getPath()); @@ -104,34 +105,33 @@ public class ContextualFileReferenceChoiceComp extends Comp> try { var source = currentPath.asLocalPath(); if (!Files.exists(source)) { - ErrorEvent.fromMessage("Unable to resolve local file path " + source).expected().handle(); + ErrorEventFactory.fromMessage("Unable to resolve local file path " + source) + .expected() + .handle(); return; } var target = sync.getTargetLocation().apply(source); - var shouldCopy = AppWindowHelper.showConfirmationAlert( - "confirmGitShareTitle", "confirmGitShareHeader", "confirmGitShareContent"); + var shouldCopy = AppDialog.confirm("confirmGitShare"); if (!shouldCopy) { return; } var handler = DataStorageSyncHandler.getInstance(); - var syncedTarget = handler.addDataFile( - source, target, sync.getPerUser().test(source)); + var syncedTarget = + handler.addDataFile(source, target, sync.getPerUser().test(source)); var pubSource = Path.of(source + ".pub"); if (Files.exists(pubSource)) { var pubTarget = sync.getTargetLocation().apply(pubSource); - handler - .addDataFile( - pubSource, pubTarget, sync.getPerUser().test(pubSource)); + handler.addDataFile(pubSource, pubTarget, sync.getPerUser().test(pubSource)); } Platform.runLater(() -> { filePath.setValue(FilePath.of(syncedTarget)); }); } catch (Exception e) { - ErrorEvent.fromThrowable(e).handle(); + ErrorEventFactory.fromThrowable(e).handle(); } }); gitShareButton.tooltipKey("gitShareFileTooltip"); diff --git a/app/src/main/java/io/xpipe/app/comp/base/DropdownComp.java b/app/src/main/java/io/xpipe/app/comp/base/DropdownComp.java deleted file mode 100644 index 524c5ac0e..000000000 --- a/app/src/main/java/io/xpipe/app/comp/base/DropdownComp.java +++ /dev/null @@ -1,65 +0,0 @@ -package io.xpipe.app.comp.base; - -import io.xpipe.app.comp.Comp; -import io.xpipe.app.comp.CompStructure; -import io.xpipe.app.comp.SimpleCompStructure; -import io.xpipe.app.comp.augment.ContextMenuAugment; -import io.xpipe.app.util.ContextMenuHelper; - -import javafx.beans.binding.Bindings; -import javafx.beans.value.ObservableValue; -import javafx.css.Size; -import javafx.css.SizeUnits; -import javafx.scene.control.Button; -import javafx.scene.control.MenuItem; - -import org.kordamp.ikonli.javafx.FontIcon; - -import java.util.List; - -public class DropdownComp extends Comp> { - - private final List> items; - - public DropdownComp(List> items) { - this.items = items; - } - - @Override - public CompStructure