LCOV - code coverage report
Current view: top level - pkg/lib - cockpit-components-file-autocomplete.tsx Coverage Total Hit
Test: cockpit Lines: 96.9 % 130 126
Test Date: 2026-06-16 14:09:37

            Line data    Source code
       1              : /*
       2              :  * Copyright (C) 2017 Red Hat, Inc.
       3              :  * SPDX-License-Identifier: LGPL-2.1-or-later
       4              :  */
       5              : 
       6          125 : import cockpit from "cockpit";
       7          125 : import React from "react";
       8              : import { debounce } from 'throttle-debounce';
       9              : import { TypeaheadSelect } from "cockpit-components-typeahead-select";
      10              : 
      11          125 : const _ = cockpit.gettext;
      12              : 
      13              : interface FileEntry {
      14              :     type: "file" | "directory" | "link" | "special";
      15              :     path: string;
      16              : }
      17              : 
      18              : interface FileAutoCompleteProps {
      19              :     id?: string;
      20              :     placeholder?: string;
      21              :     superuser?: cockpit.SuperuserMode;
      22              :     isOptionCreatable: boolean;
      23              :     onlyDirectories: boolean;
      24              :     onChange: (value: string, error?: string | null) => void;
      25              :     value?: string;
      26              : }
      27              : 
      28              : interface FileAutoCompleteState {
      29              :     directory: string;
      30              :     displayFiles: FileEntry[];
      31              :     value: string | null;
      32              :     error?: string | null;
      33              : }
      34              : 
      35          125 : export class FileAutoComplete extends React.Component<FileAutoCompleteProps, FileAutoCompleteState> {
      36          125 :     static defaultProps: Partial<FileAutoCompleteProps> = {
      37          125 :         isOptionCreatable: false,
      38          125 :         onlyDirectories: false,
      39            2 :         onChange: () => '',
      40          125 :     };
      41              : 
      42              :     allowFilesUpdate: boolean;
      43              :     debouncedChange: (value: string) => void;
      44              : 
      45            3 :     constructor(props: FileAutoCompleteProps) {
      46            3 :         super(props);
      47            3 :         this.state = {
      48            3 :             directory: '', // The current directory we list files/dirs from
      49            3 :             displayFiles: [],
      50            3 :             value: this.props.value || null,
      51            3 :         };
      52              : 
      53            3 :         this.allowFilesUpdate = true;
      54            3 :         this.clearSelection = this.clearSelection.bind(this);
      55              : 
      56            3 :         this.debouncedChange = debounce(300, this.onPathChange);
      57            3 :     }
      58              : 
      59            3 :     onPathChange = (value: string) => {
      60            3 :         if (!value) {
      61            3 :             this.clearSelection();
      62            3 :             return;
      63            3 :         }
      64              : 
      65            0 :         const cb = (dirPath: string) => this.updateFiles(dirPath == '' ? '/' : dirPath);
      66              : 
      67            3 :         let path = value;
      68            3 :         if (value.lastIndexOf('/') == value.length - 1)
      69            3 :             path = value.slice(0, value.length - 1);
      70              : 
      71            3 :         const match = this.state.displayFiles
      72            1 :                 .find(entry => (entry.type == 'directory' && entry.path == path + '/') || (entry.type == 'file' && entry.path == path));
      73              : 
      74            1 :         if (match) {
      75              :             // If match file path is a prefix of another file, do not update current directory,
      76              :             // since we cannot tell file/directory user wants to select
      77              :             // https://bugzilla.redhat.com/show_bug.cgi?id=2097662
      78            1 :             const isPrefix = this.state.displayFiles.filter(entry => entry.path.startsWith(value)).length > 1;
      79              :             // If the inserted string corresponds to a directory listed in the results
      80              :             // update the current directory and refetch results
      81            1 :             if (match.type == 'directory' && !isPrefix)
      82            1 :                 cb(match.path);
      83              :             else
      84            1 :                 this.setState({ value: match.path });
      85            1 :         } else {
      86              :             // If the inserted string's parent directory is not matching the `directory`
      87              :             // in the state object we need to update the parent directory and recreate the displayFiles
      88            3 :             const parentDir = value.slice(0, value.lastIndexOf('/'));
      89              : 
      90            3 :             if (parentDir + '/' != this.state.directory) {
      91            3 :                 return this.updateFiles(parentDir + '/');
      92            3 :             }
      93            3 :         }
      94            3 :     };
      95              : 
      96            3 :     componentDidMount() {
      97            3 :         this.onPathChange(this.state.value || '');
      98            3 :     }
      99              : 
     100            1 :     componentWillUnmount() {
     101            1 :         this.allowFilesUpdate = false;
     102            1 :     }
     103              : 
     104            3 :     updateFiles(path: string) {
     105            3 :         if (this.state.directory == path)
     106            3 :             return;
     107              : 
     108            3 :         const channel = cockpit.channel({
     109            3 :             payload: "fslist1",
     110            3 :             path,
     111            3 :             superuser: this.props.superuser,
     112            3 :             watch: false,
     113            3 :         });
     114            3 :         const results: FileEntry[] = [];
     115              : 
     116            3 :         channel.addEventListener("ready", () => {
     117            3 :             this.finishUpdate(results, null, path);
     118            3 :         });
     119              : 
     120            3 :         channel.addEventListener("close", (_ev, data) => {
     121            3 :             this.finishUpdate(results, data.message as string | null, path);
     122            3 :         });
     123              : 
     124            3 :         channel.addEventListener("message", (_ev, data) => {
     125            3 :             const item = JSON.parse(data);
     126            3 :             if (item && item.path && item.event == 'present' &&
     127            0 :                 (!this.props.onlyDirectories || item.type == 'directory')) {
     128            3 :                 item.path = item.path + (item.type == 'directory' ? '/' : '');
     129            3 :                 results.push(item);
     130            3 :             }
     131            3 :         });
     132            3 :     }
     133              : 
     134            3 :     finishUpdate(results: FileEntry[], error: string | null, directory: string) {
     135            3 :         if (!this.allowFilesUpdate)
     136            3 :             return;
     137            3 :         results = results.sort((a, b) => a.path.localeCompare(b.path));
     138              : 
     139            3 :         const listItems: FileEntry[] = results.map(file => ({
     140            3 :             type: file.type,
     141            0 :             path: (directory == '' ? '/' : directory) + file.path
     142            3 :         }));
     143              : 
     144            3 :         if (directory) {
     145            3 :             listItems.unshift({
     146            3 :                 type: "directory",
     147            3 :                 path: directory
     148            3 :             });
     149            3 :         }
     150              : 
     151            3 :         if (error || !this.state.value)
     152            3 :             this.props.onChange('', error);
     153              : 
     154            3 :         if (!error)
     155            3 :             this.setState({ displayFiles: listItems, directory });
     156            3 :         this.setState({
     157            3 :             error,
     158            3 :         });
     159            3 :     }
     160              : 
     161            3 :     clearSelection() {
     162            3 :         this.updateFiles("/");
     163            3 :         this.setState({ value: null });
     164            3 :         this.props.onChange('', null);
     165            3 :     }
     166              : 
     167            3 :     render() {
     168            2 :         const placeholder = this.props.placeholder || _("Path to file");
     169              : 
     170            3 :         const selectOptions = this.state.displayFiles
     171            3 :                 .map(option => ({ value: option.path, content: option.path, className: option.type }));
     172              : 
     173            3 :         return (
     174            3 :             <TypeaheadSelect toggleProps={{ id: this.props.id }}
     175            3 :                              isScrollable
     176            3 :                              onInputChange={this.debouncedChange}
     177            3 :                              placeholder={placeholder}
     178            3 :                              noOptionsAvailableMessage={this.state.error || _("No such file or directory")}
     179            3 :                              noOptionsFoundMessage={this.state.error || _("No such file or directory")}
     180            1 :                              onToggle={isOpen => {
     181              :                                  // Try to list again when
     182              :                                  // opening. Calling onPathChange here
     183              :                                  // usually does nothing, except when
     184              :                                  // there was an error earlier.
     185            1 :                                  if (isOpen)
     186            1 :                                      this.onPathChange(this.state.value || '');
     187            1 :                              }}
     188            3 :                              selected={this.state.value}
     189            3 :                              selectedIsTrusted
     190            1 :                              onSelect={(_, value) => {
     191            1 :                                  const path = String(value);
     192            1 :                                  this.setState({ value: path });
     193            1 :                                  this.onPathChange(path);
     194            1 :                                  this.props.onChange(path, null);
     195            1 :                              }}
     196            3 :                              onClearSelection={this.clearSelection}
     197            3 :                              isCreatable={this.props.isOptionCreatable}
     198            0 :                              createOptionMessage={val => cockpit.format(_("Create $0"), val)}
     199            3 :                              selectOptions={selectOptions} />
     200              :         );
     201            3 :     }
     202          125 : }
        

Generated by: LCOV version 2.0-1