diff --git a/app/gui2/env.d.ts b/app/gui2/env.d.ts index 271c5d0b83b..ee4f82abcc7 100644 --- a/app/gui2/env.d.ts +++ b/app/gui2/env.d.ts @@ -23,8 +23,11 @@ interface Window { * interface. */ interface FileBrowserApi { - /** Select path for local file or directory using the system file browser. */ + /** + * Select path for local file or directory using the system file browser. + * 'filePath' is same as 'file', but allows picking non-existing files. + */ readonly openFileBrowser: ( - kind: 'file' | 'directory' | 'default', + kind: 'file' | 'directory' | 'default' | 'filePath', ) => Promise } diff --git a/app/gui2/src/components/GraphEditor/widgets/WidgetFileBrowser.vue b/app/gui2/src/components/GraphEditor/widgets/WidgetFileBrowser.vue index 5a8b85e154f..f5ecf96e135 100644 --- a/app/gui2/src/components/GraphEditor/widgets/WidgetFileBrowser.vue +++ b/app/gui2/src/components/GraphEditor/widgets/WidgetFileBrowser.vue @@ -26,9 +26,30 @@ const insertAsFileConstructor = computed(() => { return false } }) -const strictlyFile = computed(() => props.input.dynamicConfig?.kind === 'File_Browse') -const strictlyDirectory = computed(() => props.input.dynamicConfig?.kind === 'Folder_Browse') -const label = computed(() => (strictlyDirectory.value ? 'Choose directory…' : 'Choose file…')) +const dialogKind = computed(() => { + switch (props.input.dynamicConfig?.kind) { + case 'File_Browse': + return props.input.dynamicConfig.existing_only ? 'file' : 'filePath' + case 'Folder_Browse': + return 'directory' + default: + if (props.input[ArgumentInfoKey]?.info?.reprType.includes(WRITABLE_FILE_TYPE)) { + return 'filePath' + } else { + return 'default' + } + } +}) +const label = computed(() => { + switch (dialogKind.value) { + case 'directory': + return 'Choose directory…' + case 'filePath': + return 'Choose path…' + default: + return 'Choose file…' + } +}) const FILE_CONSTRUCTOR = FILE_TYPE + '.new' const FILE_SHORT_CONSTRUCTOR = 'File.new' @@ -54,11 +75,7 @@ function makeValue(edit: Ast.MutableModule, useFileConstructor: boolean, path: s } const onClick = async () => { - const kind = - strictlyDirectory.value ? 'directory' - : strictlyFile.value ? 'file' - : 'default' - const selected = await window.fileBrowserApi.openFileBrowser(kind) + const selected = await window.fileBrowserApi.openFileBrowser(dialogKind.value) if (selected != null && selected[0] != null) { const edit = graph.startEdit() const value = makeValue(edit, insertAsFileConstructor.value, selected[0]) @@ -90,6 +107,8 @@ const innerWidgetInput = computed(() => { const TEXT_TYPE = 'Standard.Base.Data.Text.Text' const FILE_MODULE = 'Standard.Base.System.File' const FILE_TYPE = FILE_MODULE + '.File' +const WRITABLE_FILE_MODULE = 'Standard.Base.System.File.Generic.Writable_File' +const WRITABLE_FILE_TYPE = WRITABLE_FILE_MODULE + '.Writable_File' export const widgetDefinition = defineWidget( WidgetInput.isAstOrPlaceholder, @@ -101,7 +120,9 @@ export const widgetDefinition = defineWidget( props.input.dynamicConfig?.kind === 'Folder_Browse' ) return Score.Perfect - if (props.input[ArgumentInfoKey]?.info?.reprType.includes(FILE_TYPE)) return Score.Perfect + const reprType = props.input[ArgumentInfoKey]?.info?.reprType + if (reprType?.includes(FILE_TYPE) || reprType?.includes(WRITABLE_FILE_TYPE)) + return Score.Perfect return Score.Mismatch }, }, diff --git a/app/gui2/src/components/GraphEditor/widgets/WidgetFunctionName.vue b/app/gui2/src/components/GraphEditor/widgets/WidgetFunctionName.vue index f7b7dbc7dcf..484a3d07c1c 100644 --- a/app/gui2/src/components/GraphEditor/widgets/WidgetFunctionName.vue +++ b/app/gui2/src/components/GraphEditor/widgets/WidgetFunctionName.vue @@ -2,12 +2,11 @@ import AutoSizedInput from '@/components/widgets/AutoSizedInput.vue' import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry' import { useProjectStore } from '@/stores/project' -import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry' import { Ast } from '@/util/ast' import { Err, Ok, type Result } from '@/util/data/result' import { useToast } from '@/util/toast' import { PropertyAccess } from 'shared/ast' -import type { ExpressionId, MethodPointer } from 'shared/languageServerTypes' +import type { ExpressionId } from 'shared/languageServerTypes' import { computed, ref, watchEffect } from 'vue' import NodeWidget from '../NodeWidget.vue' diff --git a/app/gui2/src/providers/widgetRegistry/configuration.ts b/app/gui2/src/providers/widgetRegistry/configuration.ts index 12afc39cd25..323b939e7b0 100644 --- a/app/gui2/src/providers/widgetRegistry/configuration.ts +++ b/app/gui2/src/providers/widgetRegistry/configuration.ts @@ -106,6 +106,7 @@ export interface FolderBrowse { export interface FileBrowse { kind: 'File_Browse' + existing_only?: boolean | undefined } export interface SingleChoice { @@ -143,6 +144,7 @@ export const widgetConfigurationSchema: z.ZodType< z.ZodTypeDef, any > = withKindSchema.pipe( + /* eslint-disable camelcase */ z.discriminatedUnion('kind', [ z .object({ @@ -154,10 +156,8 @@ export const widgetConfigurationSchema: z.ZodType< z .object({ kind: z.literal('Vector_Editor'), - /* eslint-disable camelcase */ item_editor: z.lazy(() => widgetConfigurationSchema), item_default: z.string(), - /* eslint-enable camelcase */ }) .merge(withDisplay), z @@ -178,7 +178,10 @@ export const widgetConfigurationSchema: z.ZodType< .merge(withDisplay), z.object({ kind: z.literal('Text_Input') }).merge(withDisplay), z.object({ kind: z.literal('Folder_Browse') }).merge(withDisplay), - z.object({ kind: z.literal('File_Browse') }).merge(withDisplay), + z + .object({ kind: z.literal('File_Browse'), existing_only: z.boolean().optional() }) + .merge(withDisplay), + /* eslint-enable camelcase */ ]), ) diff --git a/app/ide-desktop/lib/client/src/index.ts b/app/ide-desktop/lib/client/src/index.ts index 096ad77acae..3c9d7fab210 100644 --- a/app/ide-desktop/lib/client/src/index.ts +++ b/app/ide-desktop/lib/client/src/index.ts @@ -445,24 +445,36 @@ class App { ) electron.ipcMain.handle( ipc.Channel.openFileBrowser, - async (_event, kind: 'default' | 'directory' | 'file') => { + async (_event, kind: 'default' | 'directory' | 'file' | 'filePath') => { logger.log('Request for opening browser for ', kind) - /** Helper for `showOpenDialog`, which has weird types by default. */ - type Properties = ('openDirectory' | 'openFile')[] - const properties: Properties = - kind === 'file' - ? ['openFile'] - : kind === 'directory' - ? ['openDirectory'] - : process.platform === 'darwin' - ? ['openFile', 'openDirectory'] - : ['openFile'] - const { canceled, filePaths } = await electron.dialog.showOpenDialog({ properties }) - if (!canceled) { - return filePaths + let retval = null + if (kind === 'filePath') { + // "Accept", as the file won't be created immediately. + const { canceled, filePath } = await electron.dialog.showSaveDialog({ + buttonLabel: 'Accept', + }) + if (!canceled) { + retval = [filePath] + } } else { - return null + /** Helper for `showOpenDialog`, which has weird types by default. */ + type Properties = ('openDirectory' | 'openFile')[] + const properties: Properties = + kind === 'file' + ? ['openFile'] + : kind === 'directory' + ? ['openDirectory'] + : process.platform === 'darwin' + ? ['openFile', 'openDirectory'] + : ['openFile'] + const { canceled, filePaths } = await electron.dialog.showOpenDialog({ + properties, + }) + if (!canceled) { + retval = filePaths + } } + return retval } ) diff --git a/app/ide-desktop/lib/client/src/preload.ts b/app/ide-desktop/lib/client/src/preload.ts index 675a4af965f..edc2d50b189 100644 --- a/app/ide-desktop/lib/client/src/preload.ts +++ b/app/ide-desktop/lib/client/src/preload.ts @@ -173,7 +173,7 @@ const AUTHENTICATION_API = { electron.contextBridge.exposeInMainWorld(AUTHENTICATION_API_KEY, AUTHENTICATION_API) const FILE_BROWSER_API = { - openFileBrowser: (kind: 'any' | 'directory' | 'file') => + openFileBrowser: (kind: 'any' | 'directory' | 'file' | 'filePath') => electron.ipcRenderer.invoke(ipc.Channel.openFileBrowser, kind), } electron.contextBridge.exposeInMainWorld(FILE_BROWSER_API_KEY, FILE_BROWSER_API) diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Metadata.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Metadata.enso index b6b3704dce1..419b2de2efe 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Metadata.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Metadata.enso @@ -76,7 +76,7 @@ type Widget Folder_Browse label:(Nothing | Text)=Nothing display:Display=Display.When_Modified ## Describes a file chooser. - File_Browse label:(Nothing | Text)=Nothing display:Display=Display.When_Modified action:File_Action=File_Action.Open file_types:(Vector Pair)=[Pair.new "All Files" "*.*"] + File_Browse existing_only:Boolean=True label:(Nothing | Text)=Nothing display:Display=Display.When_Modified action:File_Action=File_Action.Open file_types:(Vector Pair)=[Pair.new "All Files" "*.*"] ## PRIVATE make_single_choice : Vector -> Display -> Widget diff --git a/distribution/lib/Standard/Table/0.0.0-dev/src/Table.enso b/distribution/lib/Standard/Table/0.0.0-dev/src/Table.enso index d89f2c2bda7..87bb2017aac 100644 --- a/distribution/lib/Standard/Table/0.0.0-dev/src/Table.enso +++ b/distribution/lib/Standard/Table/0.0.0-dev/src/Table.enso @@ -2670,7 +2670,7 @@ type Table from Standard.Table import all example_to_xlsx = Examples.inventory_table.write (enso_project.data / "example_xlsx_output.xlsx") (Excel_Format.Sheet "MySheetName") - @path (Widget.File_Browse display=Display.Always) + @path (Widget.File_Browse existing_only=False display=Display.Always) @format Widget_Helpers.write_table_selector write : Writable_File -> File_Format -> Existing_File_Behavior -> Match_Columns -> Problem_Behavior -> File ! Column_Count_Mismatch | Illegal_Argument | File_Error write self path:Writable_File format=Auto_Detect on_existing_file=Existing_File_Behavior.Backup match_columns=Match_Columns.By_Name on_problems=Report_Warning =