Eng 510 open with in context menu (#803)

* somewhat

* proper macos support

* formatting
This commit is contained in:
Brendan Allan 2023-05-08 17:22:24 +08:00 committed by GitHub
parent 207c82bfeb
commit 05ff5ed800
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 278 additions and 48 deletions

9
Cargo.lock generated
View file

@ -4620,9 +4620,9 @@ checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "nom"
version = "7.1.1"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
@ -6815,6 +6815,7 @@ dependencies = [
name = "sd-desktop-macos"
version = "0.1.0"
dependencies = [
"serde",
"swift-rs",
]
@ -7760,9 +7761,9 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
[[package]]
name = "swift-rs"
version = "1.0.1"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806ff0904302a8a91644422fcfeee4828c3a9dbcfd1757ba495f1a755c2ac873"
checksum = "05e51d6f2b5fff4808614f429f8a7655ac8bcfe218185413f3a60c508482c2d6"
dependencies = [
"base64 0.21.0",
"serde",

View file

@ -33,7 +33,7 @@ rspc = { version = "0.1.4" }
specta = { version = "1.0.3" }
httpz = { version = "0.0.3" }
swift-rs = { version = "1.0.1" }
swift-rs = { version = "1.0.5" }
tokio = { version = "1.25.0" }

View file

@ -6,7 +6,8 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
swift-rs.workspace = true
swift-rs = { workspace = true, features = ["serde"] }
serde = { version = "1.0" }
[build-dependencies]
swift-rs = { workspace = true, features = ["build"] }

View file

@ -5,9 +5,9 @@
"package": "SwiftRs",
"repositoryURL": "https://github.com/brendonovich/swift-rs",
"state": {
"branch": null,
"revision": "cbb9b96b6036108e76879713e910c05bc9e145c7",
"version": "1.0.1"
"branch": "specta",
"revision": "dbefee04115083ad283d1640cdceca3036c41042",
"version": null
}
}
]

View file

@ -18,7 +18,7 @@ let package = Package(
],
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/brendonovich/swift-rs", from: "1.0.1"),
.package(url: "https://github.com/brendonovich/swift-rs", branch: "specta"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.

View file

@ -0,0 +1,69 @@
import AppKit
import SwiftRs
class OpenWithApplication: NSObject {
var name: SRString;
var id: SRString;
var url: SRString;
init(name: SRString, id: SRString, url: SRString) {
self.name = name
self.id = id
self.url = url
}
}
@_cdecl("get_open_with_applications")
func getOpenWithApplications(urlString: SRString) -> SRObjectArray {
let url: URL;
if #available(macOS 13.0, *) {
url = URL(filePath: urlString.toString())
} else {
// Fallback on earlier versions
url = URL(fileURLWithPath: urlString.toString())
}
if #available(macOS 12.0, *) {
return SRObjectArray(NSWorkspace.shared.urlsForApplications(toOpen: url)
.compactMap { url in
Bundle(url: url)?.infoDictionary.map { ($0, url) }
}
.compactMap { (dict, url) in
let name = SRString((dict["CFBundleDisplayName"] ?? dict["CFBundleName"]) as! String);
if !url.path.contains("/Applications/") {
return nil
}
return OpenWithApplication(
name: name,
id: SRString(dict["CFBundleIdentifier"] as! String),
url: SRString(url.path)
)
})
} else {
// Fallback on earlier versions
return SRObjectArray([])
}
}
@_cdecl("open_file_path_with")
func openFilePathWith(fileUrl: SRString, withUrl: SRString) {
let config = NSWorkspace.OpenConfiguration();
let at = URL(fileURLWithPath: withUrl.toString());
print(at);
NSWorkspace.shared.open(
[URL(fileURLWithPath: fileUrl.toString())],
withApplicationAt: at,
configuration: config
)
// NSWorkspace.shared.openApplication(at: at, configuration: config) { (app, err) in
// print(app)
// print(err)
// }
}

View file

@ -15,3 +15,13 @@ swift!(pub fn blur_window_background(window: &NSObject));
swift!(pub fn set_titlebar_style(window: &NSObject, transparent: Bool, large: Bool));
swift!(pub fn reload_webview(webview: &NSObject));
#[repr(C)]
pub struct OpenWithApplication {
pub name: SRString,
pub id: SRString,
pub url: SRString,
}
swift!(pub fn get_open_with_applications(url: &SRString) -> SRObjectArray<OpenWithApplication>);
swift!(pub fn open_file_path_with(file_url: &SRString, with_url: &SRString));

View file

@ -37,3 +37,74 @@ pub async fn open_file_path(
Ok(res)
}
#[derive(Type, serde::Serialize)]
pub struct OpenWithApplication {
name: String,
url: String,
}
#[tauri::command(async)]
#[specta::specta]
pub async fn get_file_path_open_with_apps(
library: uuid::Uuid,
id: i32,
node: tauri::State<'_, Arc<Node>>,
) -> Result<Vec<OpenWithApplication>, ()> {
let Some(library) = node.library_manager.get_library(library).await else {
return Err(())
};
let Ok(Some(path)) = library
.get_file_path(id)
.await
else {
return Err(())
};
#[cfg(target_os = "macos")]
let apps = {
unsafe { sd_desktop_macos::get_open_with_applications(&path.to_str().unwrap().into()) }
.as_slice()
.into_iter()
.map(|app| OpenWithApplication {
name: app.name.to_string(),
url: app.url.to_string(),
})
.collect()
};
#[cfg(not(target_os = "macos"))]
return Err(());
Ok(apps)
}
#[tauri::command(async)]
#[specta::specta]
pub async fn open_file_path_with(
library: uuid::Uuid,
id: i32,
with_url: String,
node: tauri::State<'_, Arc<Node>>,
) -> Result<(), ()> {
let Some(library) = node.library_manager.get_library(library).await else {
return Err(())
};
let Ok(Some(path)) = library
.get_file_path(id)
.await
else {
return Err(())
};
unsafe {
sd_desktop_macos::open_file_path_with(
&path.to_str().unwrap().into(),
&with_url.as_str().into(),
)
};
Ok(())
}

View file

@ -121,7 +121,12 @@ async fn main() -> tauri::Result<()> {
})
.on_menu_event(menu::handle_menu_event)
.menu(menu::get_menu())
.invoke_handler(tauri_handlers![app_ready, file::open_file_path])
.invoke_handler(tauri_handlers![
app_ready,
file::open_file_path,
file::get_file_path_open_with_apps,
file::open_file_path_with
])
.build(tauri::generate_context!())?;
app.run(move |app_handler, event| {

View file

@ -17,7 +17,7 @@ import {
} from '@sd/interface';
import { getSpacedropState } from '@sd/interface/hooks/useSpacedropState';
import '@sd/ui/style';
import { appReady, openFilePath } from './commands';
import { appReady, getFilePathOpenWithApps, openFilePath, openFilePathWith } from './commands';
// TODO: Bring this back once upstream is fixed up.
// const client = hooks.createClient({
@ -73,7 +73,9 @@ const platform: Platform = {
saveFilePickerDialog: () => dialog.save(),
showDevtools: () => invoke('show_devtools'),
openPath: (path) => shell.open(path),
openFilePath
openFilePath,
getFilePathOpenWithApps,
openFilePathWith
};
const queryClient = new QueryClient();

View file

@ -16,4 +16,13 @@ export function openFilePath(library: string, id: number) {
return invoke<OpenFilePathResult>("open_file_path", { library,id })
}
export function getFilePathOpenWithApps(library: string, id: number) {
return invoke<OpenWithApplication[]>("get_file_path_open_with_apps", { library,id })
}
export function openFilePathWith(library: string, id: number, withUrl: string) {
return invoke<null>("open_file_path_with", { library,id,withUrl })
}
export type OpenWithApplication = { name: string; url: string }
export type OpenFilePathResult = { t: "NoLibrary" } | { t: "NoFile" } | { t: "OpenError"; c: string } | { t: "AllGood" }

1
core/bruh.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -2,6 +2,7 @@ use crate::prisma::*;
use std::{collections::HashMap, sync::Arc};
use prisma_client_rust::Direction;
use sd_sync::*;
use serde_json::{from_value, json, to_vec, Value};
@ -160,9 +161,7 @@ impl SyncManager {
.db
.shared_operation()
.find_many(vec![])
.order_by(shared_operation::timestamp::order(
prisma_client_rust::Direction::Asc,
))
.order_by(shared_operation::timestamp::order(Direction::Asc))
.include(shared_operation::include!({ node: select {
pub_id
} }))

View file

@ -1,3 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import clsx from 'clsx';
import {
ArrowBendUpRight,
@ -13,9 +14,10 @@ import {
Trash,
TrashSimple
} from 'phosphor-react';
import { PropsWithChildren } from 'react';
import { PropsWithChildren, Suspense } from 'react';
import {
ExplorerItem,
FilePath,
isObject,
useLibraryContext,
useLibraryMutation,
@ -25,10 +27,11 @@ import { ContextMenu, dialogManager } from '@sd/ui';
import { showAlertDialog } from '~/components/AlertDialog';
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
import { usePlatform } from '~/util/Platform';
import { Platform, usePlatform } from '~/util/Platform';
import AssignTagMenuItems from '../AssignTagMenuItems';
import { OpenInNativeExplorer } from '../ContextMenu';
import { getItemFilePath, useExplorerSearchParams } from '../util';
import OpenWith from './ContextMenu/OpenWith';
import DecryptDialog from './DecryptDialog';
import DeleteDialog from './DeleteDialog';
import EncryptDialog from './EncryptDialog';
@ -253,11 +256,10 @@ export default ({ data, className, ...props }: Props) => {
const OpenOrDownloadOptions = (props: { data: ExplorerItem }) => {
const os = useOperatingSystem();
const platform = usePlatform();
const { openFilePath } = usePlatform();
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
const filePath = getItemFilePath(props.data);
const openFilePath = platform.openFilePath;
const { library } = useLibraryContext();
@ -265,24 +267,28 @@ const OpenOrDownloadOptions = (props: { data: ExplorerItem }) => {
else
return (
<>
{filePath && openFilePath && (
<ContextMenu.Item
label="Open"
keybind="⌘O"
onClick={() => {
props.data.type === 'Path' &&
props.data.item.object_id &&
updateAccessTime.mutate(props.data.item.object_id);
openFilePath(library.uuid, filePath.id);
}}
/>
{filePath && (
<>
{openFilePath && (
<ContextMenu.Item
label="Open"
keybind="⌘O"
onClick={() => {
props.data.type === 'Path' &&
props.data.item.object_id &&
updateAccessTime.mutate(props.data.item.object_id);
openFilePath(library.uuid, filePath.id);
}}
/>
)}
<OpenWith filePath={filePath} />
</>
)}
<ContextMenu.Item
label="Quick view"
keybind="␣"
onClick={() => (getExplorerStore().quickViewObject = props.data)}
/>
<ContextMenu.Item label="Open with..." keybind="⌘^O" />
</>
);
};

View file

@ -0,0 +1,54 @@
import { useQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
import { FilePath, useLibraryContext } from '@sd/client';
import { ContextMenu } from '@sd/ui';
import { Platform, usePlatform } from '~/util/Platform';
export default (props: { filePath: FilePath }) => {
const { getFilePathOpenWithApps, openFilePathWith } = usePlatform();
if (!getFilePathOpenWithApps || !openFilePathWith) return null;
return (
<ContextMenu.SubMenu label="Open with">
<Suspense>
<Items
filePath={props.filePath}
actions={{
getFilePathOpenWithApps,
openFilePathWith
}}
/>
</Suspense>
</ContextMenu.SubMenu>
);
};
const Items = ({
filePath,
actions
}: {
filePath: FilePath;
actions: Required<Pick<Platform, 'getFilePathOpenWithApps' | 'openFilePathWith'>>;
}) => {
const { library } = useLibraryContext();
const items = useQuery<any[]>(
['openWith', filePath.id],
() => actions.getFilePathOpenWithApps(library.uuid, filePath.id),
{ suspense: true }
);
return (
<>
{items.data?.map((d) => (
<ContextMenu.Item
key={d.name}
onClick={() => actions.openFilePathWith(library.uuid, filePath.id, d.url)}
>
{d.name}
</ContextMenu.Item>
))}
</>
);
};

View file

@ -19,6 +19,8 @@ export type Platform = {
openPath?(path: string): void;
// Opens a file path with a given ID
openFilePath?(library: string, id: number): any;
getFilePathOpenWithApps?(library: string, id: number): any;
openFilePathWith?(library: string, id: number, appUrl: string): any;
};
// Keep this private and use through helpers below

View file

@ -118,8 +118,6 @@ export type EncryptedKey = number[]
export type PeerId = string
export type Location = { id: number; pub_id: number[]; node_id: number; name: string; path: string; total_capacity: number | null; available_capacity: number | null; is_archived: boolean; generate_preview_media: boolean; sync_preview_media: boolean; hidden: boolean; date_created: string }
export type GenerateThumbsForLocationArgs = { id: number; path: string }
export type LibraryConfigWrapped = { uuid: string; config: LibraryConfig }
@ -131,6 +129,8 @@ export type LibraryConfigWrapped = { uuid: string; config: LibraryConfig }
*/
export type Params = "Standard" | "Hardened" | "Paranoid"
export type Tag = { id: number; pub_id: number[]; name: string | null; color: string | null; total_objects: number | null; redundancy_goal: number | null; date_created: string; date_modified: string }
/**
* `LocationUpdateArgs` is the argument received from the client using `rspc` to update a location.
* It contains the id of the location to be updated, possible a name to change the current location's name
@ -158,6 +158,8 @@ export type StoredKey = { uuid: string; version: StoredKeyVersion; key_type: Sto
export type OnboardingConfig = { password: Protected<string>; algorithm: Algorithm; hashing_algorithm: HashingAlgorithm }
export type Object = { id: number; pub_id: number[]; kind: number; key_id: number | null; hidden: boolean; favorite: boolean; important: boolean; has_thumbnail: boolean; has_thumbstrip: boolean; has_video_preview: boolean; ipfs_id: string | null; note: string | null; date_created: string; date_accessed: string | null }
export type Volume = { name: string; mount_point: string; total_capacity: string; available_capacity: string; is_removable: boolean; disk_type: string | null; file_system: string | null; is_root_filesystem: boolean }
/**
@ -185,14 +187,14 @@ export type Nonce = { XChaCha20Poly1305: number[] } | { Aes256Gcm: number[] }
export type UnlockKeyManagerArgs = { password: Protected<string>; secret_key: Protected<string> }
export type MediaData = { id: number; pixel_width: number | null; pixel_height: number | null; longitude: number | null; latitude: number | null; fps: number | null; capture_device_make: string | null; capture_device_model: string | null; capture_device_software: string | null; duration_seconds: number | null; codecs: string | null; streams: number | null }
export type TagCreateArgs = { name: string; color: string }
export type FileEncryptorJobInit = { location_id: number; path_id: number; key_uuid: string; algorithm: Algorithm; metadata: boolean; preview_media: boolean; output_path: string | null }
export type InvalidateOperationEvent = { key: string; arg: any; result: any | null }
export type Location = { id: number; pub_id: number[]; node_id: number; name: string; path: string; total_capacity: number | null; available_capacity: number | null; is_archived: boolean; generate_preview_media: boolean; sync_preview_media: boolean; hidden: boolean; date_created: string }
export type GetArgs = { id: number }
export type CRDTOperation = { node: string; timestamp: number; id: string; typ: CRDTOperationType }
@ -206,14 +208,10 @@ export type Salt = number[]
export type TagUpdateArgs = { id: number; name: string | null; color: string | null }
export type Node = { id: number; pub_id: number[]; name: string; platform: number; version: string | null; last_seen: string; timezone: string | null; date_created: string }
export type FileCutterJobInit = { source_location_id: number; source_path_id: number; target_location_id: number; target_path: string }
export type FilePathWithObject = { id: number; pub_id: number[]; is_dir: boolean; cas_id: string | null; integrity_checksum: string | null; location_id: number; materialized_path: string; name: string; extension: string; size_in_bytes: string; inode: number[]; device: number[]; object_id: number | null; parent_id: number[] | null; key_id: number | null; date_created: string; date_modified: string; date_indexed: string; object: Object | null }
export type IndexerRule = { id: number; kind: number; name: string; default: boolean; parameters: number[]; date_created: string; date_modified: string }
export type JobStatus = "Queued" | "Running" | "Completed" | "Canceled" | "Failed" | "Paused"
export type ObjectValidatorArgs = { id: number; path: string }
@ -224,23 +222,23 @@ export type LocationExplorerArgs = { location_id: number; path?: string | null;
export type TagAssignArgs = { object_id: number; tag_id: number; unassign: boolean }
export type MediaData = { id: number; pixel_width: number | null; pixel_height: number | null; longitude: number | null; latitude: number | null; fps: number | null; capture_device_make: string | null; capture_device_model: string | null; capture_device_software: string | null; duration_seconds: number | null; codecs: string | null; streams: number | null }
export type FileDeleterJobInit = { location_id: number; path_id: number }
export type FilePath = { id: number; pub_id: number[]; is_dir: boolean; cas_id: string | null; integrity_checksum: string | null; location_id: number; materialized_path: string; name: string; extension: string; size_in_bytes: string; inode: number[]; device: number[]; object_id: number | null; parent_id: number[] | null; key_id: number | null; date_created: string; date_modified: string; date_indexed: string }
/**
* These are all possible algorithms that can be used for encryption and decryption
*/
export type Algorithm = "XChaCha20Poly1305" | "Aes256Gcm"
export type Tag = { id: number; pub_id: number[]; name: string | null; color: string | null; total_objects: number | null; redundancy_goal: number | null; date_created: string; date_modified: string }
export type JobReport = { id: string; name: string; action: string | null; data: number[] | null; metadata: any | null; is_background: boolean; created_at: string | null; started_at: string | null; completed_at: string | null; parent_id: string | null; status: JobStatus; task_count: number; completed_task_count: number; message: string }
export type OwnedOperationItem = { id: any; data: OwnedOperationData }
export type CRDTOperationType = SharedOperation | RelationOperation | OwnedOperation
export type Statistics = { id: number; date_captured: string; total_object_count: number; library_db_size: string; total_bytes_used: string; total_bytes_capacity: string; total_unique_bytes: string; total_bytes_free: string; preview_media_bytes: string }
/**
* TODO: P2P event for the frontend
*/
@ -248,8 +246,6 @@ export type P2PEvent = { type: "DiscoveredPeer"; peer_id: PeerId; metadata: Peer
export type SpacedropArgs = { peer_id: PeerId; file_path: string[] }
export type FilePath = { id: number; pub_id: number[]; is_dir: boolean; cas_id: string | null; integrity_checksum: string | null; location_id: number; materialized_path: string; name: string; extension: string; size_in_bytes: string; inode: number[]; device: number[]; object_id: number | null; parent_id: number[] | null; key_id: number | null; date_created: string; date_modified: string; date_indexed: string }
export type NodeState = ({ id: string; name: string; p2p_port: number | null; p2p_email: string | null; p2p_img_url: string | null }) & { data_path: string }
export type OwnedOperation = { model: string; items: OwnedOperationItem[] }
@ -258,6 +254,8 @@ export type SharedOperation = { record_id: any; model: string; data: SharedOpera
export type RelationOperationData = "Create" | { Update: { field: string; value: any } } | "Delete"
export type Node = { id: number; pub_id: number[]; name: string; platform: number; version: string | null; last_seen: string; timezone: string | null; date_created: string }
export type SharedOperationCreateData = { u: { [key: string]: any } } | "a"
export type KeyAddArgs = { algorithm: Algorithm; hashing_algorithm: HashingAlgorithm; key: Protected<string>; library_sync: boolean; automount: boolean }
@ -292,8 +290,6 @@ export type FileCopierJobInit = { source_location_id: number; source_path_id: nu
export type ChangeNodeNameArgs = { name: string }
export type Object = { id: number; pub_id: number[]; kind: number; key_id: number | null; hidden: boolean; favorite: boolean; important: boolean; has_thumbnail: boolean; has_thumbstrip: boolean; has_video_preview: boolean; ipfs_id: string | null; note: string | null; date_created: string; date_accessed: string | null }
/**
* This defines all available password hashing algorithms.
*/
@ -316,10 +312,14 @@ export type AutomountUpdateArgs = { uuid: string; status: boolean }
export type Protected<T> = T
export type Statistics = { id: number; date_captured: string; total_object_count: number; library_db_size: string; total_bytes_used: string; total_bytes_capacity: string; total_unique_bytes: string; total_bytes_free: string; preview_media_bytes: string }
export type LightScanArgs = { location_id: number; sub_path: string }
export type RestoreBackupArgs = { password: Protected<string>; secret_key: Protected<string>; path: string }
export type IndexerRule = { id: number; kind: number; name: string; default: boolean; parameters: number[]; date_created: string; date_modified: string }
export type ObjectWithFilePaths = { id: number; pub_id: number[]; kind: number; key_id: number | null; hidden: boolean; favorite: boolean; important: boolean; has_thumbnail: boolean; has_thumbstrip: boolean; has_video_preview: boolean; ipfs_id: string | null; note: string | null; date_created: string; date_accessed: string | null; file_paths: FilePath[] }
export type RelationOperation = { relation_item: string; relation_group: string; relation: string; data: RelationOperationData }