diff --git a/public/themes/default.css b/public/themes/default.css index a7c1ceb58..82bcce0c4 100644 --- a/public/themes/default.css +++ b/public/themes/default.css @@ -7,16 +7,6 @@ :root { --fa-style-family: "Font Awesome 6 Sharp"; - /* these variables are overridden by user settings */ - /* - --termfontfamily: "Hack"; - --termfontsize: 13px; - --termlineheight: 15px; - --termpad: 7px; - --termfontsize-sm: 11px; - --termlineheight-sm: 13px; - */ - /* base fonts */ --base-font: normal 15px / normal "Lato", sans-serif; @@ -52,12 +42,10 @@ --app-bg-color: black; --app-accent-color: rgb(88, 193, 66); --app-accent-bg-color: rgba(88, 193, 66, 0.25); - --app-error-color: rgb(229, 77, 46); - --app-warning-color: rgb(224, 185, 86); - --app-success-color: rgb(78, 154, 6); --app-text-color: rgb(211, 215, 207); --app-text-primary-color: rgb(255, 255, 255); --app-text-secondary-color: rgb(195, 200, 194); + --app-text-disabled-color: rgb(173, 173, 173); --app-border-color: rgb(51, 51, 51); --app-maincontent-bg-color: #333; --app-panel-bg-color: rgba(21, 23, 21, 1); @@ -65,17 +53,15 @@ --app-icon-color: rgb(139, 145, 138); --app-icon-hover-color: #fff; --app-selected-mask-color: rgba(255, 255, 255, 0.06); - - /* icon colors */ + --app-error-color: rgb(229, 77, 46); + --app-warning-color: rgb(224, 185, 86); + --app-success-color: rgb(78, 154, 6); + --app-idle-color: var(--app-text-secondary-color); /* just for macos */ --app-border-radius-darwin: 10px; - /* global generic colors */ - --app-black: rgb(0, 0, 0); - /* scrollbar colors */ - /* --scrollbar-background-color: rgba(21, 23, 21, 1); */ --scrollbar-background-color: var(--app-bg-color); --scrollbar-thumb-color: rgba(255, 255, 255, 0.3); --scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.5); @@ -116,28 +102,9 @@ --markdown-bg-color: rgb(35, 35, 35); --markdown-outline-color: var(--form-element-primary-color); - /* status(remote) colors */ - /* todo: all status colors must be unified */ - --status-connected-color: var(--app-success-color); - --status-connecting-color: var(--app-warning-color); - --status-error-color: var(--app-error-color); - --status-disconnected-color: var(--app-text-secondary-color); - - /* status indicator colors */ - /* todo: all status colors must be unified */ - --status-indicator-color: var(--app-text-color); - --status-indicator-error: var(--status-error-color); - --status-indicator-success: var(--status-connected-color); - - /* status(version) colors */ - /* todo: all status colors must be unified */ - --status-outdated-color: var(--status-connecting-color); - --status-updated-color: var(--status-connected-color); - /* term status colors */ - /* todo: all status colors must be unified */ - --term-error-color: var(--status-error-color); - --term-warning-color: var(--status-connecting-color); + --term-error-color: var(--app-error-color); + --term-warning-color: var(--app-warning-color); /* hotkey colors */ --hotkey-text-color: var(--app-text-secondary-color); @@ -178,17 +145,6 @@ --line-actions-active-color: rgba(255, 255, 255, 1); --line-actions-bg-color: rgba(255, 255, 255, 0.15); - /* view colors */ - /* todo: bookmarks is a view, colors must be unified with --view* colors */ - --bookmarks-text-color: rgb(211, 215, 207); - --bookmarks-textarea-bg-color: rgb(0, 0, 0); - --bookmarks-disabled-text-color: rgb(173, 173, 173); - --bookmarks-control-hover-color: rgb(255, 255, 255); - - /* view colors */ - --view-error-color: var(--app-error-color); - --view-text-color: var(--app-text-color); - /* table colors */ --table-border-color: rgba(241, 246, 243, 0.15); --table-thead-border-top-color: rgba(250, 250, 250, 0.1); @@ -199,9 +155,6 @@ --table-tr-selected-bg-color: #222; --table-tr-selected-hover-bg-color: #333; - /* session colors */ - --session-bg-color: rgba(13, 13, 13, 0.85); - /* cmdinput colors */ --cmdinput-textarea-bg-color: #171717; --cmdinput-text-error-color: var(--term-red); @@ -223,4 +176,12 @@ --modal-header-bottom-border-color: rgba(241, 246, 243, 0.15); --logo-button-hover-bg-color: var(--app-accent-bg-color); + + --xterm-viewport-border-color: rgba(241, 246, 243, 0.15); + + --datepicker-cell-hover-bg-color: rgba(255, 255, 255, 0.06); + --datepicker-cell-other-text-color: rgba(255, 255, 255, 0.3); + --datepicker-header-fade-color: rgba(255, 255, 255, 0.4); + --datepicker-year-header-bg-color: rgba(255, 255, 255, 0.2); /* Light grey background */ + --datepicker-year-header-border-color: rgba(241, 246, 243, 0.15); /* Light grey border */ } diff --git a/public/themes/light.css b/public/themes/light.css index 70179e148..66c5aec0b 100644 --- a/public/themes/light.css +++ b/public/themes/light.css @@ -31,7 +31,7 @@ --button-secondary-bg-color: rgba(200, 200, 200, 0.3); /* Semi-transparent light gray */ /* view colors */ - --view-text-color: var(--app-text-color); + --app-text-color: var(--app-text-color); /* table colors */ --table-thead-border-top-color: rgba(0, 0, 0, 0.2); @@ -71,6 +71,16 @@ --line-actions-inactive-color: rgba(0, 0, 0, 0.3); --line-actions-active-color: rgba(0, 0, 0, 1); + --logo-button-hover-bg-color: #f0f0f0; + + --xterm-viewport-border-color: rgba(0, 0, 0, 0.3); + + --datepicker-cell-hover-bg-color: #f0f0f0; + --datepicker-cell-other-text-color: rgba(0, 0, 0, 0.3); + --datepicker-header-fade-color: rgba(0, 0, 0, 0.4); + --datepicker-year-header-bg-color: #f5f5f5; + --datepicker-year-header-border-color: #dcdcdc; + /* toggle colors */ --toggle-thumb-color: var(--app-bg-color); } diff --git a/src/app/app.less b/src/app/app.less index 929b3294c..41e84754b 100644 --- a/src/app/app.less +++ b/src/app/app.less @@ -735,28 +735,28 @@ a.a-block { .status-icon.status-connected { path, circle { - fill: var(--status-connected-color); + fill: var(--app-success-color); } } .status-icon.status-connecting { path, circle { - fill: var(--status-connecting-color); + fill: var(--app-warning-color); } } .status-icon.status-disconnected { path, circle { - fill: var(--status-disconnected-color); + fill: var(--app-idle-color); } } .status-icon.status-error { path, circle { - fill: var(--status-error-color); + fill: var(--app-text-disabled-color); } } diff --git a/src/app/bookmarks/bookmarks.less b/src/app/bookmarks/bookmarks.less index 65049b41d..b2d9e903d 100644 --- a/src/app/bookmarks/bookmarks.less +++ b/src/app/bookmarks/bookmarks.less @@ -6,11 +6,11 @@ } .bookmarks-list { - color: var(--bookmarks-text-color); + color: var(--app-text-color); margin: 4px 10px 5px 5px; .no-bookmarks { - color: var(--bookmarks-text-color); + color: var(--app-text-color); padding: 30px 10px 35px 10px; border-bottom: 1px solid white; } @@ -46,21 +46,21 @@ } label { - color: var(--bookmarks-text-color); + color: var(--app-text-color); margin-bottom: 4px; } textarea { width: 80%; min-width: 50%; - color: var(--bookmarks-text-color); - background-color: var(--bookmarks-textarea-bg-color); + color: var(--app-text-color); + background-color: var(--form-element-bg-color); } .bookmark-id-div { display: none; position: absolute; - color: var(--bookmarks-disabled-text-color); + color: var(--app-text-disabled-color); right: 5px; bottom: 2px; font-size: 0.8em; @@ -75,7 +75,7 @@ flex-direction: row; visibility: hidden; - color: var(--bookmarks-text-color); + color: var(--app-text-color); .bookmark-control:first-child { margin-left: 0; @@ -85,10 +85,6 @@ margin-left: 10px; cursor: pointer; padding: 2px; - - &:hover { - color: var(--bookmarks-control-hover-color); - } } } diff --git a/src/app/common/elements/button.less b/src/app/common/elements/button.less index d6090577f..6ce1eef79 100644 --- a/src/app/common/elements/button.less +++ b/src/app/common/elements/button.less @@ -1,8 +1,6 @@ .wave-button { background: none; - color: inherit; border: none; - font: inherit; cursor: pointer; outline: inherit; display: flex; @@ -12,64 +10,64 @@ border-radius: 6px; height: auto; line-height: 1.5; + display: block; + white-space: nowrap; + + color: var(--form-element-text-color); + background: var(--form-element-primary-color); + i { + fill: var(--form-element-text-color); + } &.primary { color: var(--form-element-text-color); background: var(--form-element-primary-color); + i { + fill: var(--form-element-text-color); + } + } + + &.primary.outlined { + background: none; + border: 1px solid var(--form-element-primary-color); i { fill: var(--form-element-primary-color); } + } - &.solid { - color: var(--form-element-text-color); - background: var(--form-element-primary-color); - - i { - fill: var(--form-element-text-color); - } - } - - &.outlined { - background: none; - border: 1px solid var(--form-element-primary-color); - } - - &.ghost { - background: none; - } - - &:hover { - color: var(--form-element-text-color); + &.primary.ghost { + background: none; + i { + fill: var(--form-element-primary-color); } } &.secondary { - color: var(--form-element-text-color); - background: none; color: var(--form-element-text-color); background: var(--form-element-secondary-color); - box-shadow: none; - - &.solid { - color: var(--form-element-text-color); - background: var(--form-element-secondary-color); - box-shadow: none; + i { + fill: var(--form-element-text-color); } + } - &.outlined { - background: none; - border: 1px solid var(--form-element-secondary-color); - } + &.secondary.outlined { + background: none; + border: 1px solid var(--form-element-text-color); + } - &.ghost { - background: none; - padding: 6px 10px; + &.secondary.ghost { + background: none; + } - i { - fill: var(--form-element-primary-color); - } - } + &.secondary.danger { + color: var(--app-text-disabled-color); + } + + &.small { + padding: 4px 8px; + font-size: 12px; + border-radius: 3.6px; } &.term-inline { diff --git a/src/app/common/elements/datepicker.less b/src/app/common/elements/datepicker.less new file mode 100644 index 000000000..88b3a631a --- /dev/null +++ b/src/app/common/elements/datepicker.less @@ -0,0 +1,183 @@ +.day-picker-modal { + background-color: var(--form-element-bg-color); + z-index: 1000; + width: 250px; + height: 293px; + font-size: 13px; + border: 1px solid var(--form-element-border-color); + border-radius: 4px; + color: var(--form-element-text-color); +} + +.day-picker-input { + cursor: default; + border: 1px solid var(--form-element-border-color) !important; + height: 34px; + user-select: none; + display: flex; + border-radius: 4px; + justify-content: space-between; + align-items: center; + padding: 0 15px !important; + display: flex; + align-items: center; + justify-content: center; + + input { + outline: none !important; + border: none !important; + height: 100%; + padding: 0 !important; + cursor: default !important; + } + + .fa-calendar { + margin-left: 10px; + cursor: pointer; + } +} + +.day-picker-header { + color: var(--form-element-text-color); + padding: 10px 10px 0; + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; + position: sticky; + + div { + cursor: default; + font-weight: bold; + user-select: none; + + &.fade { + color: var(--datepicker-header-fade-color); + } + } + + .arrows { + display: flex; + + .wave-button { + padding: 5px !important; + font-size: 18px !important; + cursor: default !important; + } + } +} + +.day-header { + display: flex; + color: var(--form-element-text-color); + padding: 0 10px; + + .day-header-cell { + flex: 1; + padding: 10px; + color: var(--form-element-text-color); + text-align: center; + font-weight: bold; + user-select: none; + } +} + +.day-picker { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 0; + padding: 0 10px 10px; + + .day { + padding: 10px 4px; + color: var(--form-element-text-color); + text-align: center; + cursor: default; + border-radius: 3px; + + &:hover { + background-color: var(--datepicker-cell-hover-bg-color); + } + &.selected { + background-color: var(--form-element-primary-color); + font-weight: bold; + } + } + + .other-month { + color: var(--datepicker-cell-other-text-color); + } +} + +.year-month-accordion-wrapper { + overflow-y: auto; + height: 243px; + margin-top: 8px; + padding: 0 10px 10px; + + .year-month-accordion { + color: var(--form-element-text-color); + } + + .year-header { + padding: 5px 10px; + background-color: var(--datepicker-year-header-bg-color); + border-bottom: 1px solid var(--datepicker-year-header-border-color); + margin-bottom: 5px; + font-weight: bold; + text-align: center; + cursor: default; + color: var(--form-element-text-color); + user-select: none; + + &:last-child { + margin-bottom: 0; + } + } + + .month-container { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 5px; + padding: 10px; + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; + cursor: default; + } + + .month { + padding: 5px; + text-align: center; + border-radius: 3px; + color: var(--form-element-text-color); + + &:hover { + background-color: var(--datepicker-cell-hover-bg-color); + } + + &.selected { + background-color: var(--form-element-primary-color); + } + } + + .expanded { + max-height: 500px; + } +} + +.dropdown-arrow { + display: inline-block; + margin-left: 5px; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid var(--form-element-text-color); + vertical-align: middle; + user-select: none; + + &.fade { + border-top: 5px solid var(--datepicker-header-fade-color); + } +} diff --git a/src/app/common/elements/datepicker.tsx b/src/app/common/elements/datepicker.tsx new file mode 100644 index 000000000..f045eda9e --- /dev/null +++ b/src/app/common/elements/datepicker.tsx @@ -0,0 +1,446 @@ +import React, { useState, useEffect, useRef, createRef } from "react"; +import ReactDOM from "react-dom"; +import dayjs from "dayjs"; +import cs from "classnames"; +import { Button } from "@/elements"; +import { If } from "tsx-control-statements/components"; + +import "./datepicker.less"; + +interface YearRefs { + [key: number]: React.RefObject; +} + +type DatePickerProps = { + selectedDate: Date; + onSelectDate: (date: Date) => void; + format?: string; +}; + +const DatePicker: React.FC = ({ selectedDate, format = "MM/DD/YYYY", onSelectDate }) => { + const [isOpen, setIsOpen] = useState(false); + const [selDate, setSelDate] = useState(dayjs(selectedDate)); // Initialize with dayjs object + const [showYearAccordion, setShowYearAccordion] = useState(false); + const [expandedYear, setExpandedYear] = useState(selDate.year()); + const yearRefs = useRef({}); + const wrapperRef = useRef(null); + const modalRef = useRef(null); + const calendarIconRef = useRef(null); + const inputRefs = useRef({ YYYY: null, MM: null, DD: null }); + // Extract delimiter using regex + const delimiter = format.replace(/[0-9YMD]/g, "")[0] || "/"; + // Split format and create state for each part + const formatParts = format.split(delimiter); + const [dateParts, setDateParts] = useState({ + YYYY: selDate.format("YYYY"), + MM: selDate.format("MM"), + DD: selDate.format("DD"), + }); + + useEffect(() => { + inputRefs.current = { + YYYY: createRef(), + MM: createRef(), + DD: createRef(), + }; + }, []); + + useEffect(() => { + if (showYearAccordion && expandedYear && yearRefs.current[expandedYear]) { + yearRefs.current[expandedYear].current?.scrollIntoView({ + block: "nearest", + }); + } + }, [showYearAccordion, expandedYear]); + + useEffect(() => { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + useEffect(() => { + setDateParts({ + YYYY: selDate.format("YYYY"), + MM: selDate.format("MM"), + DD: selDate.format("DD"), + }); + }, [selDate]); + + const handleClickOutside = (event: MouseEvent) => { + // Check if the click is on the calendar icon + if (calendarIconRef.current && calendarIconRef.current.contains(event.target as Node)) { + // Click is on the calendar icon, do nothing + return; + } + + // Check if the click is outside the modal + if (modalRef.current && !modalRef.current.contains(event.target as Node)) { + setIsOpen(false); // Close the modal + } + }; + + const handleDayClick = (date: Date) => { + const newDate = dayjs(date); + setSelDate(newDate); // Update selDate with the new dayjs object + onSelectDate && onSelectDate(date); // Call parent's onSelectDate + setIsOpen(false); // Close the picker + }; + + const changeMonth = (delta: number) => { + const newDate = selDate.add(delta, "month"); + setSelDate(newDate); + onSelectDate && onSelectDate(newDate.toDate()); + }; + + const renderHeader = () => { + return ( +
+
{ + if (!showYearAccordion) { + setExpandedYear(selDate.year()); // Set expandedYear when opening accordion + } + setShowYearAccordion(!showYearAccordion); + }} + > + {selDate.format("MMMM YYYY")} + +
+ +
+ + +
+
+
+ ); + }; + + const renderDayHeaders = () => { + const daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"]; // First letter of each day + return ( +
+ {daysOfWeek.map((day, i) => ( +
+ {day} +
+ ))} +
+ ); + }; + + const renderDays = () => { + const days = []; + const startDay = selDate.startOf("month"); + const endDay = selDate.endOf("month"); + const startDate = startDay.day(); // 0 for Sunday, 1 for Monday, ..., 6 for Saturday + + // Previous month's filler days + const previousMonth = startDay.subtract(1, "month"); + const daysInPreviousMonth = previousMonth.daysInMonth(); + for (let i = daysInPreviousMonth - startDate + 1; i <= daysInPreviousMonth; i++) { + const dayDate = previousMonth.date(i); + days.push( +
handleDayClick(dayDate.toDate())} + > + {i} +
+ ); + } + + // Current month's days + for ( + let dayCount = 1; + startDay.add(dayCount - 1, "day").isBefore(endDay) || + startDay.add(dayCount - 1, "day").isSame(endDay, "day"); + dayCount++ + ) { + const currentDate = startDay.add(dayCount - 1, "day"); + days.push( +
handleDayClick(currentDate.toDate())} + > + {dayCount} +
+ ); + } + + // Next month's filler days + while (days.length < 42) { + const fillerDayCount = days.length - daysInPreviousMonth - endDay.date(); + const dayDate = endDay.add(fillerDayCount + 1, "day"); + days.push( +
handleDayClick(dayDate.toDate())} + > + {dayDate.date()} +
+ ); + } + + return days; + }; + + const calculatePosition = (): React.CSSProperties => { + if (wrapperRef.current) { + const rect = wrapperRef.current.getBoundingClientRect(); + return { + position: "absolute", + top: `${rect.bottom + window.scrollY + 2}px`, + left: `${rect.left + window.scrollX}px`, + }; + } + return {}; + }; + + const populateYears = () => { + const currentYear = dayjs().year(); + const startYear = currentYear - 10; + const endYear = currentYear + 10; + const yearsRange = []; + + for (let year = startYear; year <= endYear; year++) { + yearsRange.push(year); + yearRefs.current[year] = React.createRef(); + } + + return yearsRange; + }; + + const handleMonthYearSelect = (month: number, year: number) => { + const newDate = dayjs(new Date(year, month - 1)); + setSelDate(newDate); + setShowYearAccordion(false); // Close accordion + onSelectDate && onSelectDate(newDate.toDate()); + }; + + const renderYearMonthAccordion = () => { + const years = populateYears(); + const currentYear = selDate.year(); + + return ( +
+
+ {years.map((year) => ( +
+
setExpandedYear(year === expandedYear ? null : year)} + > + {year} +
+ +
+ {Array.from({ length: 12 }, (_, i) => i + 1).map((month) => ( +
handleMonthYearSelect(month, year)} + > + {dayjs(new Date(year, month - 1)).format("MMM")} +
+ ))} +
+
+
+ ))} +
+
+ ); + }; + + const toggleModal = () => { + setIsOpen((prevIsOpen) => !prevIsOpen); + setShowYearAccordion(false); + }; + + const dayPickerModal = isOpen + ? ReactDOM.createPortal( +
+ {renderHeader()} + {showYearAccordion && renderYearMonthAccordion()} + + <> + {renderDayHeaders()} +
{renderDays()}
+ +
+
, + document.getElementById("app")! + ) + : null; + + const handleDatePartChange = (part, value) => { + const newDateParts = { ...dateParts, [part]: value }; + setDateParts(newDateParts); + + // Construct a new date from the updated parts + const newDate = dayjs(`${newDateParts.YYYY}-${newDateParts.MM}-${newDateParts.DD}`); + if (newDate.isValid()) { + onSelectDate(newDate.toDate()); // Call onSelectDate with the new date + } + }; + + const handleArrowNavigation = (event, currentPart) => { + const currentIndex = formatParts.indexOf(currentPart); + let targetInput; + + if (event.key === "ArrowLeft" && currentIndex > 0) { + targetInput = inputRefs.current[formatParts[currentIndex - 1]].current; + } else if (event.key === "ArrowRight" && currentIndex < formatParts.length - 1) { + targetInput = inputRefs.current[formatParts[currentIndex + 1]].current; + } + + if (targetInput) { + targetInput.focus(); + } + }; + + const handleKeyDown = (event, currentPart) => { + const key = event.key; + + if (key === "ArrowLeft" || key === "ArrowRight") { + // Handle arrow navigation without selecting text + handleArrowNavigation(event, currentPart); + return; + } + + if (key === " ") { + // Handle spacebar press to toggle the modal + toggleModal(); + return; + } + + if (key.match(/[0-9]/)) { + // Handle numeric keys + event.preventDefault(); + const maxLength = currentPart === "YYYY" ? 4 : 2; + const newValue = event.target.value.length < maxLength ? event.target.value + key : key; + let selectionTimeoutId = null; + handleDatePartChange(currentPart, newValue); + + // Clear any existing timeout + if (selectionTimeoutId !== null) { + clearTimeout(selectionTimeoutId); + } + + // Re-focus and select the input after state update + selectionTimeoutId = setTimeout(() => { + event.target.focus(); + event.target.select(); + }, 0); + } + }; + + const handleFocus = (event) => { + event.target.select(); + }; + + // Prevent use from selecting text in the input + const handleMouseDown = (event) => { + event.preventDefault(); + + handleFocus(event); + }; + + const handleIconKeyDown = (event) => { + if (event.key === "Enter") { + toggleModal(); + } + }; + + const setInputWidth = (inputRef, value) => { + const span = document.createElement("span"); + document.body.appendChild(span); + span.style.font = "inherit"; + span.style.visibility = "hidden"; + span.style.position = "absolute"; + span.textContent = value; + const textWidth = span.offsetWidth; + document.body.removeChild(span); + + if (inputRef.current) { + inputRef.current.style.width = `${textWidth}px`; + } + }; + + useEffect(() => { + // This timeout ensures that the effect runs after the DOM updates + const timeoutId = setTimeout(() => { + formatParts.forEach((part) => { + const inputRef = inputRefs.current[part]; + if (inputRef && inputRef.current) { + setInputWidth(inputRef, dateParts[part]); + } + }); + }, 0); + + return () => clearTimeout(timeoutId); // Cleanup timeout on unmount + }, []); + + const renderDatePickerInput = () => { + return ( +
+ {formatParts.map((part, index) => { + const inputRef = inputRefs.current[part]; + + return ( + + {index > 0 && {delimiter}} + handleDatePartChange(part, e.target.value)} + onKeyDown={(e) => handleKeyDown(e, part)} + onMouseDown={handleMouseDown} + onFocus={handleFocus} + maxLength={part === "YYYY" ? 4 : 2} + className="date-input" + placeholder={part} + /> + + ); + })} + +
+ ); + }; + + return ( +
+ {renderDatePickerInput()} + {dayPickerModal} +
+ ); +}; + +export { DatePicker }; diff --git a/src/app/common/elements/index.tsx b/src/app/common/elements/index.tsx index ed4210c1f..75f99280e 100644 --- a/src/app/common/elements/index.tsx +++ b/src/app/common/elements/index.tsx @@ -17,3 +17,4 @@ export { TextField } from "./textfield"; export { Toggle } from "./toggle"; export { Tooltip } from "./tooltip"; export { TabIcon } from "./tabicon"; +export { DatePicker } from "./datepicker"; diff --git a/src/app/common/elements/status.less b/src/app/common/elements/status.less index b05d8a219..3a27ce5ba 100644 --- a/src/app/common/elements/status.less +++ b/src/app/common/elements/status.less @@ -11,18 +11,18 @@ } .dot.green { - background-color: var(--status-connected-color); + background-color: var(--app-success-color); } .dot.red { - background-color: var(--status-error-color); + background-color: var(--app-text-disabled-color); } .dot.gray { - background-color: var(--status-disconnected-color); + background-color: var(--app-idle-color); } .dot.yellow { - background-color: var(--status-connecting-color); + background-color: var(--app-warning-color); } } diff --git a/src/app/common/icons/icons.less b/src/app/common/icons/icons.less index f00d6bc62..87530878b 100644 --- a/src/app/common/icons/icons.less +++ b/src/app/common/icons/icons.less @@ -73,18 +73,18 @@ The following accounts for a debounce in the status indicator. We will only disp visibility: hidden; } .spin #spinner { - stroke: var(--status-indicator-color); + stroke: var(--app-text-color); } &.error #indicator { visibility: visible; - fill: var(--status-indicator-error); + fill: var(--app-text-disabled-color); } &.success #indicator { visibility: visible; - fill: var(--status-indicator-success); + fill: var(--app-success-color); } &.output #indicator { visibility: visible; - fill: var(--status-indicator-color); + fill: var(--app-text-color); } } diff --git a/src/app/common/modals/about.less b/src/app/common/modals/about.less index 4f72b4897..444ff8d37 100644 --- a/src/app/common/modals/about.less +++ b/src/app/common/modals/about.less @@ -70,7 +70,7 @@ margin-bottom: 5px; i { - color: var(--status-updated-color); + color: var(--app-success-color); } } } @@ -78,7 +78,7 @@ .status.outdated { div { i { - color: var(--status-outdated-color); + color: var(--app-warning-color); } } diff --git a/src/app/common/modals/alert.tsx b/src/app/common/modals/alert.tsx index a0fb5fb58..59800f8a5 100644 --- a/src/app/common/modals/alert.tsx +++ b/src/app/common/modals/alert.tsx @@ -14,7 +14,7 @@ import "./alert.less"; class AlertModal extends React.Component<{}, {}> { @boundMethod closeModal(): void { - GlobalModel.cancelAlert(); + GlobalModel.modalsModel.popModal(() => GlobalModel.cancelAlert()); } @boundMethod diff --git a/src/app/common/modals/screensettings.tsx b/src/app/common/modals/screensettings.tsx index d1813f68b..164009524 100644 --- a/src/app/common/modals/screensettings.tsx +++ b/src/app/common/modals/screensettings.tsx @@ -10,7 +10,7 @@ import cn from "classnames"; import { GlobalModel, GlobalCommandRunner, Screen } from "@/models"; import { Toggle, InlineSettingsTextEdit, SettingsError, Modal, Dropdown, Tooltip } from "@/elements"; import * as util from "@/util/util"; -import { TabIcon } from "@/common/elements/tabicon"; +import { TabIcon, Button } from "@/elements"; import { ReactComponent as GlobeIcon } from "@/assets/icons/globe.svg"; import { ReactComponent as StatusCircleIcon } from "@/assets/icons/statuscircle.svg"; import * as appconst from "@/app/appconst"; @@ -334,12 +334,9 @@ class ScreenSettingsModal extends React.Component<{}, {}> {
-
+
+
diff --git a/src/app/common/modals/sessionsettings.tsx b/src/app/common/modals/sessionsettings.tsx index 31448c34e..d48fabf14 100644 --- a/src/app/common/modals/sessionsettings.tsx +++ b/src/app/common/modals/sessionsettings.tsx @@ -6,6 +6,7 @@ import * as mobxReact from "mobx-react"; import * as mobx from "mobx"; import { boundMethod } from "autobind-decorator"; import { GlobalModel, GlobalCommandRunner, Session } from "@/models"; +import { Button } from "@/elements"; import { Toggle, InlineSettingsTextEdit, SettingsError, Modal, Tooltip } from "@/elements"; import * as util from "@/util/util"; @@ -133,12 +134,9 @@ class SessionSettingsModal extends React.Component<{}, {}> {
-
+
+
diff --git a/src/app/common/modals/viewremoteconndetail.tsx b/src/app/common/modals/viewremoteconndetail.tsx index 2a318fbab..36a14120d 100644 --- a/src/app/common/modals/viewremoteconndetail.tsx +++ b/src/app/common/modals/viewremoteconndetail.tsx @@ -192,7 +192,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> { ); let archiveButton = ( - ); @@ -207,7 +207,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> { } if (remote.sshconfigsrc == "sshconfig-import") { archiveButton = ( - diff --git a/src/app/workspace/screen/screenview.less b/src/app/workspace/screen/screenview.less index 17ade0bd9..c8fdd3c31 100644 --- a/src/app/workspace/screen/screenview.less +++ b/src/app/workspace/screen/screenview.less @@ -95,33 +95,6 @@ height: 100%; overflow-x: hidden; - .rendermode-tag { - position: absolute; - top: 0; - right: 0; - background-color: rgba(78, 154, 6, 0.65); - color: var(--app-black); - padding: 2px 8px 2px 4px; - border-bottom-left-radius: 5px; - z-index: 10; - - &.is-active { - color: var(--app-text-color); - } - - .render-mode { - padding-top: 2px; - - position: relative; - cursor: pointer; - color: var(--app-text-color); - - &:hover { - color: var(--app-text-color); - } - } - } - .window-empty { display: flex; align-items: center; @@ -129,11 +102,11 @@ width: 100%; padding: 10px; height: 100%; - color: #ccc; + color: var(--app-text-color); code { background-color: transparent; - color: #4e9a06; + color: var(--term-green); } &.should-fade { @@ -142,44 +115,6 @@ } } - .share-tag { - color: var(--app-text-color); - position: absolute; - top: 0; - left: 40%; - background-color: darken(rgb(0, 177, 10), 20%); - padding: 2px 8px 2px 4px; - z-index: 11; - - /* border-radius: 0 0 5px 5px; */ - opacity: 0.8; - display: flex; - flex-direction: column; - - .share-tag-link { - margin-top: 10px; - display: none; - } - - &:hover { - .share-tag-title { - font-weight: bold; - } - opacity: 1; - padding: 20px; - width: 250px; - border: 1px solid #ccc; - border-top: 0; - - .share-tag-link { - display: flex; - flex-direction: row; - align-items: center; - gap: 10px; - } - } - } - .filter-running { position: relative; display: flex; diff --git a/src/app/workspace/screen/screenview.tsx b/src/app/workspace/screen/screenview.tsx index fcef25aa1..47e7b4528 100644 --- a/src/app/workspace/screen/screenview.tsx +++ b/src/app/workspace/screen/screenview.tsx @@ -694,20 +694,6 @@ class ScreenWindowView extends React.Component<{ session: Session; screen: Scree let renderMode = this.renderMode.get(); return (
-
-
- - - - - - -
-
@@ -718,40 +704,13 @@ class ScreenWindowView extends React.Component<{ session: Session; screen: Scree
- [workspace="{session.name.get()}" screen="{screen.name.get()}"] + [workspace="{session.name.get()}" tab="{screen.name.get()}"]
- -
- -
- -
- web shared -
-
-
- copy link - - - -
-
- open settings - - - -
-
-
-
0}> /dev/null)"` +const GetK8sContextCmdStr = `printf "K8SCONTEXT %s\x00" "$(kubectl config current-context 2>/dev/null)"` +const GetK8sNamespaceCmdStr = `printf "K8SNAMESPACE %s\x00" "$(kubectl config view --minify --output 'jsonpath={..namespace}' 2>/dev/null)"` const RunCommandFmt = `%s` const DebugState = false @@ -239,7 +242,7 @@ func RunSimpleCmdInPty(ecmd *exec.Cmd) ([]byte, error) { return outputBuf.Bytes(), nil } -func parsePVarOutput(pvarBytes []byte, isZsh bool) map[string]*DeclareDeclType { +func parseExtVarOutput(pvarBytes []byte, promptOutput string, zmodsOutput string) map[string]*DeclareDeclType { declMap := make(map[string]*DeclareDeclType) pvars := bytes.Split(pvarBytes, []byte{0}) for _, pvarBA := range pvars { @@ -251,11 +254,35 @@ func parsePVarOutput(pvarBytes []byte, isZsh bool) map[string]*DeclareDeclType { if pvarFields[0] == "" { continue } - decl := &DeclareDeclType{IsZshDecl: isZsh, Args: "x"} + if pvarFields[1] == "" { + continue + } + decl := &DeclareDeclType{IsExtVar: true} decl.Name = "PROMPTVAR_" + pvarFields[0] decl.Value = shellescape.Quote(pvarFields[1]) declMap[decl.Name] = decl } + if promptOutput != "" { + decl := &DeclareDeclType{IsExtVar: true} + decl.Name = "PROMPTVAR_PS1" + decl.Value = promptOutput + declMap[decl.Name] = decl + } + if zmodsOutput != "" { + var zmods []string + lines := strings.Split(zmodsOutput, "\n") + for _, line := range lines { + fields := strings.Fields(line) + if len(fields) != 2 || fields[0] != "zmodload" { + continue + } + zmods = append(zmods, fields[1]) + } + decl := &DeclareDeclType{IsExtVar: true} + decl.Name = ZModsVarName + decl.Value = utilfn.QuickJson(zmods) + declMap[decl.Name] = decl + } return declMap } diff --git a/waveshell/pkg/shellapi/zshapi.go b/waveshell/pkg/shellapi/zshapi.go index 9735c6007..b4b3fa6af 100644 --- a/waveshell/pkg/shellapi/zshapi.go +++ b/waveshell/pkg/shellapi/zshapi.go @@ -30,6 +30,21 @@ const BaseZshOpts = `` const ZshShellVersionCmdStr = `echo zsh v$ZSH_VERSION` const StateOutputFdNum = 20 +const ( + ZshSection_Version = iota + ZshSection_Cwd + ZshSection_Env + ZshSection_Mods + ZshSection_Vars + ZshSection_Aliases + ZshSection_Fpath + ZshSection_Funcs + ZshSection_PVars + ZshSection_Prompt + + ZshSection_NumFieldsExpected // must be last +) + // TODO these need updating const RunZshSudoCommandFmt = `sudo -n -C %d zsh /dev/fd/%d` const RunZshSudoPasswordCommandFmt = `cat /dev/fd/%d | sudo -k -S -C %d zsh -c "echo '[from-mshell]'; exec %d>&-; zsh /dev/fd/%d < /dev/fd/%d"` @@ -54,6 +69,7 @@ var ZshIgnoreVars = map[string]bool{ "SHLVL": true, "TTY": true, "ZDOTDIR": true, + "PPID": true, "epochtime": true, "langinfo": true, "keymaps": true, @@ -77,6 +93,8 @@ var ZshIgnoreVars = map[string]bool{ "funcsourcetrace": true, "funcstack": true, "functrace": true, + "nameddirs": true, + "userdirs": true, "parameters": true, "commands": true, "functions": true, @@ -86,6 +104,25 @@ var ZshIgnoreVars = map[string]bool{ "_comps": true, "_patcomps": true, "_postpatcomps": true, + + // zsh/system + "errnos": true, + "sysparams": true, + + // zsh/curses + "ZCURSES_COLORS": true, + "ZCURSES_COLOR_PAIRS": true, + "zcurses_attrs": true, + "zcurses_colors": true, + "zcurses_keycodes": true, + "zcurses_windows": true, + + // not listed, but we also exclude all ZFTP_* variables +} + +var ZshIgnoreFuncs = map[string]bool{ + "zftp_chpwd": true, + "zftp_progress": true, } // only options we restore (other than ZshForceOptions) @@ -131,11 +168,13 @@ var ZshUnsetVars = []string{ "ZSH_EXECUTION_STRING", } -var ZshLoadMods = []string{ - "zsh/parameter", - "zsh/langinfo", +var ZshForceLoadMods = map[string]bool{ + "zsh/parameter": true, + "zsh/langinfo": true, } +const ZModsVarName = "WAVESTATE_ZMODS" + // do not use these directly, call GetLocalMajorVersion() var localZshMajorVersionOnce = &sync.Once{} var localZshMajorVersion = "" @@ -273,14 +312,29 @@ func (z zshShellApi) MakeRcFileStr(pk *packet.RunPacketType) string { rcBuf.WriteString(fmt.Sprintf("unsetopt %s\n", optName)) } } - for _, modName := range ZshLoadMods { + for modName := range ZshForceLoadMods { rcBuf.WriteString(fmt.Sprintf("zmodload %s\n", modName)) } + modDecl := getDeclByName(varDecls, ZModsVarName) + if modDecl != nil { + modsArr := utilfn.QuickParseJson[[]string](modDecl.Value) + for _, modName := range modsArr { + if !ZshForceLoadMods[modName] { + rcBuf.WriteString(fmt.Sprintf("zmodload %s\n", modName)) + } + } + } var postDecls []*shellenv.DeclareDeclType for _, varDecl := range varDecls { if ZshIgnoreVars[varDecl.Name] { continue } + if strings.HasPrefix(varDecl.Name, "ZFTP_") { + continue + } + if varDecl.IsExtVar { + continue + } if ZshUniqueArrayVars[varDecl.Name] && !varDecl.IsUniqueArray() { varDecl.AddFlag("U") } @@ -332,6 +386,9 @@ func (z zshShellApi) MakeRcFileStr(pk *packet.RunPacketType) string { rcBuf.WriteString("# error decoding zsh functions\n") } else { for fnKey, fnValue := range fnMap { + if ZshIgnoreFuncs[fnKey.ParamName] { + continue + } if fnValue == ZshFnAutoLoad { rcBuf.WriteString(fmt.Sprintf("autoload %s\n", shellescape.Quote(fnKey.ParamName))) } else { @@ -411,6 +468,8 @@ pwd; printf "[%SECTIONSEP%]"; env -0; printf "[%SECTIONSEP%]"; +zmodload -L +printf "[%SECTIONSEP%]"; typeset -p +H -m '*'; printf "[%SECTIONSEP%]"; for var in "${(@k)aliases}"; do @@ -448,10 +507,16 @@ for var in "${(@k)dis_functions_source}"; do done printf "[%SECTIONSEP%]"; [%GITBRANCH%] +[%K8SCONTEXT%] +[%K8SNAMESPACE%] +printf "[%SECTIONSEP%]"; +print -P "$PS1" ` cmd = strings.TrimSpace(cmd) cmd = strings.ReplaceAll(cmd, "[%ZSHVERSION%]", ZshShellVersionCmdStr) cmd = strings.ReplaceAll(cmd, "[%GITBRANCH%]", GetGitBranchCmdStr) + cmd = strings.ReplaceAll(cmd, "[%K8SCONTEXT%]", GetK8sContextCmdStr) + cmd = strings.ReplaceAll(cmd, "[%K8SNAMESPACE%]", GetK8sNamespaceCmdStr) cmd = strings.ReplaceAll(cmd, "[%PARTSEP%]", utilfn.ShellHexEscape(string(sectionSeparator[0:len(sectionSeparator)-1]))) cmd = strings.ReplaceAll(cmd, "[%SECTIONSEP%]", utilfn.ShellHexEscape(string(sectionSeparator))) cmd = strings.ReplaceAll(cmd, "[%OUTPUTFD%]", fmt.Sprintf("/dev/fd/%d", fdNum)) @@ -599,6 +664,9 @@ func ParseZshFunctions(fpathArr []string, fnBytes []byte, partSeparator []byte) if fnName == "zshexit" { continue } + if ZshIgnoreFuncs[fnName] { + continue + } if fnType == "functions" || fnType == "dis_functions" { fnBody[ZshParamKey{ParamType: fnType, ParamName: fnName}] = fnValue } @@ -609,10 +677,13 @@ func ParseZshFunctions(fpathArr []string, fnBytes []byte, partSeparator []byte) // ok, so the trick here is that we want to only include functions that are *not* autoloaded // the ones that are pending autoloading or come from a source file in fpath, can just be set to autoload for fnKey := range fnBody { + var inFpath bool source := fnSource[fnKey.ParamName] - if isSourceFileInFpath(fpathArr, source) { - fnBody[fnKey] = ZshFnAutoLoad - } else if strings.TrimSpace(fnBody[fnKey]) == ZshAutoloadFnBody { + if source != "" { + inFpath = isSourceFileInFpath(fpathArr, source) + } + isAutoloadFnBody := strings.TrimSpace(fnBody[fnKey]) == ZshAutoloadFnBody + if inFpath || isAutoloadFnBody { fnBody[fnKey] = ZshFnAutoLoad } } @@ -639,11 +710,11 @@ func (z zshShellApi) ParseShellStateOutput(outputBytes []byte) (*packet.ShellSta versionStr := string(outputBytes[0:firstZeroIdx]) sectionSeparator := outputBytes[firstZeroIdx+1 : firstDZeroIdx+2] partSeparator := sectionSeparator[0 : len(sectionSeparator)-1] - // 8 fields: version [0], cwd [1], env [2], vars [3], aliases [4], fpath [5], functions [6], pvars [7] - fields := bytes.Split(outputBytes, sectionSeparator) - if len(fields) != 8 { + // sections: see ZshSection_* consts + sections := bytes.Split(outputBytes, sectionSeparator) + if len(sections) != ZshSection_NumFieldsExpected { base.Logf("invalid -- numfields\n") - return nil, fmt.Errorf("invalid zsh shell state output, wrong number of fields, fields=%d", len(fields)) + return nil, fmt.Errorf("invalid zsh shell state output, wrong number of sections, section=%d", len(sections)) } rtn := &packet.ShellState{} rtn.Version = strings.TrimSpace(versionStr) @@ -653,10 +724,10 @@ func (z zshShellApi) ParseShellStateOutput(outputBytes []byte) (*packet.ShellSta if _, _, err := packet.ParseShellStateVersion(rtn.Version); err != nil { return nil, fmt.Errorf("invalid zsh shell state output, invalid version: %v", err) } - cwdStr := stripNewLineChars(string(fields[1])) + cwdStr := stripNewLineChars(string(sections[ZshSection_Cwd])) rtn.Cwd = cwdStr - zshEnv := parseZshEnv(fields[2]) - zshDecls, err := parseZshDecls(fields[3]) + zshEnv := parseZshEnv(sections[ZshSection_Env]) + zshDecls, err := parseZshDecls(sections[ZshSection_Vars]) if err != nil { base.Logf("invalid - parsedecls %v\n", err) return nil, err @@ -666,16 +737,15 @@ func (z zshShellApi) ParseShellStateOutput(outputBytes []byte) (*packet.ShellSta decl.ZshEnvValue = zshEnv[decl.ZshBoundScalar] } } - aliasMap := parseZshAliasStateOutput(fields[4], partSeparator) + aliasMap := parseZshAliasStateOutput(sections[ZshSection_Aliases], partSeparator) rtn.Aliases = string(EncodeZshMap(aliasMap)) - fpathStr := stripNewLineChars(string(string(fields[5]))) + fpathStr := stripNewLineChars(string(string(sections[ZshSection_Fpath]))) fpathArr := strings.Split(fpathStr, ":") - zshFuncs := ParseZshFunctions(fpathArr, fields[6], partSeparator) + zshFuncs := ParseZshFunctions(fpathArr, sections[ZshSection_Funcs], partSeparator) rtn.Funcs = string(EncodeZshMap(zshFuncs)) - pvarMap := parsePVarOutput(fields[7], true) + pvarMap := parseExtVarOutput(sections[ZshSection_PVars], string(sections[ZshSection_Prompt]), string(sections[ZshSection_Mods])) utilfn.CombineMaps(zshDecls, pvarMap) rtn.ShellVars = shellenv.SerializeDeclMap(zshDecls) - base.Logf("parse shellstate done\n") return rtn, nil } diff --git a/waveshell/pkg/shellenv/shellenv.go b/waveshell/pkg/shellenv/shellenv.go index 9414e8865..6f10ca477 100644 --- a/waveshell/pkg/shellenv/shellenv.go +++ b/waveshell/pkg/shellenv/shellenv.go @@ -22,6 +22,7 @@ const ( type DeclareDeclType struct { IsZshDecl bool + IsExtVar bool // set for "special" wave internal variables Args string Name string @@ -36,31 +37,31 @@ type DeclareDeclType struct { } func (d *DeclareDeclType) IsExport() bool { - return strings.Index(d.Args, "x") >= 0 + return strings.Contains(d.Args, "x") } func (d *DeclareDeclType) IsReadOnly() bool { - return strings.Index(d.Args, "r") >= 0 + return strings.Contains(d.Args, "r") } func (d *DeclareDeclType) IsZshScalarBound() bool { - return strings.Index(d.Args, "T") >= 0 + return strings.Contains(d.Args, "T") } func (d *DeclareDeclType) IsArray() bool { - return strings.Index(d.Args, "a") >= 0 + return strings.Contains(d.Args, "a") } func (d *DeclareDeclType) IsAssocArray() bool { - return strings.Index(d.Args, "A") >= 0 + return strings.Contains(d.Args, "A") } func (d *DeclareDeclType) IsUniqueArray() bool { - return d.IsArray() && strings.Index(d.Args, "U") >= 0 + return d.IsArray() && strings.Contains(d.Args, "U") } func (d *DeclareDeclType) AddFlag(flag string) { - if strings.Index(d.Args, flag) >= 0 { + if strings.Contains(d.Args, flag) { return } d.Args += flag @@ -101,13 +102,13 @@ func (d *DeclareDeclType) SortZshFlags() { } func (d *DeclareDeclType) DataType() string { - if strings.Index(d.Args, "a") >= 0 { + if strings.Contains(d.Args, "a") { return DeclTypeArray } - if strings.Index(d.Args, "A") >= 0 { + if strings.Contains(d.Args, "A") { return DeclTypeAssocArray } - if strings.Index(d.Args, "i") >= 0 { + if strings.Contains(d.Args, "i") { return DeclTypeInt } return DeclTypeNormal @@ -124,7 +125,15 @@ func FindVarDecl(decls []*DeclareDeclType, name string) *DeclareDeclType { // NOTE Serialize no longer writes the final null byte func (d *DeclareDeclType) Serialize() []byte { - if d.IsZshDecl { + if d.IsExtVar { + parts := []string{ + "e1", + d.Args, + d.Name, + d.Value, + } + return utilfn.EncodeStringArray(parts) + } else if d.IsZshDecl { d.SortZshFlags() parts := []string{ "z1", @@ -149,6 +158,15 @@ func (d *DeclareDeclType) Serialize() []byte { // return []byte(rtn) } +func (d *DeclareDeclType) UnescapedValue() string { + if d.IsExtVar { + return d.Value + } + ectx := simpleexpand.SimpleExpandContext{} + rtn, _ := simpleexpand.SimpleExpandPartialWord(ectx, d.Value, false) + return rtn +} + func DeclsEqual(compareName bool, d1 *DeclareDeclType, d2 *DeclareDeclType) bool { if d1.IsExport() != d2.IsExport() { return false @@ -164,7 +182,8 @@ func DeclsEqual(compareName bool, d1 *DeclareDeclType, d2 *DeclareDeclType) bool // envline should be valid func parseDeclLine(envLineBytes []byte) *DeclareDeclType { - if utilfn.EncodedStringArrayHasFirstKey(envLineBytes, "z1") { + esFirstVal := utilfn.EncodedStringArrayGetFirstVal(envLineBytes) + if esFirstVal == "z1" { parts, err := utilfn.DecodeStringArray(envLineBytes) if err != nil { return nil @@ -180,7 +199,7 @@ func parseDeclLine(envLineBytes []byte) *DeclareDeclType { ZshBoundScalar: parts[4], ZshEnvValue: parts[5], } - } else if utilfn.EncodedStringArrayHasFirstKey(envLineBytes, "b1") { + } else if esFirstVal == "b1" { parts, err := utilfn.DecodeStringArray(envLineBytes) if err != nil { return nil @@ -193,8 +212,25 @@ func parseDeclLine(envLineBytes []byte) *DeclareDeclType { Name: parts[2], Value: parts[3], } + } else if esFirstVal == "e1" { + parts, err := utilfn.DecodeStringArray(envLineBytes) + if err != nil { + return nil + } + if len(parts) != 4 { + return nil + } + return &DeclareDeclType{ + IsExtVar: true, + Args: parts[1], + Name: parts[2], + Value: parts[3], + } + } else if esFirstVal == "p1" { + // deprecated + return nil } - // legacy decoding (v0) + // legacy decoding (v0) (not an encoded string array) envLine := string(envLineBytes) eqIdx := strings.Index(envLine, "=") if eqIdx == -1 { diff --git a/waveshell/pkg/utilfn/utilfn.go b/waveshell/pkg/utilfn/utilfn.go index 4492c3fa2..643192307 100644 --- a/waveshell/pkg/utilfn/utilfn.go +++ b/waveshell/pkg/utilfn/utilfn.go @@ -7,6 +7,7 @@ import ( "bytes" "crypto/sha1" "encoding/base64" + "encoding/json" "errors" "fmt" "math" @@ -400,7 +401,7 @@ func DecodeStringArray(barr []byte) ([]string, error) { return rtn, nil } -func EncodedStringArrayHasFirstKey(encoded []byte, firstKey string) bool { +func EncodedStringArrayHasFirstVal(encoded []byte, firstKey string) bool { firstKeyBytes := NullEncodeStr(firstKey) if !bytes.HasPrefix(encoded, firstKeyBytes) { return false @@ -411,6 +412,18 @@ func EncodedStringArrayHasFirstKey(encoded []byte, firstKey string) bool { return false } +// on encoding error returns "" +// this is used to perform logic on first value without decoding the entire array +func EncodedStringArrayGetFirstVal(encoded []byte) string { + sepIdx := bytes.IndexByte(encoded, nullEncodeSepByte) + if sepIdx == -1 { + str, _ := NullDecodeStr(encoded) + return str + } + str, _ := NullDecodeStr(encoded[0:sepIdx]) + return str +} + // encodes a string, removing null/zero bytes (and separators '|') // a zero byte is encoded as "\0", a '\' is encoded as "\\", sep is encoded as "\s" // allows for easy double splitting (first on \x00, and next on "|") @@ -520,3 +533,22 @@ func CombineStrArrays(sarr1 []string, sarr2 []string) []string { } return rtn } + +func QuickJson(v interface{}) string { + barr, _ := json.Marshal(v) + return string(barr) +} + +func QuickParseJson[T any](s string) T { + var v T + _ = json.Unmarshal([]byte(s), &v) + return v +} + +func StrArrayToMap(sarr []string) map[string]bool { + m := make(map[string]bool) + for _, s := range sarr { + m[s] = true + } + return m +} diff --git a/wavesrv/cmd/main-server.go b/wavesrv/cmd/main-server.go index f106ac4d4..3e8b7f196 100644 --- a/wavesrv/cmd/main-server.go +++ b/wavesrv/cmd/main-server.go @@ -677,6 +677,40 @@ func HandleRunCommand(w http.ResponseWriter, r *http.Request) { WriteJsonSuccess(w, update) } +func CheckIsDir(dirHandler http.Handler, fileHandler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + configPath := r.URL.Path + configAbsPath, err := filepath.Abs(configPath) + if err != nil { + w.WriteHeader(500) + w.Write([]byte(fmt.Sprintf("error getting absolute path", err))) + return + } + configBaseDir := path.Join(scbase.GetWaveHomeDir(), "config") + configFullPath := path.Join(scbase.GetWaveHomeDir(), configAbsPath) + if !strings.HasPrefix(configFullPath, configBaseDir) { + w.WriteHeader(500) + w.Write([]byte(fmt.Sprintf("error: path is not in config folder"))) + return + } + fstat, err := os.Stat(configFullPath) + if errors.Is(err, fs.ErrNotExist) { + w.WriteHeader(404) + w.Write([]byte(fmt.Sprintf("file not found: ", configAbsPath))) + return + } else if err != nil { + w.WriteHeader(500) + w.Write([]byte(fmt.Sprintf("file stat err", err))) + return + } + if fstat.IsDir() { + AuthKeyMiddleWare(dirHandler).ServeHTTP(w, r) + } else { + AuthKeyMiddleWare(fileHandler).ServeHTTP(w, r) + } + }) +} + func AuthKeyMiddleWare(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reqAuthKey := r.Header.Get("X-AuthKey") @@ -857,6 +891,39 @@ func doShutdown(reason string) { }) } +func configDirHandler(w http.ResponseWriter, r *http.Request) { + log.Printf("running?") + configPath := r.URL.Path + configFullPath := path.Join(scbase.GetWaveHomeDir(), configPath) + dirFile, err := os.Open(configFullPath) + if err != nil { + w.WriteHeader(500) + w.Write([]byte(fmt.Sprintf("error opening specified dir: ", err))) + return + } + entries, err := dirFile.Readdir(0) + if err != nil { + w.WriteHeader(500) + w.Write([]byte(fmt.Sprintf("error getting files: ", err))) + return + } + var files []*packet.FileStatPacketType + for index := 0; index < len(entries); index++ { + curEntry := entries[index] + curFile := packet.MakeFileStatPacketFromFileInfo(curEntry, "", false) + files = append(files, curFile) + } + dirListJson, err := json.Marshal(files) + if err != nil { + w.WriteHeader(500) + w.Write([]byte(fmt.Sprintf("json err: ", err))) + return + } + w.WriteHeader(200) + w.Header().Set("Content-Type", "application/json") + w.Write(dirListJson) +} + func main() { scbase.BuildTime = BuildTime scbase.WaveVersion = WaveVersion @@ -953,7 +1020,9 @@ func main() { gr.HandleFunc("/api/write-file", AuthKeyWrap(HandleWriteFile)).Methods("POST") configPath := path.Join(scbase.GetWaveHomeDir(), "config") + "/" log.Printf("[wave] config path: %q\n", configPath) - gr.PathPrefix("/config/").Handler(AuthKeyMiddleWare(http.StripPrefix("/config/", http.FileServer(http.Dir(configPath))))) + isFileHandler := http.StripPrefix("/config/", http.FileServer(http.Dir(configPath))) + isDirHandler := http.HandlerFunc(configDirHandler) + gr.PathPrefix("/config/").Handler(CheckIsDir(isDirHandler, isFileHandler)) serverAddr := MainServerAddr if scbase.IsDevMode() { diff --git a/wavesrv/pkg/cmdrunner/cmdrunner.go b/wavesrv/pkg/cmdrunner/cmdrunner.go index 086250e10..58a1f05cf 100644 --- a/wavesrv/pkg/cmdrunner/cmdrunner.go +++ b/wavesrv/pkg/cmdrunner/cmdrunner.go @@ -3825,7 +3825,8 @@ func HistoryViewAllCommand(ctx context.Context, pk *scpacket.FeCommandPacketType if pk.Kwargs["fromts"] != "" { fromTs, err := resolvePosInt(pk.Kwargs["fromts"], 0) if err != nil { - return nil, fmt.Errorf("invalid fromts (must be unixtime (milliseconds): %v", err) + // no error here anymore (otherwise it jams up the frontend, just ignore and set to 0) + opts.FromTs = 0 } if fromTs > 0 { opts.FromTs = int64(fromTs) diff --git a/wavesrv/pkg/cmdrunner/shparse.go b/wavesrv/pkg/cmdrunner/shparse.go index 500ffd733..f28ee28da 100644 --- a/wavesrv/pkg/cmdrunner/shparse.go +++ b/wavesrv/pkg/cmdrunner/shparse.go @@ -214,6 +214,7 @@ var literalRtnStateCommands = []string{ "enable", "disable", "function", + "zmodload", } func getCallExprLitArg(callExpr *syntax.CallExpr, argNum int) string { diff --git a/wavesrv/pkg/remote/remote.go b/wavesrv/pkg/remote/remote.go index c78cf2976..97c249d11 100644 --- a/wavesrv/pkg/remote/remote.go +++ b/wavesrv/pkg/remote/remote.go @@ -1784,16 +1784,22 @@ func makeTermOpts(runPk *packet.RunPacketType) sstore.TermOpts { return sstore.TermOpts{Rows: int64(runPk.TermOpts.Rows), Cols: int64(runPk.TermOpts.Cols), FlexRows: runPk.TermOpts.FlexRows, MaxPtySize: DefaultMaxPtySize} } -// returns (ok, currentPSC) -// if ok is true, currentPSC will be nil -// if ok is false, currentPSC will be the existing pending state command (not nil) -func (msh *MShellProc) testAndSetPendingStateCmd(screenId string, rptr sstore.RemotePtrType, newCK *base.CommandKey) (bool, *base.CommandKey) { +// returns (ok, rct) +// if ok is true, rct will be nil +// if ok is false, rct will be the existing pending state command (not nil) +func (msh *MShellProc) testAndSetPendingStateCmd(screenId string, rptr sstore.RemotePtrType, newCK *base.CommandKey) (bool, *RunCmdType) { key := pendingStateKey{ScreenId: screenId, RemotePtr: rptr} msh.Lock.Lock() defer msh.Lock.Unlock() ck, found := msh.PendingStateCmds[key] if found { - return false, &ck + // we don't call GetRunningCmd here because we already hold msh.Lock + rct := msh.RunningCmds[ck] + if rct != nil { + return false, rct + } + // ok, so rct is nil (that's strange). allow command to proceed, but log + log.Printf("[warning] found pending state cmd with no running cmd: %s\n", ck) } if newCK != nil { msh.PendingStateCmds[key] = *newCK @@ -1883,15 +1889,14 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru if runPacket.ReturnState { newPSC = &runPacket.CK } - ok, existingPSC := msh.testAndSetPendingStateCmd(screenId, remotePtr, newPSC) + ok, existingRct := msh.testAndSetPendingStateCmd(screenId, remotePtr, newPSC) if !ok { - rct := msh.GetRunningCmd(*existingPSC) - if rct.Ephemeral { + if existingRct.Ephemeral { // if the existing command is ephemeral, we cancel it and continue - rct.EphCancled.Store(true) + existingRct.EphCancled.Store(true) } else { - line, _, err := sstore.GetLineCmdByLineId(ctx, screenId, existingPSC.GetCmdId()) - return nil, nil, makePSCLineError(*existingPSC, line, err) + line, _, err := sstore.GetLineCmdByLineId(ctx, screenId, existingRct.CK.GetCmdId()) + return nil, nil, makePSCLineError(existingRct.CK, line, err) } } if newPSC != nil { diff --git a/wavesrv/pkg/sstore/sstore.go b/wavesrv/pkg/sstore/sstore.go index 5610b4c43..d82a08b4f 100644 --- a/wavesrv/pkg/sstore/sstore.go +++ b/wavesrv/pkg/sstore/sstore.go @@ -751,16 +751,17 @@ func FeStateFromShellState(state *packet.ShellState) map[string]string { } rtn := make(map[string]string) rtn["cwd"] = state.Cwd - envMap := shellenv.EnvMapFromState(state) - if envMap["VIRTUAL_ENV"] != "" { - rtn["VIRTUAL_ENV"] = envMap["VIRTUAL_ENV"] + declMap := shellenv.DeclMapFromState(state) + if decl, ok := declMap["VIRTUAL_ENV"]; ok { + rtn["VIRTUAL_ENV"] = decl.UnescapedValue() } - if envMap["CONDA_DEFAULT_ENV"] != "" { - rtn["CONDA_DEFAULT_ENV"] = envMap["CONDA_DEFAULT_ENV"] + if decl, ok := declMap["CONDA_DEFAULT_ENV"]; ok { + rtn["CONDA_DEFAULT_ENV"] = decl.UnescapedValue() } - for key, val := range envMap { - if strings.HasPrefix(key, "PROMPTVAR_") && envMap[key] != "" { - rtn[key] = val + for _, decl := range declMap { + // works for both legacy and new IsExtVar decls + if strings.HasPrefix(decl.Name, "PROMPTVAR_") { + rtn[decl.Name] = decl.UnescapedValue() } } _, _, err := packet.ParseShellStateVersion(state.Version)